From 25bdc5f248502e2c119810c04aea77cbf90e469d Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Sat, 18 Apr 2026 05:50:03 +0000 Subject: [PATCH 01/41] feat(defi): add asset-leasing Anchor example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed-term leasing of SPL tokens with SPL collateral, per-second rent streaming, and Pyth-priced liquidation. Joins AMM / Escrow / Token Fundraiser / Pyth under the 'Financial Software' section. Why another DeFi primitive: leasing is the canonical 'time-bounded custody with collateral at risk' pattern. It exercises the vault PDA, clock-driven accruals, oracle-priced liquidation, and a keeper-incentive flow all in one program, so it makes a good teaching companion to the existing escrow and AMM examples which focus on one of those axes each. Design notes: - Lease PDA seeded by (lessor, lease_id); lessor can run multiple leases in parallel. - Leased and collateral tokens each sit in their own PDA-authored vault (authority = vault itself) — simpler signing than routing through the Lease PDA and keeps the seed surface small. - Rent accrues linearly against the collateral vault and is settled on every pay_rent, return_lease, and liquidate call. Rent never accrues past end_ts, so returning early does not accrue rent for unused time (and therefore no 'unused rent refund' is needed — documented in instructions/return_lease.rs). - Liquidation uses a Pyth PriceUpdateV2 account. We decode the layout by hand instead of pulling in pyth-solana-receiver-sdk because that crate currently has a transitive borsh conflict with anchor-lang 1.0.0 (oracles/pyth/anchor is flagged 'not building' in .github/.ghaignore for the same reason). - Bounty is applied to the *post-rent* collateral balance so the handler can never over-draw the vault. Tests (LiteSVM, tests/test_asset_leasing.rs) cover the full lifecycle: create → take → pay_rent → top_up → return, the happy-path liquidation with mocked Pyth price, the healthy-position liquidation rejection, and the two close_expired branches (cancel-listed + default-recovery). --- README.md | 6 + defi/asset-leasing/anchor/.gitignore | 7 + defi/asset-leasing/anchor/Anchor.toml | 20 + defi/asset-leasing/anchor/Cargo.toml | 15 + defi/asset-leasing/anchor/README.md | 58 ++ .../anchor/programs/asset-leasing/Cargo.toml | 49 + .../programs/asset-leasing/src/constants.rs | 28 + .../programs/asset-leasing/src/errors.rs | 33 + .../src/instructions/close_expired.rs | 177 ++++ .../src/instructions/create_lease.rs | 139 +++ .../src/instructions/liquidate.rs | 322 +++++++ .../asset-leasing/src/instructions/mod.rs | 17 + .../src/instructions/pay_rent.rs | 135 +++ .../src/instructions/return_lease.rs | 220 +++++ .../asset-leasing/src/instructions/shared.rs | 51 + .../src/instructions/take_lease.rs | 134 +++ .../src/instructions/top_up_collateral.rs | 73 ++ .../anchor/programs/asset-leasing/src/lib.rs | 78 ++ .../programs/asset-leasing/src/state/lease.rs | 68 ++ .../programs/asset-leasing/src/state/mod.rs | 2 + .../asset-leasing/tests/test_asset_leasing.rs | 880 ++++++++++++++++++ 21 files changed, 2512 insertions(+) create mode 100644 defi/asset-leasing/anchor/.gitignore create mode 100644 defi/asset-leasing/anchor/Anchor.toml create mode 100644 defi/asset-leasing/anchor/Cargo.toml create mode 100644 defi/asset-leasing/anchor/README.md create mode 100644 defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml create mode 100644 defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs create mode 100644 defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs create mode 100644 defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs create mode 100644 defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs create mode 100644 defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs create mode 100644 defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/mod.rs create mode 100644 defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_rent.rs create mode 100644 defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs create mode 100644 defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs create mode 100644 defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs create mode 100644 defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/top_up_collateral.rs create mode 100644 defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs create mode 100644 defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs create mode 100644 defi/asset-leasing/anchor/programs/asset-leasing/src/state/mod.rs create mode 100644 defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs diff --git a/README.md b/README.md index 6f6ecf0ed..123e3a5f5 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ Constant product AMM (x·y=k) — create liquidity pools, deposit and withdraw l [⚓ Anchor](./tokens/token-swap/anchor) [💫 Quasar](./tokens/token-swap/quasar) +### Asset Leasing + +Fixed-term leasing of SPL tokens with SPL collateral, per-second rent, and Pyth-priced liquidation — lessors list tokens, lessees post collateral, keepers liquidate undercollateralised positions. + +[⚓ Anchor](./defi/asset-leasing/anchor) + ### Escrow Peer-to-peer OTC trade — one user deposits token A and specifies how much token B they want. A counterparty fulfils the offer and both sides receive their tokens atomically. diff --git a/defi/asset-leasing/anchor/.gitignore b/defi/asset-leasing/anchor/.gitignore new file mode 100644 index 000000000..2e0446b07 --- /dev/null +++ b/defi/asset-leasing/anchor/.gitignore @@ -0,0 +1,7 @@ +.anchor +.DS_Store +target +**/*.rs.bk +node_modules +test-ledger +.yarn diff --git a/defi/asset-leasing/anchor/Anchor.toml b/defi/asset-leasing/anchor/Anchor.toml new file mode 100644 index 000000000..acec9f668 --- /dev/null +++ b/defi/asset-leasing/anchor/Anchor.toml @@ -0,0 +1,20 @@ +[toolchain] +# Pin Solana to the version used across the repo's Anchor 1.0 examples so the +# bundled test validator and BPF toolchain stay in lock-step. +solana_version = "3.1.8" + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +asset_leasing = "HHKEhLk6dyzG4mK1isPyZiHcEMW4J1CRKryzyQ3JFtnF" + +[provider] +cluster = "Localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +# LiteSVM Rust tests live under `programs/asset-leasing/tests/` and include the +# built `.so` via `include_bytes!`, so a fresh `anchor build` must run first. +test = "cargo test" diff --git a/defi/asset-leasing/anchor/Cargo.toml b/defi/asset-leasing/anchor/Cargo.toml new file mode 100644 index 000000000..11cbab7ba --- /dev/null +++ b/defi/asset-leasing/anchor/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +# Local workspace — the repo root Cargo.toml does not include Anchor projects, +# each Anchor example ships its own workspace plus Cargo.lock. +members = ["programs/*"] +resolver = "2" + +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 + +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md new file mode 100644 index 000000000..d924c8e9b --- /dev/null +++ b/defi/asset-leasing/anchor/README.md @@ -0,0 +1,58 @@ +# Asset Leasing + +Fixed-term leasing of SPL tokens with SPL collateral, time-streamed rent, and +Pyth-priced liquidation. + +A lessor lists a batch of leased tokens along with the rental terms. Once a +lessee takes the lease they deposit collateral and receive the tokens. Rent +accrues per second in the collateral mint and can be swept to the lessor at +any time. If the collateral falls below the maintenance margin (priced via a +Pyth `PriceUpdateV2` account), a keeper can liquidate the position and earn a +small bounty — the rest of the collateral compensates the lessor. At expiry, +a lessee who fails to return the tokens forfeits all of their collateral. + +## Instructions + +| Instruction | Who calls it | What it does | +| --- | --- | --- | +| `create_lease` | Lessor | Locks the leased tokens in a program vault and publishes the terms. Lease starts in `Listed`. | +| `take_lease` | Lessee | Posts the required collateral, receives the leased tokens. Status → `Active`. | +| `pay_rent` | Anyone | Streams accrued rent (seconds × `rent_per_second`) from the collateral vault to the lessor. | +| `top_up_collateral` | Lessee | Adds more collateral to stay above the maintenance margin. | +| `return_lease` | Lessee | Returns the leased tokens, settles final rent, refunds any unused collateral, closes the lease. | +| `liquidate` | Keeper | If the position is underwater per the supplied Pyth price, seizes collateral, pays bounty to keeper + balance to lessor. | +| `close_expired` | Lessor | Cancels an unrented `Listed` lease or, after `end_ts`, claims a defaulted lessee's collateral. | + +## Accounts + +- `Lease` PDA — seeded by `(b"lease", lessor, lease_id)`. +- `leased_vault` PDA — seeded by `(b"leased_vault", lease)`, holds the leased tokens between listing and settlement. +- `collateral_vault` PDA — seeded by `(b"collateral_vault", lease)`, escrows the lessee's collateral. + +## Pyth integration notes + +The `liquidate` instruction reads a `PriceUpdateV2` account owned by the +canonical Pyth Solana Receiver program +(`rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ`). The price must quote one +leased token in collateral-token units. The program decodes the relevant +fields manually — it does **not** pull in `pyth-solana-receiver-sdk` because +that crate currently has a transitive `borsh` conflict with `anchor-lang` +1.0.0 (see `program-examples/.github/.ghaignore` — `oracles/pyth/anchor` is +flagged for the same reason). + +Staleness is enforced (`publish_time` must be within the last 60 seconds and +must not be in the future). + +## Running the tests + +LiteSVM-based Rust tests live under `programs/asset-leasing/tests/` and load +the built program via `include_bytes!`, so the `.so` must exist first. + +```bash +anchor build +cargo test +``` + +The tests cover the full lifecycle, including a mocked Pyth price drop that +triggers liquidation and a healthy-position check that must refuse to +liquidate. diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml b/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml new file mode 100644 index 000000000..dfd7ece5b --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "asset-leasing" +version = "0.1.0" +description = "Fixed-term SPL token leasing with collateral and Pyth-priced liquidation" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "asset_leasing" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + +[dependencies] +# `init-if-needed` is required because several instructions lazily create the +# counterparty's ATAs (keeper's collateral ATA on first liquidation, lessor's +# leased ATA on first return, etc.). Anchor forces an opt-in to make us +# re-affirm that we verify ownership on every touch — which we do via the +# `associated_token::authority = ...` constraints. +anchor-lang = { version = "1.0.0", features = ["init-if-needed"] } +anchor-spl = "1.0.0" +# Note: we intentionally do NOT depend on `pyth-solana-receiver-sdk` here. +# Version 1.1.0 currently pulls in a transitive `borsh` conflict with +# `anchor-lang` 1.0.0 (see program-examples/.github/.ghaignore — the +# oracles/pyth/anchor example is flagged "not building" for the same reason). +# Instead we parse the fixed layout of the Pyth Receiver `PriceUpdateV2` +# account by hand in `instructions/liquidate.rs`, matching the published +# on-chain schema. + +[dev-dependencies] +# Match the test stack used by tokens/escrow and tokens/token-fundraiser so +# contributors can move between examples without version drift. +litesvm = "0.11.0" +solana-signer = "3.0.0" +solana-keypair = "3.0.1" +solana-account = "3.0.0" +solana-kite = "0.3.0" +borsh = "1.6.1" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs new file mode 100644 index 000000000..20a3c30f4 --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs @@ -0,0 +1,28 @@ +/// PDA seed for the `Lease` account. Combined with the lessor pubkey and a +/// u64 `lease_id` so one lessor can run many leases in parallel. +pub const LEASE_SEED: &[u8] = b"lease"; + +/// PDA seed for the SPL vault that holds the leased tokens while the lease is +/// `Listed` and that accepts returned tokens on settlement. +pub const LEASED_VAULT_SEED: &[u8] = b"leased_vault"; + +/// PDA seed for the SPL vault that escrows the lessee's collateral for the +/// life of the lease. +pub const COLLATERAL_VAULT_SEED: &[u8] = b"collateral_vault"; + +/// Denominator for basis-point (bps) ratios used for the maintenance margin +/// and the liquidation bounty. 10_000 bps = 100%. +pub const BPS_DENOMINATOR: u64 = 10_000; + +/// Maximum allowed maintenance margin: 50_000 bps = 500%. Prevents the lessor +/// setting an impossible margin that would let them liquidate on day one. +pub const MAX_MAINTENANCE_MARGIN_BPS: u16 = 50_000; + +/// Maximum liquidation bounty the keeper can claim: 2_000 bps = 20%. Keeps +/// most of the collateral flowing to the lessor on default. +pub const MAX_LIQUIDATION_BOUNTY_BPS: u16 = 2_000; + +/// A Pyth price update is considered stale if its `publish_time` is older +/// than this many seconds versus the current on-chain clock. 60 s matches the +/// default staleness window used in the Pyth SDK docs. +pub const PYTH_MAX_AGE_SECONDS: u64 = 60; diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs new file mode 100644 index 000000000..f89f06d98 --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs @@ -0,0 +1,33 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum AssetLeasingError { + #[msg("Lease is not in the required state for this action")] + InvalidLeaseStatus, + #[msg("Duration must be greater than zero")] + InvalidDuration, + #[msg("Leased amount must be greater than zero")] + InvalidLeasedAmount, + #[msg("Required collateral amount must be greater than zero")] + InvalidCollateralAmount, + #[msg("Rent per second must be greater than zero")] + InvalidRentPerSecond, + #[msg("Maintenance margin is outside the allowed range")] + InvalidMaintenanceMargin, + #[msg("Liquidation bounty is outside the allowed range")] + InvalidLiquidationBounty, + #[msg("Lease has already expired")] + LeaseExpired, + #[msg("Lease has not yet expired")] + LeaseNotExpired, + #[msg("Position is healthy; liquidation is not allowed")] + PositionHealthy, + #[msg("Pyth price update is stale")] + StalePrice, + #[msg("Pyth price is not positive")] + NonPositivePrice, + #[msg("Arithmetic overflow")] + MathOverflow, + #[msg("Signer is not authorised for this action")] + Unauthorised, +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs new file mode 100644 index 000000000..fff5905ac --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs @@ -0,0 +1,177 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{close_account, CloseAccount, Mint, TokenAccount, TokenInterface}, +}; + +use crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, + errors::AssetLeasingError, + instructions::shared::transfer_tokens_from_vault, + state::{Lease, LeaseStatus}, +}; + +/// Lessor-only recovery path. Two real-world situations collapse here: +/// +/// - The lease sat in `Listed` and the lessor wants to cancel it, recovering +/// the leased tokens they pre-funded. Allowed any time. +/// - The lease was `Active` but the lessee ghosted past `end_ts`. The lessor +/// takes the collateral as compensation and closes the books. +#[derive(Accounts)] +pub struct CloseExpired<'info> { + #[account(mut)] + pub lessor: Signer<'info>, + + #[account( + mut, + seeds = [LEASE_SEED, lessor.key().as_ref(), &lease.lease_id.to_le_bytes()], + bump = lease.bump, + has_one = lessor, + has_one = leased_mint, + has_one = collateral_mint, + constraint = matches!(lease.status, LeaseStatus::Listed | LeaseStatus::Active) + @ AssetLeasingError::InvalidLeaseStatus, + close = lessor, + )] + pub lease: Account<'info, Lease>, + + pub leased_mint: Box>, + pub collateral_mint: Box>, + + #[account( + mut, + seeds = [LEASED_VAULT_SEED, lease.key().as_ref()], + bump = lease.leased_vault_bump, + token::mint = leased_mint, + token::authority = leased_vault, + token::token_program = token_program, + )] + pub leased_vault: Box>, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease.key().as_ref()], + bump = lease.collateral_vault_bump, + token::mint = collateral_mint, + token::authority = collateral_vault, + token::token_program = token_program, + )] + pub collateral_vault: Box>, + + #[account( + init_if_needed, + payer = lessor, + associated_token::mint = leased_mint, + associated_token::authority = lessor, + associated_token::token_program = token_program, + )] + pub lessor_leased_account: Box>, + + #[account( + init_if_needed, + payer = lessor, + associated_token::mint = collateral_mint, + associated_token::authority = lessor, + associated_token::token_program = token_program, + )] + pub lessor_collateral_account: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +pub fn handle_close_expired(context: Context) -> Result<()> { + let now = Clock::get()?.unix_timestamp; + let lease_key = context.accounts.lease.key(); + let status = context.accounts.lease.status; + + // Active leases can only be closed after they expire. Listed leases have + // no start/end so the check is skipped. + if status == LeaseStatus::Active { + require!( + now >= context.accounts.lease.end_ts, + AssetLeasingError::LeaseNotExpired + ); + } + + let leased_vault_bump = context.accounts.lease.leased_vault_bump; + let leased_vault_seeds: &[&[u8]] = &[ + LEASED_VAULT_SEED, + lease_key.as_ref(), + core::slice::from_ref(&leased_vault_bump), + ]; + let collateral_vault_bump = context.accounts.lease.collateral_vault_bump; + let collateral_vault_seeds: &[&[u8]] = &[ + COLLATERAL_VAULT_SEED, + lease_key.as_ref(), + core::slice::from_ref(&collateral_vault_bump), + ]; + + // Drain whatever is in the leased vault back to the lessor. For a Listed + // lease this is the full leased_amount; for a defaulted Active lease the + // vault is empty (the lessee never returned) and this is a no-op. + let leased_vault_balance = context.accounts.leased_vault.amount; + if leased_vault_balance > 0 { + transfer_tokens_from_vault( + &context.accounts.leased_vault, + &context.accounts.lessor_leased_account, + leased_vault_balance, + &context.accounts.leased_mint, + &context.accounts.leased_vault.to_account_info(), + &context.accounts.token_program, + &[leased_vault_seeds], + )?; + } + + // Drain the collateral vault to the lessor. For a Listed lease this is 0. + // For a defaulted Active lease this is the lessee's forfeited collateral. + let collateral_vault_balance = context.accounts.collateral_vault.amount; + if collateral_vault_balance > 0 { + transfer_tokens_from_vault( + &context.accounts.collateral_vault, + &context.accounts.lessor_collateral_account, + collateral_vault_balance, + &context.accounts.collateral_mint, + &context.accounts.collateral_vault.to_account_info(), + &context.accounts.token_program, + &[collateral_vault_seeds], + )?; + } + + close_vault( + &context.accounts.leased_vault, + &context.accounts.lessor, + &context.accounts.token_program, + &[leased_vault_seeds], + )?; + close_vault( + &context.accounts.collateral_vault, + &context.accounts.lessor, + &context.accounts.token_program, + &[collateral_vault_seeds], + )?; + + context.accounts.lease.collateral_amount = 0; + context.accounts.lease.status = LeaseStatus::Closed; + + Ok(()) +} + +fn close_vault<'info>( + vault: &InterfaceAccount<'info, TokenAccount>, + destination: &Signer<'info>, + token_program: &Interface<'info, TokenInterface>, + signer_seeds: &[&[&[u8]]], +) -> Result<()> { + let accounts = CloseAccount { + account: vault.to_account_info(), + destination: destination.to_account_info(), + authority: vault.to_account_info(), + }; + close_account(CpiContext::new_with_signer( + token_program.key(), + accounts, + signer_seeds, + )) +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs new file mode 100644 index 000000000..4f456568b --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs @@ -0,0 +1,139 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::{ + constants::{ + COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED, MAX_LIQUIDATION_BOUNTY_BPS, + MAX_MAINTENANCE_MARGIN_BPS, + }, + errors::AssetLeasingError, + instructions::shared::transfer_tokens_from_user, + state::{Lease, LeaseStatus}, +}; + +#[derive(Accounts)] +#[instruction(lease_id: u64)] +pub struct CreateLease<'info> { + #[account(mut)] + pub lessor: Signer<'info>, + + #[account(mint::token_program = token_program)] + pub leased_mint: InterfaceAccount<'info, Mint>, + + #[account(mint::token_program = token_program)] + pub collateral_mint: InterfaceAccount<'info, Mint>, + + #[account( + mut, + associated_token::mint = leased_mint, + associated_token::authority = lessor, + associated_token::token_program = token_program, + )] + pub lessor_leased_account: Box>, + + #[account( + init, + payer = lessor, + space = Lease::DISCRIMINATOR.len() + Lease::INIT_SPACE, + seeds = [LEASE_SEED, lessor.key().as_ref(), &lease_id.to_le_bytes()], + bump, + )] + pub lease: Account<'info, Lease>, + + /// PDA-owned vault holding the leased tokens while `Listed`. Authority is + /// the vault PDA itself so the lease account does not need to sign for + /// returns / liquidation; any handler just signs with the vault seeds. + #[account( + init, + payer = lessor, + seeds = [LEASED_VAULT_SEED, lease.key().as_ref()], + bump, + token::mint = leased_mint, + token::authority = leased_vault, + token::token_program = token_program, + )] + pub leased_vault: Box>, + + #[account( + init, + payer = lessor, + seeds = [COLLATERAL_VAULT_SEED, lease.key().as_ref()], + bump, + token::mint = collateral_mint, + token::authority = collateral_vault, + token::token_program = token_program, + )] + pub collateral_vault: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +#[allow(clippy::too_many_arguments)] +pub fn handle_create_lease( + context: Context, + lease_id: u64, + leased_amount: u64, + required_collateral_amount: u64, + rent_per_second: u64, + duration_seconds: i64, + maintenance_margin_bps: u16, + liquidation_bounty_bps: u16, +) -> Result<()> { + require!(leased_amount > 0, AssetLeasingError::InvalidLeasedAmount); + require!( + required_collateral_amount > 0, + AssetLeasingError::InvalidCollateralAmount + ); + require!(rent_per_second > 0, AssetLeasingError::InvalidRentPerSecond); + require!(duration_seconds > 0, AssetLeasingError::InvalidDuration); + require!( + maintenance_margin_bps > 0 && maintenance_margin_bps <= MAX_MAINTENANCE_MARGIN_BPS, + AssetLeasingError::InvalidMaintenanceMargin + ); + require!( + liquidation_bounty_bps <= MAX_LIQUIDATION_BOUNTY_BPS, + AssetLeasingError::InvalidLiquidationBounty + ); + + // Lock the leased tokens into the program-owned vault up-front. Doing this + // here (not on take_lease) guarantees a lessee can never accept a lease + // the lessor no longer has the funds to deliver. + transfer_tokens_from_user( + &context.accounts.lessor_leased_account, + &context.accounts.leased_vault, + leased_amount, + &context.accounts.leased_mint, + &context.accounts.lessor, + &context.accounts.token_program, + )?; + + let lease = &mut context.accounts.lease; + lease.set_inner(Lease { + lease_id, + lessor: context.accounts.lessor.key(), + // No lessee yet — will be populated by take_lease. + lessee: Pubkey::default(), + leased_mint: context.accounts.leased_mint.key(), + leased_amount, + collateral_mint: context.accounts.collateral_mint.key(), + // No collateral yet — posted on take_lease. + collateral_amount: 0, + required_collateral_amount, + rent_per_second, + duration_seconds, + // start_ts / end_ts / last_rent_paid_ts are set when the lease + // activates in `take_lease`. + start_ts: 0, + end_ts: 0, + last_rent_paid_ts: 0, + maintenance_margin_bps, + liquidation_bounty_bps, + status: LeaseStatus::Listed, + bump: context.bumps.lease, + leased_vault_bump: context.bumps.leased_vault, + collateral_vault_bump: context.bumps.collateral_vault, + }); + + Ok(()) +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs new file mode 100644 index 000000000..2259245c3 --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs @@ -0,0 +1,322 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{close_account, CloseAccount, Mint, TokenAccount, TokenInterface}, +}; + +use crate::{ + constants::{ + BPS_DENOMINATOR, COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED, + PYTH_MAX_AGE_SECONDS, + }, + errors::AssetLeasingError, + instructions::{pay_rent::compute_rent_due, shared::transfer_tokens_from_vault}, + state::{Lease, LeaseStatus}, +}; + +/// Pyth Solana Receiver program ID on mainnet (also used on devnet by the +/// canonical Pyth integrations). Declared here as a string so the tests can +/// mint mock `PriceUpdateV2` accounts owned by the same id. +pub const PYTH_RECEIVER_PROGRAM_ID: Pubkey = + anchor_lang::pubkey!("rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ"); + +/// Anchor discriminator for `PriceUpdateV2`. Equal to the first 8 bytes of +/// `sha256("account:PriceUpdateV2")`. Hard-coded because we parse the account +/// by hand rather than pulling in `pyth-solana-receiver-sdk` (see Cargo.toml). +pub const PRICE_UPDATE_V2_DISCRIMINATOR: [u8; 8] = [34, 241, 35, 99, 157, 126, 244, 205]; + +#[derive(Accounts)] +pub struct Liquidate<'info> { + /// Keeper who calls the instruction — they receive the bounty. + #[account(mut)] + pub keeper: Signer<'info>, + + /// CHECK: PDA seed + rent/collateral destination. + #[account(mut)] + pub lessor: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [LEASE_SEED, lessor.key().as_ref(), &lease.lease_id.to_le_bytes()], + bump = lease.bump, + has_one = lessor, + has_one = leased_mint, + has_one = collateral_mint, + constraint = lease.status == LeaseStatus::Active @ AssetLeasingError::InvalidLeaseStatus, + close = lessor, + )] + pub lease: Account<'info, Lease>, + + pub leased_mint: Box>, + pub collateral_mint: Box>, + + #[account( + mut, + seeds = [LEASED_VAULT_SEED, lease.key().as_ref()], + bump = lease.leased_vault_bump, + token::mint = leased_mint, + token::authority = leased_vault, + token::token_program = token_program, + )] + pub leased_vault: Box>, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease.key().as_ref()], + bump = lease.collateral_vault_bump, + token::mint = collateral_mint, + token::authority = collateral_vault, + token::token_program = token_program, + )] + pub collateral_vault: Box>, + + #[account( + init_if_needed, + payer = keeper, + associated_token::mint = collateral_mint, + associated_token::authority = lessor, + associated_token::token_program = token_program, + )] + pub lessor_collateral_account: Box>, + + #[account( + init_if_needed, + payer = keeper, + associated_token::mint = collateral_mint, + associated_token::authority = keeper, + associated_token::token_program = token_program, + )] + pub keeper_collateral_account: Box>, + + /// CHECK: We verify the account is owned by the Pyth Receiver program and + /// carries the expected `PriceUpdateV2` discriminator before decoding. + /// The price feed must quote *one leased token in collateral units* — + /// keepers are responsible for supplying an appropriate feed, the program + /// cannot know which pair is correct for a given lease. + #[account(owner = PYTH_RECEIVER_PROGRAM_ID)] + pub price_update: UncheckedAccount<'info>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +/// Minimal projection of `PriceUpdateV2` — only the fields we actually need. +/// Layout: `[discriminator(8) | write_authority(32) | verification_level(1) | +/// feed_id(32) | price(i64) | conf(u64) | exponent(i32) | publish_time(i64) | +/// prev_publish_time(i64) | ema_price(i64) | ema_conf(u64) | posted_slot(u64)]`. +pub struct DecodedPriceUpdate { + pub price: i64, + pub exponent: i32, + pub publish_time: i64, +} + +pub fn decode_price_update(data: &[u8]) -> Result { + // Discriminator (8) + write_authority (32) + verification_level (1) + + // feed_id (32) = 73 bytes before the fields we care about. + const PRICE_OFFSET: usize = 73; + const EXPONENT_OFFSET: usize = PRICE_OFFSET + 8 + 8; // price + conf + const PUBLISH_TIME_OFFSET: usize = EXPONENT_OFFSET + 4; // exponent + const MIN_LEN: usize = PUBLISH_TIME_OFFSET + 8; + + require!(data.len() >= MIN_LEN, AssetLeasingError::StalePrice); + require!( + data[..8] == PRICE_UPDATE_V2_DISCRIMINATOR, + AssetLeasingError::StalePrice + ); + + let price = i64::from_le_bytes(data[PRICE_OFFSET..PRICE_OFFSET + 8].try_into().unwrap()); + let exponent = i32::from_le_bytes( + data[EXPONENT_OFFSET..EXPONENT_OFFSET + 4] + .try_into() + .unwrap(), + ); + let publish_time = i64::from_le_bytes( + data[PUBLISH_TIME_OFFSET..PUBLISH_TIME_OFFSET + 8] + .try_into() + .unwrap(), + ); + + Ok(DecodedPriceUpdate { + price, + exponent, + publish_time, + }) +} + +pub fn handle_liquidate(context: Context) -> Result<()> { + let now = Clock::get()?.unix_timestamp; + let price_data = context.accounts.price_update.try_borrow_data()?; + let decoded = decode_price_update(&price_data)?; + drop(price_data); + + require!( + is_underwater(&context.accounts.lease, &decoded, now)?, + AssetLeasingError::PositionHealthy + ); + + // Settle accrued rent first (up to end_ts) so the lessor is paid for the + // time the lessee actually used. Only then slice off bounty + remainder. + let rent_due = compute_rent_due(&context.accounts.lease, now)?; + let rent_payable = rent_due.min(context.accounts.lease.collateral_amount); + + let lease_key = context.accounts.lease.key(); + let collateral_vault_bump = context.accounts.lease.collateral_vault_bump; + let collateral_vault_seeds: &[&[u8]] = &[ + COLLATERAL_VAULT_SEED, + lease_key.as_ref(), + core::slice::from_ref(&collateral_vault_bump), + ]; + let leased_vault_bump = context.accounts.lease.leased_vault_bump; + let leased_vault_seeds: &[&[u8]] = &[ + LEASED_VAULT_SEED, + lease_key.as_ref(), + core::slice::from_ref(&leased_vault_bump), + ]; + + if rent_payable > 0 { + transfer_tokens_from_vault( + &context.accounts.collateral_vault, + &context.accounts.lessor_collateral_account, + rent_payable, + &context.accounts.collateral_mint, + &context.accounts.collateral_vault.to_account_info(), + &context.accounts.token_program, + &[collateral_vault_seeds], + )?; + } + + let remaining = context + .accounts + .lease + .collateral_amount + .checked_sub(rent_payable) + .ok_or(AssetLeasingError::MathOverflow)?; + + // Bounty is a percentage of the collateral *after* rent — guarantees we + // never try to pay out more than what actually sits in the vault. + let bounty = (remaining as u128) + .checked_mul(context.accounts.lease.liquidation_bounty_bps as u128) + .ok_or(AssetLeasingError::MathOverflow)? + .checked_div(BPS_DENOMINATOR as u128) + .ok_or(AssetLeasingError::MathOverflow)? as u64; + + if bounty > 0 { + transfer_tokens_from_vault( + &context.accounts.collateral_vault, + &context.accounts.keeper_collateral_account, + bounty, + &context.accounts.collateral_mint, + &context.accounts.collateral_vault.to_account_info(), + &context.accounts.token_program, + &[collateral_vault_seeds], + )?; + } + + let lessor_share = remaining + .checked_sub(bounty) + .ok_or(AssetLeasingError::MathOverflow)?; + if lessor_share > 0 { + transfer_tokens_from_vault( + &context.accounts.collateral_vault, + &context.accounts.lessor_collateral_account, + lessor_share, + &context.accounts.collateral_mint, + &context.accounts.collateral_vault.to_account_info(), + &context.accounts.token_program, + &[collateral_vault_seeds], + )?; + } + + // The leased vault is empty (lessee kept the tokens on default) but was + // rent-exempt funded at creation. Close both vaults so the lessor recoups + // the rent-exempt lamports. + close_vault( + &context.accounts.leased_vault, + &context.accounts.lessor, + &context.accounts.token_program, + &[leased_vault_seeds], + )?; + close_vault( + &context.accounts.collateral_vault, + &context.accounts.lessor, + &context.accounts.token_program, + &[collateral_vault_seeds], + )?; + + context.accounts.lease.collateral_amount = 0; + context.accounts.lease.last_rent_paid_ts = now.min(context.accounts.lease.end_ts); + context.accounts.lease.status = LeaseStatus::Liquidated; + + Ok(()) +} + +/// Liquidatable when collateral value < debt value * maintenance margin. +/// All math stays in integers by folding the Pyth exponent into whichever +/// side of the inequality does not already have a power of ten applied. +pub fn is_underwater(lease: &Lease, price: &DecodedPriceUpdate, now: i64) -> Result { + // Staleness guard. `publish_time` coming from the future is treated as + // stale too — the keeper must not front-run the clock. + require!(price.publish_time <= now, AssetLeasingError::StalePrice); + let age = (now - price.publish_time) as u64; + require!(age <= PYTH_MAX_AGE_SECONDS, AssetLeasingError::StalePrice); + + require!(price.price > 0, AssetLeasingError::NonPositivePrice); + let price_raw = price.price as u128; + + let leased_amount = lease.leased_amount as u128; + let collateral_amount = lease.collateral_amount as u128; + let margin_bps = lease.maintenance_margin_bps as u128; + let denom = BPS_DENOMINATOR as u128; + + let (collateral_scaled, debt_scaled) = if price.exponent >= 0 { + let scale = ten_pow(price.exponent as u32)?; + let debt = leased_amount + .checked_mul(price_raw) + .and_then(|product| product.checked_mul(scale)) + .ok_or(AssetLeasingError::MathOverflow)?; + (collateral_amount, debt) + } else { + let scale = ten_pow((-price.exponent) as u32)?; + let collateral = collateral_amount + .checked_mul(scale) + .ok_or(AssetLeasingError::MathOverflow)?; + let debt = leased_amount + .checked_mul(price_raw) + .ok_or(AssetLeasingError::MathOverflow)?; + (collateral, debt) + }; + + let lhs = collateral_scaled + .checked_mul(denom) + .ok_or(AssetLeasingError::MathOverflow)?; + let rhs = debt_scaled + .checked_mul(margin_bps) + .ok_or(AssetLeasingError::MathOverflow)?; + + Ok(lhs < rhs) +} + +fn ten_pow(exponent: u32) -> Result { + 10u128 + .checked_pow(exponent) + .ok_or(AssetLeasingError::MathOverflow.into()) +} + +fn close_vault<'info>( + vault: &InterfaceAccount<'info, TokenAccount>, + destination: &UncheckedAccount<'info>, + token_program: &Interface<'info, TokenInterface>, + signer_seeds: &[&[&[u8]]], +) -> Result<()> { + let accounts = CloseAccount { + account: vault.to_account_info(), + destination: destination.to_account_info(), + authority: vault.to_account_info(), + }; + close_account(CpiContext::new_with_signer( + token_program.key(), + accounts, + signer_seeds, + )) +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/mod.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/mod.rs new file mode 100644 index 000000000..18a4660b1 --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/mod.rs @@ -0,0 +1,17 @@ +pub mod close_expired; +pub mod create_lease; +pub mod liquidate; +pub mod pay_rent; +pub mod return_lease; +pub mod shared; +pub mod take_lease; +pub mod top_up_collateral; + +pub use close_expired::*; +pub use create_lease::*; +pub use liquidate::*; +pub use pay_rent::*; +pub use return_lease::*; +pub use shared::*; +pub use take_lease::*; +pub use top_up_collateral::*; diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_rent.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_rent.rs new file mode 100644 index 000000000..71b833622 --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_rent.rs @@ -0,0 +1,135 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASE_SEED}, + errors::AssetLeasingError, + instructions::shared::transfer_tokens_from_vault, + state::{Lease, LeaseStatus}, +}; + +#[derive(Accounts)] +pub struct PayRent<'info> { + /// Anyone may settle rent — the lessee has every incentive to keep the + /// lease current, but a keeper bot could also push a payment before a + /// liquidation check so healthy leases stay healthy. + #[account(mut)] + pub payer: Signer<'info>, + + /// CHECK: Referenced only for PDA derivation + has_one check on `lease`. + pub lessor: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [LEASE_SEED, lessor.key().as_ref(), &lease.lease_id.to_le_bytes()], + bump = lease.bump, + has_one = lessor, + has_one = collateral_mint, + constraint = lease.status == LeaseStatus::Active @ AssetLeasingError::InvalidLeaseStatus, + )] + pub lease: Account<'info, Lease>, + + pub collateral_mint: InterfaceAccount<'info, Mint>, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease.key().as_ref()], + bump = lease.collateral_vault_bump, + token::mint = collateral_mint, + token::authority = collateral_vault, + token::token_program = token_program, + )] + pub collateral_vault: Box>, + + /// Lessor's collateral-mint ATA, created on demand so the lessor does not + /// need to pre-fund it with rent. + #[account( + init_if_needed, + payer = payer, + associated_token::mint = collateral_mint, + associated_token::authority = lessor, + associated_token::token_program = token_program, + )] + pub lessor_collateral_account: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +pub fn handle_pay_rent(context: Context) -> Result<()> { + let now = Clock::get()?.unix_timestamp; + + let rent_amount = compute_rent_due(&context.accounts.lease, now)?; + + // No time has passed (or already capped at end_ts). Nothing to do. + if rent_amount == 0 { + update_last_paid_ts(&mut context.accounts.lease, now); + return Ok(()); + } + + // Cap rent at whatever collateral actually sits in the vault. If the + // lessee under-collateralised we cannot magically create funds; the + // remainder is their debt and can trigger liquidation. + let payable = rent_amount.min(context.accounts.collateral_amount_available()); + + if payable > 0 { + let lease_key = context.accounts.lease.key(); + let collateral_vault_bump = context.accounts.lease.collateral_vault_bump; + let collateral_vault_seeds: &[&[u8]] = &[ + COLLATERAL_VAULT_SEED, + lease_key.as_ref(), + core::slice::from_ref(&collateral_vault_bump), + ]; + let signer_seeds = [collateral_vault_seeds]; + + transfer_tokens_from_vault( + &context.accounts.collateral_vault, + &context.accounts.lessor_collateral_account, + payable, + &context.accounts.collateral_mint, + &context.accounts.collateral_vault.to_account_info(), + &context.accounts.token_program, + &signer_seeds, + )?; + + context.accounts.lease.collateral_amount = context + .accounts + .lease + .collateral_amount + .checked_sub(payable) + .ok_or(AssetLeasingError::MathOverflow)?; + } + + update_last_paid_ts(&mut context.accounts.lease, now); + Ok(()) +} + +/// Rent accrues linearly: `(min(now, end_ts) - last_rent_paid_ts) * rate`. +/// Extracted so it can be re-used by `return_lease` and `liquidate` for a +/// final settlement before closing the lease. +pub fn compute_rent_due(lease: &Lease, now: i64) -> Result { + let cutoff = now.min(lease.end_ts); + if cutoff <= lease.last_rent_paid_ts { + return Ok(0); + } + let elapsed = (cutoff - lease.last_rent_paid_ts) as u64; + elapsed + .checked_mul(lease.rent_per_second) + .ok_or(AssetLeasingError::MathOverflow.into()) +} + +/// Advance `last_rent_paid_ts` but never past the lease end — after end_ts +/// the lease is settled and extra rent does not accrue. +pub fn update_last_paid_ts(lease: &mut Lease, now: i64) { + lease.last_rent_paid_ts = now.min(lease.end_ts); +} + +impl<'info> PayRent<'info> { + fn collateral_amount_available(&self) -> u64 { + self.lease.collateral_amount + } +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs new file mode 100644 index 000000000..86ff3c2c0 --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs @@ -0,0 +1,220 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{close_account, CloseAccount, Mint, TokenAccount, TokenInterface}, +}; + +use crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, + errors::AssetLeasingError, + instructions::{ + pay_rent::{compute_rent_due, update_last_paid_ts}, + shared::{transfer_tokens_from_user, transfer_tokens_from_vault}, + }, + state::{Lease, LeaseStatus}, +}; + +#[derive(Accounts)] +pub struct ReturnLease<'info> { + #[account(mut)] + pub lessee: Signer<'info>, + + /// CHECK: Reference only — receives rent + closed-vault rent refund. + #[account(mut)] + pub lessor: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [LEASE_SEED, lessor.key().as_ref(), &lease.lease_id.to_le_bytes()], + bump = lease.bump, + has_one = lessor, + has_one = leased_mint, + has_one = collateral_mint, + constraint = lease.lessee == lessee.key() @ AssetLeasingError::Unauthorised, + constraint = lease.status == LeaseStatus::Active @ AssetLeasingError::InvalidLeaseStatus, + close = lessor, + )] + pub lease: Account<'info, Lease>, + + pub leased_mint: Box>, + pub collateral_mint: Box>, + + /// Leased tokens flow back into this vault from the lessee, then out to + /// the lessor in the same instruction. Closed at the end to reclaim rent. + #[account( + mut, + seeds = [LEASED_VAULT_SEED, lease.key().as_ref()], + bump = lease.leased_vault_bump, + token::mint = leased_mint, + token::authority = leased_vault, + token::token_program = token_program, + )] + pub leased_vault: Box>, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease.key().as_ref()], + bump = lease.collateral_vault_bump, + token::mint = collateral_mint, + token::authority = collateral_vault, + token::token_program = token_program, + )] + pub collateral_vault: Box>, + + #[account( + mut, + associated_token::mint = leased_mint, + associated_token::authority = lessee, + associated_token::token_program = token_program, + )] + pub lessee_leased_account: Box>, + + #[account( + mut, + associated_token::mint = collateral_mint, + associated_token::authority = lessee, + associated_token::token_program = token_program, + )] + pub lessee_collateral_account: Box>, + + /// Lessor's leased-mint ATA, created on demand. They may have sent the + /// original tokens from a different account. + #[account( + init_if_needed, + payer = lessee, + associated_token::mint = leased_mint, + associated_token::authority = lessor, + associated_token::token_program = token_program, + )] + pub lessor_leased_account: Box>, + + #[account( + init_if_needed, + payer = lessee, + associated_token::mint = collateral_mint, + associated_token::authority = lessor, + associated_token::token_program = token_program, + )] + pub lessor_collateral_account: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +pub fn handle_return_lease(context: Context) -> Result<()> { + let now = Clock::get()?.unix_timestamp; + let lease_key = context.accounts.lease.key(); + + // 1. Lessee returns leased tokens to the leased vault (full amount). + let leased_amount = context.accounts.lease.leased_amount; + transfer_tokens_from_user( + &context.accounts.lessee_leased_account, + &context.accounts.leased_vault, + leased_amount, + &context.accounts.leased_mint, + &context.accounts.lessee, + &context.accounts.token_program, + )?; + + // 2. Forward leased tokens from the vault to the lessor. + let leased_vault_bump = context.accounts.lease.leased_vault_bump; + let leased_vault_seeds: &[&[u8]] = &[ + LEASED_VAULT_SEED, + lease_key.as_ref(), + core::slice::from_ref(&leased_vault_bump), + ]; + transfer_tokens_from_vault( + &context.accounts.leased_vault, + &context.accounts.lessor_leased_account, + leased_amount, + &context.accounts.leased_mint, + &context.accounts.leased_vault.to_account_info(), + &context.accounts.token_program, + &[leased_vault_seeds], + )?; + + // 3. Settle accrued rent: collateral vault -> lessor. + let rent_due = compute_rent_due(&context.accounts.lease, now)?; + let rent_payable = rent_due.min(context.accounts.lease.collateral_amount); + + let collateral_vault_bump = context.accounts.lease.collateral_vault_bump; + let collateral_vault_seeds: &[&[u8]] = &[ + COLLATERAL_VAULT_SEED, + lease_key.as_ref(), + core::slice::from_ref(&collateral_vault_bump), + ]; + + if rent_payable > 0 { + transfer_tokens_from_vault( + &context.accounts.collateral_vault, + &context.accounts.lessor_collateral_account, + rent_payable, + &context.accounts.collateral_mint, + &context.accounts.collateral_vault.to_account_info(), + &context.accounts.token_program, + &[collateral_vault_seeds], + )?; + } + + // 4. Refund remaining collateral to the lessee. Returning early does not + // entitle the lessee to a future-rent refund — rent only accrues for time + // actually used, so `compute_rent_due` already excludes the unused tail. + let collateral_after_rent = context + .accounts + .lease + .collateral_amount + .checked_sub(rent_payable) + .ok_or(AssetLeasingError::MathOverflow)?; + + if collateral_after_rent > 0 { + transfer_tokens_from_vault( + &context.accounts.collateral_vault, + &context.accounts.lessee_collateral_account, + collateral_after_rent, + &context.accounts.collateral_mint, + &context.accounts.collateral_vault.to_account_info(), + &context.accounts.token_program, + &[collateral_vault_seeds], + )?; + } + + // 5. Close both vaults so the rent-exempt lamports come back to the + // lessor — the lessee only pays for the temporary state they held. + close_vault( + &context.accounts.leased_vault, + &context.accounts.lessor, + &context.accounts.token_program, + &[leased_vault_seeds], + )?; + close_vault( + &context.accounts.collateral_vault, + &context.accounts.lessor, + &context.accounts.token_program, + &[collateral_vault_seeds], + )?; + + update_last_paid_ts(&mut context.accounts.lease, now); + context.accounts.lease.collateral_amount = 0; + context.accounts.lease.status = LeaseStatus::Closed; + + Ok(()) +} + +fn close_vault<'info>( + vault: &InterfaceAccount<'info, TokenAccount>, + destination: &UncheckedAccount<'info>, + token_program: &Interface<'info, TokenInterface>, + signer_seeds: &[&[&[u8]]], +) -> Result<()> { + let accounts = CloseAccount { + account: vault.to_account_info(), + destination: destination.to_account_info(), + authority: vault.to_account_info(), + }; + close_account(CpiContext::new_with_signer( + token_program.key(), + accounts, + signer_seeds, + )) +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs new file mode 100644 index 000000000..f40c7e50c --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs @@ -0,0 +1,51 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +/// Transfer SPL tokens from a user-controlled account to a program-controlled +/// vault (or any other account the signer owns). Authority is a plain signer. +pub fn transfer_tokens_from_user<'info>( + from: &InterfaceAccount<'info, TokenAccount>, + to: &InterfaceAccount<'info, TokenAccount>, + amount: u64, + mint: &InterfaceAccount<'info, Mint>, + authority: &Signer<'info>, + token_program: &Interface<'info, TokenInterface>, +) -> Result<()> { + let accounts = TransferChecked { + from: from.to_account_info(), + mint: mint.to_account_info(), + to: to.to_account_info(), + authority: authority.to_account_info(), + }; + transfer_checked( + CpiContext::new(token_program.key(), accounts), + amount, + mint.decimals, + ) +} + +/// Transfer SPL tokens out of a PDA-owned vault using the supplied signer +/// seeds. Used by the program when moving tokens held under its authority. +pub fn transfer_tokens_from_vault<'info>( + from: &InterfaceAccount<'info, TokenAccount>, + to: &InterfaceAccount<'info, TokenAccount>, + amount: u64, + mint: &InterfaceAccount<'info, Mint>, + authority: &AccountInfo<'info>, + token_program: &Interface<'info, TokenInterface>, + signer_seeds: &[&[&[u8]]], +) -> Result<()> { + let accounts = TransferChecked { + from: from.to_account_info(), + mint: mint.to_account_info(), + to: to.to_account_info(), + authority: authority.clone(), + }; + transfer_checked( + CpiContext::new_with_signer(token_program.key(), accounts, signer_seeds), + amount, + mint.decimals, + ) +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs new file mode 100644 index 000000000..470e4056b --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs @@ -0,0 +1,134 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, + errors::AssetLeasingError, + instructions::shared::{transfer_tokens_from_user, transfer_tokens_from_vault}, + state::{Lease, LeaseStatus}, +}; + +#[derive(Accounts)] +pub struct TakeLease<'info> { + #[account(mut)] + pub lessee: Signer<'info>, + + /// CHECK: Only used as a reference for the PDA seeds; no data accessed. + pub lessor: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [LEASE_SEED, lessor.key().as_ref(), &lease.lease_id.to_le_bytes()], + bump = lease.bump, + has_one = lessor, + has_one = leased_mint, + has_one = collateral_mint, + constraint = lease.status == LeaseStatus::Listed @ AssetLeasingError::InvalidLeaseStatus, + )] + pub lease: Account<'info, Lease>, + + pub leased_mint: Box>, + pub collateral_mint: Box>, + + #[account( + mut, + seeds = [LEASED_VAULT_SEED, lease.key().as_ref()], + bump = lease.leased_vault_bump, + token::mint = leased_mint, + token::authority = leased_vault, + token::token_program = token_program, + )] + pub leased_vault: Box>, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease.key().as_ref()], + bump = lease.collateral_vault_bump, + token::mint = collateral_mint, + token::authority = collateral_vault, + token::token_program = token_program, + )] + pub collateral_vault: Box>, + + /// Lessee's existing collateral account — they must already hold the + /// required collateral before calling. + #[account( + mut, + associated_token::mint = collateral_mint, + associated_token::authority = lessee, + associated_token::token_program = token_program, + )] + pub lessee_collateral_account: Box>, + + /// Lessee's ATA for the leased mint. Created on-demand if missing so the + /// UI only has to hand over a lessee keypair plus the two mints. + #[account( + init_if_needed, + payer = lessee, + associated_token::mint = leased_mint, + associated_token::authority = lessee, + associated_token::token_program = token_program, + )] + pub lessee_leased_account: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +pub fn handle_take_lease(context: Context) -> Result<()> { + let now = Clock::get()?.unix_timestamp; + + // Bindings for values we still need after `&mut lease` is borrowed. + let required_collateral_amount = context.accounts.lease.required_collateral_amount; + let leased_amount = context.accounts.lease.leased_amount; + let duration_seconds = context.accounts.lease.duration_seconds; + + // Lessee deposits collateral first so a failed leased-token transfer + // rolls back their deposit atomically. + transfer_tokens_from_user( + &context.accounts.lessee_collateral_account, + &context.accounts.collateral_vault, + required_collateral_amount, + &context.accounts.collateral_mint, + &context.accounts.lessee, + &context.accounts.token_program, + )?; + + // Pay out leased tokens from the vault PDA. + let lease_key = context.accounts.lease.key(); + let leased_vault_bump = context.accounts.lease.leased_vault_bump; + let leased_vault_seeds: &[&[u8]] = &[ + LEASED_VAULT_SEED, + lease_key.as_ref(), + core::slice::from_ref(&leased_vault_bump), + ]; + let signer_seeds = [leased_vault_seeds]; + + transfer_tokens_from_vault( + &context.accounts.leased_vault, + &context.accounts.lessee_leased_account, + leased_amount, + &context.accounts.leased_mint, + &context.accounts.leased_vault.to_account_info(), + &context.accounts.token_program, + &signer_seeds, + )?; + + let end_ts = now + .checked_add(duration_seconds) + .ok_or(AssetLeasingError::MathOverflow)?; + + let lease = &mut context.accounts.lease; + lease.lessee = context.accounts.lessee.key(); + lease.collateral_amount = required_collateral_amount; + lease.start_ts = now; + lease.end_ts = end_ts; + lease.last_rent_paid_ts = now; + lease.status = LeaseStatus::Active; + + Ok(()) +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/top_up_collateral.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/top_up_collateral.rs new file mode 100644 index 000000000..9a8984c52 --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/top_up_collateral.rs @@ -0,0 +1,73 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASE_SEED}, + errors::AssetLeasingError, + instructions::shared::transfer_tokens_from_user, + state::{Lease, LeaseStatus}, +}; + +#[derive(Accounts)] +pub struct TopUpCollateral<'info> { + #[account(mut)] + pub lessee: Signer<'info>, + + /// CHECK: PDA seed reference; no reads. + pub lessor: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [LEASE_SEED, lessor.key().as_ref(), &lease.lease_id.to_le_bytes()], + bump = lease.bump, + has_one = lessor, + has_one = collateral_mint, + constraint = lease.lessee == lessee.key() @ AssetLeasingError::Unauthorised, + constraint = lease.status == LeaseStatus::Active @ AssetLeasingError::InvalidLeaseStatus, + )] + pub lease: Account<'info, Lease>, + + pub collateral_mint: InterfaceAccount<'info, Mint>, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease.key().as_ref()], + bump = lease.collateral_vault_bump, + token::mint = collateral_mint, + token::authority = collateral_vault, + token::token_program = token_program, + )] + pub collateral_vault: Box>, + + #[account( + mut, + associated_token::mint = collateral_mint, + associated_token::authority = lessee, + associated_token::token_program = token_program, + )] + pub lessee_collateral_account: Box>, + + pub token_program: Interface<'info, TokenInterface>, +} + +pub fn handle_top_up_collateral(context: Context, amount: u64) -> Result<()> { + require!(amount > 0, AssetLeasingError::InvalidCollateralAmount); + + transfer_tokens_from_user( + &context.accounts.lessee_collateral_account, + &context.accounts.collateral_vault, + amount, + &context.accounts.collateral_mint, + &context.accounts.lessee, + &context.accounts.token_program, + )?; + + context.accounts.lease.collateral_amount = context + .accounts + .lease + .collateral_amount + .checked_add(amount) + .ok_or(AssetLeasingError::MathOverflow)?; + + Ok(()) +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs new file mode 100644 index 000000000..0b9e6495c --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs @@ -0,0 +1,78 @@ +pub mod constants; +pub mod errors; +pub mod instructions; +pub mod state; + +use anchor_lang::prelude::*; + +pub use constants::*; +pub use instructions::*; +pub use state::*; + +declare_id!("HHKEhLk6dyzG4mK1isPyZiHcEMW4J1CRKryzyQ3JFtnF"); + +#[program] +pub mod asset_leasing { + use super::*; + + /// Lessor lists a lease: deposits leased tokens into the leased vault and + /// publishes the rental terms. The lease sits in `Listed` until a lessee + /// takes it. + pub fn create_lease( + context: Context, + lease_id: u64, + leased_amount: u64, + required_collateral_amount: u64, + rent_per_second: u64, + duration_seconds: i64, + maintenance_margin_bps: u16, + liquidation_bounty_bps: u16, + ) -> Result<()> { + instructions::create_lease::handle_create_lease( + context, + lease_id, + leased_amount, + required_collateral_amount, + rent_per_second, + duration_seconds, + maintenance_margin_bps, + liquidation_bounty_bps, + ) + } + + /// Lessee takes the lease: posts collateral into the collateral vault and + /// receives the leased tokens. Lease transitions to `Active`. + pub fn take_lease(context: Context) -> Result<()> { + instructions::take_lease::handle_take_lease(context) + } + + /// Stream rent from the collateral vault to the lessor, up to `end_ts`. + /// Anyone may call this to keep the lease current. + pub fn pay_rent(context: Context) -> Result<()> { + instructions::pay_rent::handle_pay_rent(context) + } + + /// Lessee adds more collateral to stay above the maintenance margin. + pub fn top_up_collateral(context: Context, amount: u64) -> Result<()> { + instructions::top_up_collateral::handle_top_up_collateral(context, amount) + } + + /// Lessee returns the leased tokens (at or before `end_ts`). Accrued rent + /// is settled and the remaining collateral is refunded. + pub fn return_lease(context: Context) -> Result<()> { + instructions::return_lease::handle_return_lease(context) + } + + /// Keeper liquidates an undercollateralised lease using a Pyth price + /// update. Collateral goes to the lessor, minus the keeper bounty. + pub fn liquidate(context: Context) -> Result<()> { + instructions::liquidate::handle_liquidate(context) + } + + /// After `end_ts`, if the lessee never returned the tokens, the lessor + /// reclaims the collateral as compensation and closes the lease. Also + /// used by the lessor to cancel an unrented (`Listed`) lease. + pub fn close_expired(context: Context) -> Result<()> { + instructions::close_expired::handle_close_expired(context) + } +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs new file mode 100644 index 000000000..d50001dd9 --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs @@ -0,0 +1,68 @@ +use anchor_lang::prelude::*; + +/// Lifecycle of a `Lease`. Transitions: +/// Listed --take_lease--> Active +/// Active --return_lease--> Closed +/// Active --liquidate--> Liquidated +/// Listed --close_expired--> Closed (lessor cancels unrented lease) +/// Active --close_expired--> Closed (after end_ts, defaulted lessee) +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, Debug, InitSpace)] +pub enum LeaseStatus { + Listed, + Active, + Liquidated, + Closed, +} + +#[account] +#[derive(InitSpace)] +pub struct Lease { + /// Caller-supplied id so one lessor can run many leases in parallel. The + /// PDA is seeded by (LEASE_SEED, lessor, lease_id). + pub lease_id: u64, + /// Account that listed the lease and receives rent. Always set. + pub lessor: Pubkey, + /// Account that took the lease. `Pubkey::default()` while `Listed`. + pub lessee: Pubkey, + + /// Mint of the tokens being leased out. + pub leased_mint: Pubkey, + /// Amount of leased tokens locked at creation. Used for repayment checks. + pub leased_amount: u64, + + /// Mint of the collateral posted by the lessee. + pub collateral_mint: Pubkey, + /// Collateral the lessee posted (increases on top-up). Decreases as rent + /// is streamed out of the collateral vault. + pub collateral_amount: u64, + /// Collateral the lessee must deposit up-front when taking the lease. + pub required_collateral_amount: u64, + + /// Rent charged per second, denominated in collateral tokens and paid + /// from the collateral vault to the lessor on each `pay_rent`. + pub rent_per_second: u64, + /// Length of the lease, in seconds. Set at creation, used to compute + /// `end_ts` when the lease activates. + pub duration_seconds: i64, + /// Unix timestamp when the lease becomes active (set on `take_lease`). + pub start_ts: i64, + /// Unix timestamp after which the lease expires. 0 while `Listed`. + pub end_ts: i64, + /// Last time rent was settled. Rent accrues from here to `now.min(end_ts)`. + pub last_rent_paid_ts: i64, + + /// Required collateral value as a percentage of the leased value, + /// expressed in basis points. 12_000 bps = 120%. + pub maintenance_margin_bps: u16, + /// Share of the seized collateral paid to the keeper that liquidates the + /// lease, expressed in basis points of `collateral_amount`. + pub liquidation_bounty_bps: u16, + + /// Current lifecycle state. + pub status: LeaseStatus, + + /// Bump seeds — stored so CPIs can sign without re-deriving. + pub bump: u8, + pub leased_vault_bump: u8, + pub collateral_vault_bump: u8, +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/state/mod.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/state/mod.rs new file mode 100644 index 000000000..d7ad671aa --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/state/mod.rs @@ -0,0 +1,2 @@ +pub mod lease; +pub use lease::*; diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs b/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs new file mode 100644 index 000000000..96e1be72f --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs @@ -0,0 +1,880 @@ +//! LiteSVM tests for the asset-leasing program. +//! +//! Covers the full lifecycle: listing, taking, rent streaming, top-ups, +//! early return, keeper liquidation via a mocked Pyth `PriceUpdateV2` +//! account, and lessor-initiated default recovery after expiry. + +use { + anchor_lang::{ + solana_program::{instruction::Instruction, pubkey::Pubkey, system_program}, + InstructionData, ToAccountMetas, + }, + anchor_lang::solana_program::clock::Clock, + litesvm::LiteSVM, + solana_keypair::Keypair, + solana_kite::{ + create_associated_token_account, create_token_mint, create_wallet, + get_token_account_balance, mint_tokens_to_token_account, + send_transaction_from_instructions, + }, + solana_signer::Signer, +}; + +// Keep test-side seeds in sync with `programs/asset-leasing/src/constants.rs`. +// Duplicated rather than imported so tests stay self-contained. +const LEASE_SEED: &[u8] = b"lease"; +const LEASED_VAULT_SEED: &[u8] = b"leased_vault"; +const COLLATERAL_VAULT_SEED: &[u8] = b"collateral_vault"; + +// Pyth Receiver program id — matches `PYTH_RECEIVER_PROGRAM_ID` in the +// program. Kept as a &str so we can parse it once at the top of liquidation +// tests without pulling in extra crate types. +const PYTH_RECEIVER_PROGRAM_ID_STR: &str = "rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ"; + +// Matches `PRICE_UPDATE_V2_DISCRIMINATOR` in liquidate.rs — sha256 of +// "account:PriceUpdateV2" taken from the Pyth receiver IDL. +const PRICE_UPDATE_V2_DISCRIMINATOR: [u8; 8] = [34, 241, 35, 99, 157, 126, 244, 205]; + +fn token_program_id() -> Pubkey { + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + .parse() + .unwrap() +} + +fn ata_program_id() -> Pubkey { + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + .parse() + .unwrap() +} + +fn derive_ata(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { + let (ata, _bump) = Pubkey::find_program_address( + &[wallet.as_ref(), token_program_id().as_ref(), mint.as_ref()], + &ata_program_id(), + ); + ata +} + +fn lease_pdas(program_id: &Pubkey, lessor: &Pubkey, lease_id: u64) -> (Pubkey, Pubkey, Pubkey) { + let (lease, _) = Pubkey::find_program_address( + &[LEASE_SEED, lessor.as_ref(), &lease_id.to_le_bytes()], + program_id, + ); + let (leased_vault, _) = + Pubkey::find_program_address(&[LEASED_VAULT_SEED, lease.as_ref()], program_id); + let (collateral_vault, _) = + Pubkey::find_program_address(&[COLLATERAL_VAULT_SEED, lease.as_ref()], program_id); + (lease, leased_vault, collateral_vault) +} + +struct Scenario { + svm: LiteSVM, + program_id: Pubkey, + // `payer` funds the mint authority + ATA creations during setup but is + // not used directly by the tests afterwards. + #[allow(dead_code)] + payer: Keypair, + lessor: Keypair, + lessee: Keypair, + keeper: Keypair, + leased_mint: Pubkey, + collateral_mint: Pubkey, + lessor_leased_ata: Pubkey, + lessee_collateral_ata: Pubkey, +} + +fn full_setup() -> Scenario { + let program_id = asset_leasing::id(); + let mut svm = LiteSVM::new(); + let program_bytes = include_bytes!("../../../target/deploy/asset_leasing.so"); + svm.add_program(program_id, program_bytes).unwrap(); + + let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); + let lessor = create_wallet(&mut svm, 10_000_000_000).unwrap(); + let lessee = create_wallet(&mut svm, 10_000_000_000).unwrap(); + let keeper = create_wallet(&mut svm, 10_000_000_000).unwrap(); + + // 6 decimals matches USDC and keeps test arithmetic readable. + let decimals = 6u8; + let leased_mint = create_token_mint(&mut svm, &payer, decimals, None).unwrap(); + let collateral_mint = create_token_mint(&mut svm, &payer, decimals, None).unwrap(); + + let lessor_leased_ata = + create_associated_token_account(&mut svm, &lessor.pubkey(), &leased_mint, &payer).unwrap(); + mint_tokens_to_token_account( + &mut svm, + &leased_mint, + &lessor_leased_ata, + 1_000_000_000, + &payer, + ) + .unwrap(); + + let lessee_collateral_ata = + create_associated_token_account(&mut svm, &lessee.pubkey(), &collateral_mint, &payer) + .unwrap(); + mint_tokens_to_token_account( + &mut svm, + &collateral_mint, + &lessee_collateral_ata, + 1_000_000_000, + &payer, + ) + .unwrap(); + + // Anchor macros init the Lease + vault accounts — LiteSVM's default clock + // is epoch 0 which makes the first `take_lease` have start_ts=0 and look + // identical to a Listed lease. Advance once so rent math has signal. + advance_clock_to(&mut svm, 1_700_000_000); + + Scenario { + svm, + program_id, + payer, + lessor, + lessee, + keeper, + leased_mint, + collateral_mint, + lessor_leased_ata, + lessee_collateral_ata, + } +} + +fn advance_clock_to(svm: &mut LiteSVM, unix_timestamp: i64) { + let mut clock = svm.get_sysvar::(); + clock.unix_timestamp = unix_timestamp; + svm.set_sysvar::(&clock); +} + +fn advance_clock_by(svm: &mut LiteSVM, delta_seconds: i64) { + let mut clock = svm.get_sysvar::(); + clock.unix_timestamp += delta_seconds; + svm.set_sysvar::(&clock); +} + +fn current_clock(svm: &LiteSVM) -> i64 { + svm.get_sysvar::().unix_timestamp +} + +// --------------------------------------------------------------------------- +// Instruction builders +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn build_create_lease_ix( + sc: &Scenario, + lease_id: u64, + leased_amount: u64, + required_collateral_amount: u64, + rent_per_second: u64, + duration_seconds: i64, + maintenance_margin_bps: u16, + liquidation_bounty_bps: u16, +) -> Instruction { + let (lease, leased_vault, collateral_vault) = + lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + Instruction::new_with_bytes( + sc.program_id, + &asset_leasing::instruction::CreateLease { + lease_id, + leased_amount, + required_collateral_amount, + rent_per_second, + duration_seconds, + maintenance_margin_bps, + liquidation_bounty_bps, + } + .data(), + asset_leasing::accounts::CreateLease { + lessor: sc.lessor.pubkey(), + leased_mint: sc.leased_mint, + collateral_mint: sc.collateral_mint, + lessor_leased_account: sc.lessor_leased_ata, + lease, + leased_vault, + collateral_vault, + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +fn build_take_lease_ix(sc: &Scenario, lease_id: u64) -> Instruction { + let (lease, leased_vault, collateral_vault) = + lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + let lessee_leased_ata = derive_ata(&sc.lessee.pubkey(), &sc.leased_mint); + Instruction::new_with_bytes( + sc.program_id, + &asset_leasing::instruction::TakeLease {}.data(), + asset_leasing::accounts::TakeLease { + lessee: sc.lessee.pubkey(), + lessor: sc.lessor.pubkey(), + lease, + leased_mint: sc.leased_mint, + collateral_mint: sc.collateral_mint, + leased_vault, + collateral_vault, + lessee_collateral_account: sc.lessee_collateral_ata, + lessee_leased_account: lessee_leased_ata, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +fn build_pay_rent_ix(sc: &Scenario, lease_id: u64) -> Instruction { + let (lease, _leased_vault, collateral_vault) = + lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); + Instruction::new_with_bytes( + sc.program_id, + &asset_leasing::instruction::PayRent {}.data(), + asset_leasing::accounts::PayRent { + payer: sc.lessee.pubkey(), + lessor: sc.lessor.pubkey(), + lease, + collateral_mint: sc.collateral_mint, + collateral_vault, + lessor_collateral_account: lessor_collateral_ata, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +fn build_top_up_ix(sc: &Scenario, lease_id: u64, amount: u64) -> Instruction { + let (lease, _leased_vault, collateral_vault) = + lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + Instruction::new_with_bytes( + sc.program_id, + &asset_leasing::instruction::TopUpCollateral { amount }.data(), + asset_leasing::accounts::TopUpCollateral { + lessee: sc.lessee.pubkey(), + lessor: sc.lessor.pubkey(), + lease, + collateral_mint: sc.collateral_mint, + collateral_vault, + lessee_collateral_account: sc.lessee_collateral_ata, + token_program: token_program_id(), + } + .to_account_metas(None), + ) +} + +fn build_return_lease_ix(sc: &Scenario, lease_id: u64) -> Instruction { + let (lease, leased_vault, collateral_vault) = + lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + let lessee_leased_ata = derive_ata(&sc.lessee.pubkey(), &sc.leased_mint); + let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); + Instruction::new_with_bytes( + sc.program_id, + &asset_leasing::instruction::ReturnLease {}.data(), + asset_leasing::accounts::ReturnLease { + lessee: sc.lessee.pubkey(), + lessor: sc.lessor.pubkey(), + lease, + leased_mint: sc.leased_mint, + collateral_mint: sc.collateral_mint, + leased_vault, + collateral_vault, + lessee_leased_account: lessee_leased_ata, + lessee_collateral_account: sc.lessee_collateral_ata, + lessor_leased_account: sc.lessor_leased_ata, + lessor_collateral_account: lessor_collateral_ata, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +fn build_liquidate_ix(sc: &Scenario, lease_id: u64, price_update: Pubkey) -> Instruction { + let (lease, leased_vault, collateral_vault) = + lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); + let keeper_collateral_ata = derive_ata(&sc.keeper.pubkey(), &sc.collateral_mint); + Instruction::new_with_bytes( + sc.program_id, + &asset_leasing::instruction::Liquidate {}.data(), + asset_leasing::accounts::Liquidate { + keeper: sc.keeper.pubkey(), + lessor: sc.lessor.pubkey(), + lease, + leased_mint: sc.leased_mint, + collateral_mint: sc.collateral_mint, + leased_vault, + collateral_vault, + lessor_collateral_account: lessor_collateral_ata, + keeper_collateral_account: keeper_collateral_ata, + price_update, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +fn build_close_expired_ix(sc: &Scenario, lease_id: u64) -> Instruction { + let (lease, leased_vault, collateral_vault) = + lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); + Instruction::new_with_bytes( + sc.program_id, + &asset_leasing::instruction::CloseExpired {}.data(), + asset_leasing::accounts::CloseExpired { + lessor: sc.lessor.pubkey(), + lease, + leased_mint: sc.leased_mint, + collateral_mint: sc.collateral_mint, + leased_vault, + collateral_vault, + lessor_leased_account: sc.lessor_leased_ata, + lessor_collateral_account: lessor_collateral_ata, + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +/// Build a minimal `PriceUpdateV2` account body with the requested price and +/// exponent, timestamped `publish_time`. Fields not used by the program are +/// filled with zero bytes. +fn build_price_update_data(price: i64, exponent: i32, publish_time: i64) -> Vec { + // Size layout: + // 8 disc + 32 write_authority + 1 verification_level + 32 feed_id + + // 8 price + 8 conf + 4 exponent + 8 publish_time + 8 prev_publish_time + + // 8 ema_price + 8 ema_conf + 8 posted_slot = 141 bytes. + const TOTAL_LEN: usize = 141; + let mut data = Vec::with_capacity(TOTAL_LEN); + data.extend_from_slice(&PRICE_UPDATE_V2_DISCRIMINATOR); + // write_authority — irrelevant for liquidation logic. + data.extend_from_slice(&[0u8; 32]); + // verification_level = Full (1). + data.push(1); + // feed_id — arbitrary; not checked by the program. + data.extend_from_slice(&[0xAB; 32]); + data.extend_from_slice(&price.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); // conf + data.extend_from_slice(&exponent.to_le_bytes()); + data.extend_from_slice(&publish_time.to_le_bytes()); + data.extend_from_slice(&publish_time.to_le_bytes()); // prev_publish_time + data.extend_from_slice(&0i64.to_le_bytes()); // ema_price + data.extend_from_slice(&0u64.to_le_bytes()); // ema_conf + data.extend_from_slice(&0u64.to_le_bytes()); // posted_slot + data +} + +/// Install a mock `PriceUpdateV2` account owned by the Pyth receiver program. +fn mock_price_update( + svm: &mut LiteSVM, + address: Pubkey, + price: i64, + exponent: i32, + publish_time: i64, +) { + let data = build_price_update_data(price, exponent, publish_time); + let lamports = svm.minimum_balance_for_rent_exemption(data.len()); + let owner: Pubkey = PYTH_RECEIVER_PROGRAM_ID_STR.parse().unwrap(); + svm.set_account( + address, + solana_account::Account { + lamports, + data, + owner, + executable: false, + rent_epoch: 0, + }, + ) + .unwrap(); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +// Shared lease parameters so the sanity assertions line up across tests. +const LEASED_AMOUNT: u64 = 100_000_000; // 100 "leased" tokens (6 dp) +const REQUIRED_COLLATERAL: u64 = 200_000_000; // 200 collateral tokens +const RENT_PER_SECOND: u64 = 10; // 10 base-units / sec +const DURATION_SECONDS: i64 = 60 * 60 * 24; // 24h +const MAINTENANCE_MARGIN_BPS: u16 = 12_000; // 120% +const LIQUIDATION_BOUNTY_BPS: u16 = 500; // 5% + +#[test] +fn create_lease_locks_tokens_and_lists() { + let mut sc = full_setup(); + + let lease_id = 1u64; + let ix = build_create_lease_ix( + &sc, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + RENT_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BPS, + LIQUIDATION_BOUNTY_BPS, + ); + send_transaction_from_instructions(&mut sc.svm, vec![ix], &[&sc.lessor], &sc.lessor.pubkey()) + .unwrap(); + + let (lease_pda, leased_vault, collateral_vault) = + lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + + // Leased tokens escrowed. + assert_eq!( + get_token_account_balance(&sc.svm, &leased_vault).unwrap(), + LEASED_AMOUNT + ); + // Collateral vault exists but has no collateral yet. + assert_eq!( + get_token_account_balance(&sc.svm, &collateral_vault).unwrap(), + 0 + ); + // Lessor's leased balance dropped by the escrowed amount. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.lessor_leased_ata).unwrap(), + 1_000_000_000 - LEASED_AMOUNT + ); + + // Lease account exists and is owned by the program. + let lease_account = sc.svm.get_account(&lease_pda).expect("lease PDA missing"); + assert_eq!(lease_account.owner, sc.program_id); + assert!(!lease_account.data.is_empty()); +} + +#[test] +fn take_lease_posts_collateral_and_delivers_tokens() { + let mut sc = full_setup(); + let lease_id = 2u64; + + let create_ix = build_create_lease_ix( + &sc, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + RENT_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BPS, + LIQUIDATION_BOUNTY_BPS, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix], + &[&sc.lessor], + &sc.lessor.pubkey(), + ) + .unwrap(); + + let take_ix = build_take_lease_ix(&sc, lease_id); + send_transaction_from_instructions( + &mut sc.svm, + vec![take_ix], + &[&sc.lessee], + &sc.lessee.pubkey(), + ) + .unwrap(); + + let (_, leased_vault, collateral_vault) = + lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + let lessee_leased_ata = derive_ata(&sc.lessee.pubkey(), &sc.leased_mint); + + // Leased vault drained into the lessee. + assert_eq!(get_token_account_balance(&sc.svm, &leased_vault).unwrap(), 0); + assert_eq!( + get_token_account_balance(&sc.svm, &lessee_leased_ata).unwrap(), + LEASED_AMOUNT + ); + // Collateral moved from the lessee into the collateral vault. + assert_eq!( + get_token_account_balance(&sc.svm, &collateral_vault).unwrap(), + REQUIRED_COLLATERAL + ); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.lessee_collateral_ata).unwrap(), + 1_000_000_000 - REQUIRED_COLLATERAL + ); +} + +#[test] +fn pay_rent_streams_collateral_by_elapsed_time() { + let mut sc = full_setup(); + let lease_id = 3u64; + + let create_ix = build_create_lease_ix( + &sc, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + RENT_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BPS, + LIQUIDATION_BOUNTY_BPS, + ); + let take_ix = build_take_lease_ix(&sc, lease_id); + send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix, take_ix], + &[&sc.lessor, &sc.lessee], + &sc.lessor.pubkey(), + ) + .unwrap(); + + let elapsed: i64 = 120; // 2 minutes + advance_clock_by(&mut sc.svm, elapsed); + + let pay_ix = build_pay_rent_ix(&sc, lease_id); + send_transaction_from_instructions( + &mut sc.svm, + vec![pay_ix], + &[&sc.lessee], + &sc.lessee.pubkey(), + ) + .unwrap(); + + let expected_rent = (elapsed as u64) * RENT_PER_SECOND; + let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); + assert_eq!( + get_token_account_balance(&sc.svm, &lessor_collateral_ata).unwrap(), + expected_rent + ); + let (_, _, collateral_vault) = lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + assert_eq!( + get_token_account_balance(&sc.svm, &collateral_vault).unwrap(), + REQUIRED_COLLATERAL - expected_rent + ); +} + +#[test] +fn top_up_collateral_increases_vault_balance() { + let mut sc = full_setup(); + let lease_id = 4u64; + + let create_ix = build_create_lease_ix( + &sc, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + RENT_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BPS, + LIQUIDATION_BOUNTY_BPS, + ); + let take_ix = build_take_lease_ix(&sc, lease_id); + send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix, take_ix], + &[&sc.lessor, &sc.lessee], + &sc.lessor.pubkey(), + ) + .unwrap(); + + let top_up_amount: u64 = 50_000_000; + let top_up_ix = build_top_up_ix(&sc, lease_id, top_up_amount); + send_transaction_from_instructions( + &mut sc.svm, + vec![top_up_ix], + &[&sc.lessee], + &sc.lessee.pubkey(), + ) + .unwrap(); + + let (_, _, collateral_vault) = lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + assert_eq!( + get_token_account_balance(&sc.svm, &collateral_vault).unwrap(), + REQUIRED_COLLATERAL + top_up_amount + ); +} + +#[test] +fn return_lease_refunds_unused_collateral() { + let mut sc = full_setup(); + let lease_id = 5u64; + + let create_ix = build_create_lease_ix( + &sc, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + RENT_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BPS, + LIQUIDATION_BOUNTY_BPS, + ); + let take_ix = build_take_lease_ix(&sc, lease_id); + send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix, take_ix], + &[&sc.lessor, &sc.lessee], + &sc.lessor.pubkey(), + ) + .unwrap(); + + // Lessee returns early — 10 minutes in, for a 24h lease. + let elapsed: i64 = 600; + advance_clock_by(&mut sc.svm, elapsed); + + let return_ix = build_return_lease_ix(&sc, lease_id); + send_transaction_from_instructions( + &mut sc.svm, + vec![return_ix], + &[&sc.lessee], + &sc.lessee.pubkey(), + ) + .unwrap(); + + let rent_paid = (elapsed as u64) * RENT_PER_SECOND; + let refund_expected = REQUIRED_COLLATERAL - rent_paid; + + // Lessor got their leased tokens back. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.lessor_leased_ata).unwrap(), + 1_000_000_000 + ); + // Lessor also received the accrued rent. + let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); + assert_eq!( + get_token_account_balance(&sc.svm, &lessor_collateral_ata).unwrap(), + rent_paid + ); + // Lessee got the unused-time portion of their collateral back. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.lessee_collateral_ata).unwrap(), + 1_000_000_000 - REQUIRED_COLLATERAL + refund_expected + ); + + // Lease + vault PDAs are closed. + let (lease_pda, leased_vault, collateral_vault) = + lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + assert!(sc.svm.get_account(&lease_pda).is_none()); + assert!(sc.svm.get_account(&leased_vault).is_none()); + assert!(sc.svm.get_account(&collateral_vault).is_none()); +} + +#[test] +fn liquidate_seizes_collateral_on_price_drop() { + let mut sc = full_setup(); + let lease_id = 6u64; + + let create_ix = build_create_lease_ix( + &sc, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + RENT_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BPS, + LIQUIDATION_BOUNTY_BPS, + ); + let take_ix = build_take_lease_ix(&sc, lease_id); + send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix, take_ix], + &[&sc.lessor, &sc.lessee], + &sc.lessor.pubkey(), + ) + .unwrap(); + + // A bit of rent accrues before the liquidation call so the handler has to + // settle rent *and* bounty on the same vault balance. + let elapsed: i64 = 300; + advance_clock_by(&mut sc.svm, elapsed); + + // Install a Pyth price that quotes leased-in-collateral at 4.0 per unit + // with exponent 0. At 100 leased units the debt is 400 collateral units + // vs. the 200 collateral we hold — ratio 50%, well below 120% margin. + let price_update_key = Keypair::new(); + let now = current_clock(&sc.svm); + mock_price_update( + &mut sc.svm, + price_update_key.pubkey(), + 4, + 0, + now, // fresh publish_time + ); + + let liq_ix = build_liquidate_ix(&sc, lease_id, price_update_key.pubkey()); + send_transaction_from_instructions( + &mut sc.svm, + vec![liq_ix], + &[&sc.keeper], + &sc.keeper.pubkey(), + ) + .unwrap(); + + let rent_paid = (elapsed as u64) * RENT_PER_SECOND; + let remaining_after_rent = REQUIRED_COLLATERAL - rent_paid; + let bounty = remaining_after_rent * (LIQUIDATION_BOUNTY_BPS as u64) / 10_000; + let lessor_share = remaining_after_rent - bounty; + + let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); + let keeper_collateral_ata = derive_ata(&sc.keeper.pubkey(), &sc.collateral_mint); + + assert_eq!( + get_token_account_balance(&sc.svm, &lessor_collateral_ata).unwrap(), + rent_paid + lessor_share + ); + assert_eq!( + get_token_account_balance(&sc.svm, &keeper_collateral_ata).unwrap(), + bounty + ); + + // Vaults and lease account closed. + let (lease_pda, leased_vault, collateral_vault) = + lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + assert!(sc.svm.get_account(&lease_pda).is_none()); + assert!(sc.svm.get_account(&leased_vault).is_none()); + assert!(sc.svm.get_account(&collateral_vault).is_none()); +} + +#[test] +fn liquidate_rejects_healthy_position() { + let mut sc = full_setup(); + let lease_id = 7u64; + + let create_ix = build_create_lease_ix( + &sc, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + RENT_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BPS, + LIQUIDATION_BOUNTY_BPS, + ); + let take_ix = build_take_lease_ix(&sc, lease_id); + send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix, take_ix], + &[&sc.lessor, &sc.lessee], + &sc.lessor.pubkey(), + ) + .unwrap(); + + // Price of 1.0 per leased token → debt = 100 collateral units, collateral + // = 200 → ratio 200% ≥ 120% maintenance margin. Expect the transaction + // to fail with `PositionHealthy`. + let price_update_key = Keypair::new(); + let now = current_clock(&sc.svm); + mock_price_update(&mut sc.svm, price_update_key.pubkey(), 1, 0, now); + + let liq_ix = build_liquidate_ix(&sc, lease_id, price_update_key.pubkey()); + let result = send_transaction_from_instructions( + &mut sc.svm, + vec![liq_ix], + &[&sc.keeper], + &sc.keeper.pubkey(), + ); + assert!(result.is_err(), "healthy liquidation must fail"); +} + +#[test] +fn close_expired_reclaims_collateral_after_end_ts() { + let mut sc = full_setup(); + let lease_id = 8u64; + + let create_ix = build_create_lease_ix( + &sc, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + RENT_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BPS, + LIQUIDATION_BOUNTY_BPS, + ); + let take_ix = build_take_lease_ix(&sc, lease_id); + send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix, take_ix], + &[&sc.lessor, &sc.lessee], + &sc.lessor.pubkey(), + ) + .unwrap(); + + // Jump past the lease end. + advance_clock_by(&mut sc.svm, DURATION_SECONDS + 1); + + let close_ix = build_close_expired_ix(&sc, lease_id); + send_transaction_from_instructions( + &mut sc.svm, + vec![close_ix], + &[&sc.lessor], + &sc.lessor.pubkey(), + ) + .unwrap(); + + // Full collateral forfeited to the lessor. Leased tokens are gone (the + // lessee kept them on default) so the lessor's leased balance is only + // what they had after the initial escrow minus the leased amount. + let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); + assert_eq!( + get_token_account_balance(&sc.svm, &lessor_collateral_ata).unwrap(), + REQUIRED_COLLATERAL + ); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.lessor_leased_ata).unwrap(), + 1_000_000_000 - LEASED_AMOUNT + ); + + let (lease_pda, leased_vault, collateral_vault) = + lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + assert!(sc.svm.get_account(&lease_pda).is_none()); + assert!(sc.svm.get_account(&leased_vault).is_none()); + assert!(sc.svm.get_account(&collateral_vault).is_none()); +} + +#[test] +fn close_expired_cancels_listed_lease() { + let mut sc = full_setup(); + let lease_id = 9u64; + + let create_ix = build_create_lease_ix( + &sc, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + RENT_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BPS, + LIQUIDATION_BOUNTY_BPS, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix], + &[&sc.lessor], + &sc.lessor.pubkey(), + ) + .unwrap(); + + // Lessor bails before anyone takes the lease — allowed immediately. + let close_ix = build_close_expired_ix(&sc, lease_id); + send_transaction_from_instructions( + &mut sc.svm, + vec![close_ix], + &[&sc.lessor], + &sc.lessor.pubkey(), + ) + .unwrap(); + + // Lessor recovered the full leased amount. No collateral was ever posted. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.lessor_leased_ata).unwrap(), + 1_000_000_000 + ); + let (lease_pda, leased_vault, collateral_vault) = + lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + assert!(sc.svm.get_account(&lease_pda).is_none()); + assert!(sc.svm.get_account(&leased_vault).is_none()); + assert!(sc.svm.get_account(&collateral_vault).is_none()); +} From 6dab03fc7ddd161886161afb7c36671d3d9d319b Mon Sep 17 00:00:00 2001 From: Edward Date: Sat, 18 Apr 2026 20:57:21 +0000 Subject: [PATCH 02/41] docs(asset-leasing): beginner-friendly README explaining finance concepts The previous README assumed familiarity with collateral, margin, liquidation, keepers, basis points and oracles. This rewrite teaches all of them from scratch for a developer writing their first Solana program. Structure: - plain-English intro with a car-rental analogy and a governance-leasing use case - concept-by-concept primer (SPL tokens, collateral, maintenance margin, liquidation, keepers, bps, oracles, per-second rent, PDAs, vaults) - full lifecycle walked through with concrete numbers for every path (happy, margin call, liquidation, default, cancelled listing) - expanded instructions table that explains WHY each instruction exists - accounts + PDAs reference - Pyth integration, including why we decode manually (SDK/borsh conflict) - safety/edge-case discussion - build/test section with LiteSVM explained - extension ideas and further reading Every claim in the README was cross-checked against the source files. --- defi/asset-leasing/anchor/README.md | 1152 ++++++++++++++++++++++++++- 1 file changed, 1113 insertions(+), 39 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index d924c8e9b..f99c5d018 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -1,58 +1,1132 @@ # Asset Leasing -Fixed-term leasing of SPL tokens with SPL collateral, time-streamed rent, and -Pyth-priced liquidation. +Fixed-term leasing of SPL tokens on Solana, with SPL collateral, rent that +streams by the second, and Pyth-priced liquidation when the lessee stops +posting enough collateral. -A lessor lists a batch of leased tokens along with the rental terms. Once a -lessee takes the lease they deposit collateral and receive the tokens. Rent -accrues per second in the collateral mint and can be swept to the lessor at -any time. If the collateral falls below the maintenance margin (priced via a -Pyth `PriceUpdateV2` account), a keeper can liquidate the position and earn a -small bounty — the rest of the collateral compensates the lessor. At expiry, -a lessee who fails to return the tokens forfeits all of their collateral. +This README is written as a **teaching document**. It assumes you have +written code before (you know what a function is, you can read basic Rust +or are happy to Google as you go) but it does **not** assume you know +anything about traditional finance. No previous Solana programs required +either — "this is my first program" is exactly the audience. -## Instructions +If you already know what collateral, maintenance margin, basis points, +liquidation and oracles mean, skip straight to the [Instructions +reference](#4-instructions-reference) or the [Lifecycle walk-through +](#3-the-full-lifecycle-walked-through-with-numbers). -| Instruction | Who calls it | What it does | +--- + +## Table of contents + +1. [What is asset leasing?](#1-what-is-asset-leasing) +2. [Key concepts, explained from scratch](#2-key-concepts-explained-from-scratch) +3. [The full lifecycle, walked through with numbers](#3-the-full-lifecycle-walked-through-with-numbers) +4. [Instructions reference](#4-instructions-reference) +5. [Accounts and PDAs](#5-accounts-and-pdas) +6. [Pyth integration, in plain English](#6-pyth-integration-in-plain-english) +7. [Safety and edge cases worth knowing](#7-safety-and-edge-cases-worth-knowing) +8. [How to build and test](#8-how-to-build-and-test) +9. [Extending this example](#9-extending-this-example) +10. [Further reading](#10-further-reading) + +--- + +## 1. What is asset leasing? + +The short version: **renting a token instead of buying it, with a refundable +security deposit that keeps the renter honest.** + +### A real-world analogy + +Imagine you want to drive a car for a week but you do not want to buy one. +You walk into a rental place. They hand you the keys, but only after you +leave a **security deposit** on your credit card and agree to a **daily +rate**. Every day you keep the car, they bill you for another day. If the +car's value suddenly crashes (they discover it has a bent chassis), they +might ask for a **bigger deposit** so they are still protected. If you +never return the car, they **keep your deposit** to cover the loss. If +you return it on time and pay the rent you owe, they hand your deposit +back, minus the days you used. + +Asset leasing on Solana is the same shape: + +- The **lessor** (the car rental company) owns some SPL tokens and is + happy to lend them out for a fee. +- The **lessee** (the renter) wants those tokens for a while but does not + want to buy them outright. +- The lessee posts **collateral** (security deposit) in a different SPL + token, and in exchange gets the leased tokens. +- **Rent** ticks away per second, paid out of that collateral into the + lessor's wallet. +- If the market value of the collateral drops versus the leased tokens, + the lessee must **top up** or risk being **liquidated** — a third + party ("keeper") takes the collateral, the lessor is made whole, and + the keeper earns a small fee for the trouble. +- If the lessee disappears and never returns the tokens, the lessor + **keeps the whole collateral** to compensate for the loss. + +### Why would anyone want to do this? + +A concrete example — **governance leasing**. + +> Alice owns 10,000 tokens of a DAO's governance token. She does **not +> want to sell** them — they grow in value, she likes the project, and +> she wants to keep her long-term position. But right now she is not +> voting on anything and the tokens sit idle. +> +> Bob, meanwhile, wants to vote on an upcoming proposal. He does not +> want to spend the cash to buy 10,000 tokens just to vote once. What +> he does want is **temporary voting power for a week**. +> +> They lease. Alice posts her 10,000 tokens for rent. Bob takes the +> lease, hands over USDC as collateral, gets the 10,000 governance +> tokens, votes, and returns them a week later. Alice earns a week of +> rent in USDC for doing nothing. Bob got voting power without burning +> capital on tokens he does not want to hold long-term. Everyone is +> happier than they would have been otherwise. + +The same pattern works for: + +- **NFT utility rental** — rent a game item, an access pass, or a + profile-picture NFT for a weekend. +- **Collateral rental** — borrow an asset to use as collateral elsewhere + (rehypothecation, in traditional-finance speak), return it later. +- **Voting / boost rental** — "ve-token" systems where holding the + token gives extra yield; a DAO that wants a temporary boost can lease + instead of buying. + +The onchain bit matters because there is **no trusted middleman**. The +program enforces the rules: the collateral is locked, the rent is paid +automatically, and if either party misbehaves the other has an +onchain remedy. You do not have to trust Alice not to run off with +Bob's deposit, and Alice does not have to trust Bob to return the +tokens — the program enforces both sides. + +--- + +## 2. Key concepts, explained from scratch + +Before reading the code, it helps to know what these words mean. None of +them are complicated — they are just jargon the finance world uses, and +the code re-uses the same vocabulary. + +### 2.1 SPL tokens + +On Ethereum you have ERC-20 tokens. **SPL tokens are the Solana +equivalent** — a common standard so any wallet, any program, and any UI +can speak to any token the same way. Both the leased asset and the +collateral in this example are SPL tokens. USDC on Solana is an SPL +token. Wrapped SOL is an SPL token. A DAO's governance token is +(probably) an SPL token. You can mint your own in a few lines. The +program does not care which mint is used — only that both sides agreed +on them at listing time. + +### 2.2 Collateral + +Collateral is a **security deposit**. It is something valuable the +borrower gives up temporarily to reassure the lender. + +Why demand it? Because once the lessee has your tokens, nothing except +collateral stops them from walking away. "Skin in the game" is the +phrase — the lessee has something to lose, so cooperating (returning +the tokens on time, paying rent) is more profitable than defecting +(keeping the tokens and losing the deposit). + +In this program the collateral lives in a program-owned `collateral_vault`. +The lessee cannot touch it. The program will only release it under the +rules defined in the code (return, top-up withdrawal, liquidation, or +expiry). + +### 2.3 Maintenance margin + +This is where the finance vocabulary bites, but the idea is simple. + +**The collateral must stay worth more than the thing being borrowed.** +How much more? That is the maintenance margin, expressed as a +percentage (well, a basis-point number — see §2.6). + +In this program, the margin is stored as `maintenance_margin_bps`. If +you set it to `12_000`, you are saying: + +> collateral value must be ≥ 120% of the leased-asset value at all +> times. If it falls below 120%, the position is **underwater** and can +> be liquidated. + +Worked example (same mint for simplicity): + +- Alice lists 100 GOV tokens. +- The maintenance margin is 120% (`12_000` bps). +- Right now 1 GOV = 1 USDC. So the debt is 100 USDC. +- Required collateral to stay healthy: 100 × 120% = **120 USDC**. +- Bob posts 200 USDC to be safe. + +A week later GOV pumps to 1.80 USDC: + +- Debt value is now 100 × 1.80 = 180 USDC. +- Required collateral: 180 × 120% = **216 USDC**. +- Bob only has 200 locked up. He is now **underwater**. Unless he tops + up, a keeper can liquidate him. + +Why the margin exists: price quotes are stale by the time you see them, +and a 0.01% cushion is not enough to cover even a small move. A 120% +or 150% margin gives the lessor a buffer against the inevitable price +swings between liquidation checks. + +### 2.4 Liquidation + +Liquidation is **the eject button**. When a position breaches the +maintenance margin, the protocol seizes the collateral, pays the +lessor what they are owed, and closes the position. + +Why does the lessee not just volunteer to close? Because by the time +they are underwater, defaulting might be cheaper for them than +returning the tokens. Liquidation makes sure the lessor still gets +paid even when the lessee disappears. + +**Here is a crucial Solana detail**: Solana programs cannot run +themselves. They have no background thread, no cron, no "trigger when +price changes". Every bit of code runs only because someone (a wallet, +a bot) sent a transaction that called an instruction. + +So the program cannot "automatically" liquidate anyone. It has to wait +for someone to send a `liquidate` transaction, providing fresh price +data, and *then* it decides whether the liquidation is valid. This +is why we need keepers. + +### 2.5 Keepers and the keeper bounty + +A **keeper** is a bot (or a person) that watches the chain, spots +leases that have gone underwater, and sends `liquidate` transactions +to clean them up. Keepers are not special — they are not on some +whitelist, they have no privileged access. They are just accounts +with a script. + +Why would anyone run a keeper? Because the program pays them. On a +successful liquidation, a configurable **bounty** (a small share of +the seized collateral, capped at 20% by `MAX_LIQUIDATION_BOUNTY_BPS`) +goes to the keeper. Everyone else's share is smaller as a result, so +the lessor does not love paying it, but it is strictly better than +having no keeper at all and letting the position rot. + +This is a common DeFi pattern: **economic incentives replace trusted +operators**. Instead of hiring someone to watch positions, you write +a rule that pays whoever notices first, and the market takes care of +the rest. Keepers compete on speed; the lessor pays a tiny toll to +keep the system honest. + +### 2.6 Basis points (bps) + +The finance world avoids "percent" when precision matters. Instead it +uses **basis points**: 1 bp = 0.01%, 100 bps = 1%, **10,000 bps = 100%**. + +Why? Mostly two reasons: + +- **Clarity.** "Raise rates by 25 bps" is unambiguous. "Raise rates by + 0.25 percent" could mean 0.25 percentage points (reasonable) or + 0.25% of the current rate (much smaller). Bps never have that + problem. +- **Integer arithmetic.** On Solana, floating-point is slow and has + rounding surprises. Integer math is cheap and exact. If you express + ratios as bps you can do `amount * bps / 10_000` with plain `u64`s + and never touch a float. + +This program uses bps everywhere a ratio appears: + +- `maintenance_margin_bps` — e.g. `12_000` for 120%. +- `liquidation_bounty_bps` — e.g. `500` for 5%. +- The constant `BPS_DENOMINATOR` (= `10_000`) is the divisor. + +### 2.7 Oracles — why programs need them + +A Solana program is a pure function: given the accounts you hand it, +it computes a result. It **cannot call out** to an external API, it +**cannot read** a price from CoinGecko, and it does not have a +"world model" with current prices baked in. + +So how does the program know whether a position is underwater? It +needs someone to **push the price onchain** for it to read. That +someone is an **oracle**. + +**Pyth** is one of the main oracle networks on Solana. A set of +trusted publishers submit their prices every few seconds to the Pyth +program on Solana. Pyth then aggregates them into a single "official" +price, and stores the result in a special account you can read like +any other — the `PriceUpdateV2` account. + +When the keeper calls `liquidate`, they pass in a freshly-updated +`PriceUpdateV2` account. The program: + +1. Checks the account is actually owned by Pyth's receiver program + (not a malicious account someone minted). +2. Reads the price out of it. +3. Checks that the price is not stale (more on this in §2.8). +4. Uses the price to compute `collateral_value / debt_value`. +5. Compares that to the maintenance margin. + +Without an oracle, the program has no idea what "worth more" means — +it only sees token amounts, not token values. The price feed is the +bridge from "100 GOV, 200 USDC" to "the collateral is worth 2× +the debt". + +> **Note.** The program trusts the keeper to supply a feed that +> actually quotes the leased asset in collateral units. Nothing in the +> code pins a specific feed to a specific lease — that would be a +> sensible upgrade (see §9). + +### 2.8 Per-second rent + +You could imagine a lease where the lessee pays a flat lump sum up +front, regardless of whether they use it for a minute or a month. +That is simple but wasteful: a lessee who returns early gets nothing +back, a lessee who runs late pays the same as one who returns on time. + +This program instead does **streaming rent**: rent accrues by the +second. Every time anyone calls `pay_rent`, the program computes +`rent_per_second × seconds_elapsed_since_last_payment`, pulls that +out of the collateral vault, and sends it to the lessor. + +From the code: + +```rust +pub fn compute_rent_due(lease: &Lease, now: i64) -> Result { + let cutoff = now.min(lease.end_ts); + if cutoff <= lease.last_rent_paid_ts { + return Ok(0); + } + let elapsed = (cutoff - lease.last_rent_paid_ts) as u64; + elapsed + .checked_mul(lease.rent_per_second) + .ok_or(AssetLeasingError::MathOverflow.into()) +} +``` + +Note two details: + +- `cutoff = now.min(end_ts)` — rent stops accruing after the lease + ends. If the lessee is 3 days late, they only owe rent up to + `end_ts`, not for the late days. (The lessor gets the whole + collateral by default, so "late fees" are not needed here.) +- The caller sends `pay_rent` whenever they like, but `now` on Solana + is taken from the **clock sysvar**, not from the caller. So the + lessee cannot cheat by sending early transactions with a fake + timestamp. + +### 2.9 PDAs — how a program owns things without a private key + +A Program Derived Address is a **public key with no private key**. +It is deterministically computed from a program id plus some "seeds" +(arbitrary byte strings you choose). Because there is no private key, +no wallet can sign for it — except the program that owns it, which +can sign by replaying the seeds. + +In this codebase you will see seeds like: + +```rust +pub const LEASE_SEED: &[u8] = b"lease"; +pub const LEASED_VAULT_SEED: &[u8] = b"leased_vault"; +pub const COLLATERAL_VAULT_SEED: &[u8] = b"collateral_vault"; +``` + +And in the account constraints, something like: + +```rust +seeds = [LEASE_SEED, lessor.key().as_ref(), &lease_id.to_le_bytes()], +``` + +That address is **deterministic**: given the program id, the lessor's +pubkey, and `lease_id`, everyone in the world can compute the same +PDA. Convenient for UIs ("the lease for (lessor=X, id=7) lives +*here*") and impossible to spoof (nobody can beat the program to the +address). + +### 2.10 Vault accounts + +The `leased_vault` and `collateral_vault` are **SPL token accounts +whose authority is a PDA**. Ordinary tokens sit in wallets. These +tokens sit in accounts the program controls: only the program can +sign for moves out of them, and only under the rules the program +encodes. + +From `create_lease.rs`: + +```rust +#[account( + init, + payer = lessor, + seeds = [LEASED_VAULT_SEED, lease.key().as_ref()], + bump, + token::mint = leased_mint, + token::authority = leased_vault, // <- vault is its own authority + token::token_program = token_program, +)] +pub leased_vault: Box>, +``` + +Note the authority is the vault itself — a self-authorising PDA. The +handler moves tokens out by re-deriving the seeds and using them to +"sign" the CPI (cross-program invocation) to the token program: + +```rust +let leased_vault_seeds: &[&[u8]] = &[ + LEASED_VAULT_SEED, + lease_key.as_ref(), + core::slice::from_ref(&leased_vault_bump), +]; +let signer_seeds = [leased_vault_seeds]; +transfer_tokens_from_vault( + &context.accounts.leased_vault, + &context.accounts.lessee_leased_account, + leased_amount, + &context.accounts.leased_mint, + &context.accounts.leased_vault.to_account_info(), + &context.accounts.token_program, + &signer_seeds, +)?; +``` + +The "signature" here is not a crypto signature in the traditional +sense — it is the runtime saying "yes, this program is allowed to +move these tokens because it re-derived the seeds that produced the +authority, so it must be the program that set them up". + +--- + +## 3. The full lifecycle, walked through with numbers + +Let's follow a single lease through every path the program supports. +To keep the numbers friendly, we will use two fake SPL tokens: + +- **GOV** — a governance token, 6 decimals. Current price: 1 USDC each. +- **USDC** — everyone's favourite stablecoin, 6 decimals. Worth 1 USDC + (obviously). + +Meet the cast: + +- **Alice** — lessor. Owns 1,000 GOV and does not want to sell. +- **Bob** — lessee. Wants GOV temporarily, has some USDC. +- **Kim** — keeper. Runs a bot. Has no direct interest in either + side, but will happily pocket a bounty for noticing if Bob goes + underwater. + +All amounts below are in whole-token units for readability; in the +actual test the same logic runs in 6-decimal base units. + +### 3.1 Listing — `create_lease` + +Alice calls `create_lease` with: + +| Parameter | Value | Meaning | | --- | --- | --- | -| `create_lease` | Lessor | Locks the leased tokens in a program vault and publishes the terms. Lease starts in `Listed`. | -| `take_lease` | Lessee | Posts the required collateral, receives the leased tokens. Status → `Active`. | -| `pay_rent` | Anyone | Streams accrued rent (seconds × `rent_per_second`) from the collateral vault to the lessor. | -| `top_up_collateral` | Lessee | Adds more collateral to stay above the maintenance margin. | -| `return_lease` | Lessee | Returns the leased tokens, settles final rent, refunds any unused collateral, closes the lease. | -| `liquidate` | Keeper | If the position is underwater per the supplied Pyth price, seizes collateral, pays bounty to keeper + balance to lessor. | -| `close_expired` | Lessor | Cancels an unrented `Listed` lease or, after `end_ts`, claims a defaulted lessee's collateral. | +| `lease_id` | `1` | Unique per-lessor id so she can run multiple leases | +| `leased_amount` | `1_000` GOV | Amount she wants to lease out | +| `required_collateral_amount` | `1_200` USDC | Deposit Bob must post | +| `rent_per_second` | `1` USDC | Rent rate — ridiculously high for clarity | +| `duration_seconds` | `604_800` | One week | +| `maintenance_margin_bps` | `12_000` | 120% | +| `liquidation_bounty_bps` | `500` | 5% of the remaining collateral | + +What happens: + +1. The program creates three new accounts: the `Lease` PDA, the + `leased_vault` (for GOV), and the `collateral_vault` (for USDC). +2. It transfers 1,000 GOV from Alice's wallet into the `leased_vault`. + **This happens at listing, not at take-up**, so a lessee can never + accept a lease where the lessor does not actually have the goods. +3. It records all the terms on the `Lease` account, with `status = + Listed`, `lessee = Pubkey::default()`, `collateral_amount = 0`. + +At this point Alice's wallet has 1,000 fewer GOV. They are escrowed. + +### 3.2 Taking — `take_lease` + +Bob sees Alice's listing (via a UI that queries `Lease` accounts). +He decides to take it. He calls `take_lease`. The program: + +1. Verifies the lease is `Listed` and that the mints match. +2. Transfers 1,200 USDC from Bob's wallet into the `collateral_vault`. +3. Transfers 1,000 GOV out of the `leased_vault` into Bob's GOV ATA + (created on the fly if he did not have one). +4. Updates the `Lease`: `lessee = Bob`, `collateral_amount = 1_200`, + `start_ts = now`, `end_ts = now + 604_800`, `last_rent_paid_ts = + now`, `status = Active`. + +Now: + +- The `leased_vault` is empty (Bob has the GOV). +- The `collateral_vault` holds 1,200 USDC. +- Alice has neither the GOV nor the USDC; she has an onchain claim + to rent and, eventually, the GOV back. + +### 3.3 Happy path — return on time + +Two days in (172,800 seconds) Bob has finished his governance voting +and wants to return the tokens. A few things might have happened in +between: + +- Someone (Bob, or a public-spirited keeper, or Alice herself) may + have called `pay_rent` from time to time, streaming rent from the + vault to Alice. It is not required — the tally is cumulative. +- The price of GOV may have moved either way. As long as the position + stayed above the maintenance margin, nothing special happened. + +Bob calls `return_lease`. The program: + +1. Transfers 1,000 GOV from Bob back into the `leased_vault`, then + straight out to Alice. +2. Computes accrued rent since `last_rent_paid_ts`. In this example: + 172,800 s × 1 USDC/s = 172,800 USDC of rent — clearly more than + the 1,200 USDC collateral, so the program caps it at the vault + balance. (Obviously you would choose a sensible rent rate in + practice; these numbers are just to illustrate.) +3. Pays the rent to Alice, refunds **the rest of the collateral** to + Bob. +4. Closes both vaults and the `Lease` account, sending the + rent-exempt lamports back to Alice. + +Key point: **Bob never pays rent for time he did not use.** The +`cutoff = now.min(end_ts)` in `compute_rent_due` and the "return +early" code path together guarantee it. + +(In realistic numbers — say `rent_per_second = 10` base-units of +USDC — 172,800 s of rent is 1.728 USDC, Alice gets that, Bob gets +the other ~1,198.27 USDC back. Much happier arithmetic.) + +### 3.4 Margin-call path — Bob tops up + +Halfway through the week, GOV moons from 1.00 to 1.50 USDC. Now: + +- Debt value: 1,000 GOV × 1.50 = 1,500 USDC. +- Required cushion at 120%: 1,800 USDC. +- Bob only has 1,200 USDC locked. + +If any keeper calls `liquidate` with a fresh price, the program will +agree the position is underwater and seize the collateral. Bob does +not want that — he wants to finish his week. So he calls +`top_up_collateral` with `amount = 700` USDC. That tops the vault up +to 1,900 USDC, back above the 1,800 requirement. Any liquidation +attempt will now be rejected with `PositionHealthy`. + +The code here is small and boring — exactly the sign of a good +function: + +```rust +pub fn handle_top_up_collateral(context: Context, amount: u64) -> Result<()> { + require!(amount > 0, AssetLeasingError::InvalidCollateralAmount); + transfer_tokens_from_user(...)?; + context.accounts.lease.collateral_amount = context + .accounts + .lease + .collateral_amount + .checked_add(amount) + .ok_or(AssetLeasingError::MathOverflow)?; + Ok(()) +} +``` + +Note only the `lessee` can top up their own lease (`constraint = +lease.lessee == lessee.key()`). + +### 3.5 Liquidation path — Bob does nothing + +Same setup, but Bob is either asleep, out of USDC, or hoping the +price will come back. He does not top up. A keeper (Kim) is watching +and notices the lease is now underwater. She submits `liquidate` with +a recent `PriceUpdateV2` account quoting GOV at 1.50 USDC. + +The program: + +1. Verifies the `PriceUpdateV2` account is owned by the Pyth receiver + program (rejects anything else). +2. Decodes the price and publish time. Rejects if the price is + stale (> 60 s old) or in the future or non-positive. +3. Computes whether the position is underwater: + `collateral_value × 10_000 < debt_value × margin_bps`. + With 1,200 USDC vs. 1,500×1.20 = 1,800 USDC, yes — underwater. +4. Pays accrued rent to Alice first (so she gets paid for the time + Bob did use). +5. Takes the **remaining** collateral and slices off the bounty: + 5% × remaining → Kim. The rest → Alice. +6. Closes both vaults and the lease account. `status = Liquidated`, + `collateral_amount = 0`. + +Notice: the leased vault is empty in this path. Bob kept the GOV. +Alice's compensation is purely in collateral. That is by design — +the collateral exists specifically to cover this case. If the +margin was set high enough to begin with, Alice is whole. + +### 3.6 Default path — the week runs out, Bob ghosts + +Bob never returns the tokens and never gets liquidated (maybe the +price held steady, so no keeper had cause to liquidate him). A week +later `end_ts` passes. The lease is still `Active`, but by its own +terms it has expired. + +Alice calls `close_expired`. The program: + +1. Checks `lease.status` is `Active` (or `Listed` — same instruction + handles both). +2. If `Active`, requires `now >= end_ts`. +3. Drains whatever is in the leased vault back to Alice. (In this + default case: zero — Bob has the GOV.) +4. Drains the collateral vault back to Alice. (In this case: the + full 1,200 USDC, because no rent was settled.) +5. Closes both vaults and the lease. + +Alice is out 1,000 GOV but has gained 1,200 USDC. As long as the +lease was priced correctly when created (collateral > leased value), +that is a fair outcome for her. -## Accounts +> **Heads up — a subtlety:** `close_expired` does not settle rent. +> In the default path all the collateral goes to Alice anyway, so +> the distinction does not matter. But if you ever extend the +> instruction (e.g. to refund *partial* collateral to the lessee), +> you will need to decide whether to call `compute_rent_due` first. -- `Lease` PDA — seeded by `(b"lease", lessor, lease_id)`. -- `leased_vault` PDA — seeded by `(b"leased_vault", lease)`, holds the leased tokens between listing and settlement. -- `collateral_vault` PDA — seeded by `(b"collateral_vault", lease)`, escrows the lessee's collateral. +### 3.7 Cancelled listing — Alice changes her mind -## Pyth integration notes +Alice lists the lease and then decides not to rent it out. She can +reclaim her GOV any time before anyone takes it: -The `liquidate` instruction reads a `PriceUpdateV2` account owned by the -canonical Pyth Solana Receiver program -(`rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ`). The price must quote one -leased token in collateral-token units. The program decodes the relevant -fields manually — it does **not** pull in `pyth-solana-receiver-sdk` because -that crate currently has a transitive `borsh` conflict with `anchor-lang` -1.0.0 (see `program-examples/.github/.ghaignore` — `oracles/pyth/anchor` is -flagged for the same reason). +- She calls `close_expired` on the `Listed` lease. +- The `now >= end_ts` check does **not** apply to `Listed` leases + (look at the `if status == Active` guard in the handler — listed + leases skip it). +- The leased vault holds her 1,000 GOV; they go straight back to + her. The collateral vault was never funded, so that part is a + no-op. Both vaults and the lease close. -Staleness is enforced (`publish_time` must be within the last 60 seconds and -must not be in the future). +--- -## Running the tests +## 4. Instructions reference -LiteSVM-based Rust tests live under `programs/asset-leasing/tests/` and load -the built program via `include_bytes!`, so the `.so` must exist first. +There are seven instructions. They map one-to-one onto a file under +`programs/asset-leasing/src/instructions/`. + +| Instruction | Who calls | What it does | Why it exists | +| --- | --- | --- | --- | +| `create_lease` | Lessor | Locks the leased tokens in a program vault and records the terms. Lease starts `Listed`. | Listing up front (rather than on take-up) means a lessee cannot accept a lease the lessor can no longer honour. The tokens are real, escrowed, and nobody but the program can move them. | +| `take_lease` | Lessee | Deposits `required_collateral_amount`, receives the leased tokens. Sets `start_ts`, `end_ts`, `last_rent_paid_ts`. Status → `Active`. | Two-step listing-then-take lets the lessor advertise terms without pre-committing to a specific lessee; anyone can take it first. | +| `pay_rent` | **Anyone** | Computes rent since `last_rent_paid_ts`, transfers it from the collateral vault to the lessor, updates `last_rent_paid_ts`. | Keeping the rent-accrual separate from return/liquidation lets the lessor (or anyone else) settle rent whenever — handy for long leases. The caller pays transaction fees, but no state is otherwise affected if there is no rent to move. | +| `top_up_collateral` | Lessee | Adds more collateral to the vault. | Market prices move. Without a top-up, a short-lived dip during a volatile hour would liquidate every lessee. Top-ups give the lessee a chance to defend their position. | +| `return_lease` | Lessee | Returns the full `leased_amount`, pays final rent, refunds remaining collateral, closes the lease. | The cooperative way to end a lease early or on time. Runs all the settlements in one transaction so there is no window where, say, the tokens have been returned but the collateral is still locked. | +| `liquidate` | Keeper | Verifies the supplied Pyth price, checks the position is underwater, pays accrued rent + bounty + lessor share, closes the lease. Status → `Liquidated`. | The non-cooperative way to end the lease when the collateral no longer covers the debt. Lessor is compensated, keeper is rewarded for doing the work. | +| `close_expired` | Lessor | Two modes: (1) cancel a `Listed` lease to reclaim the leased tokens, (2) sweep collateral + any remaining tokens after a defaulted `Active` lease's `end_ts`. | Lessors need an unclog path — "nobody is taking this, give me my tokens back" — and a default recovery path — "they never returned the tokens, pay me the collateral". Same instruction covers both; the branch is on `lease.status`. | + +Each instruction has its own `Accounts` struct listing every account it +touches, with Anchor constraints (`has_one = lessor`, `constraint = +lease.status == LeaseStatus::Active`, etc.) that run **before** the +handler body. If any constraint fails, the transaction aborts and no +state changes. This is the usual Anchor pattern: put the validation in +the struct, keep the handler body about the business logic. + +### Calling conventions + +- `create_lease`, `take_lease`, `top_up_collateral`, `pay_rent`, + `return_lease` and `close_expired` take the `lease_id` implicit in + the seed-derived `Lease` PDA — the client derives the PDA and passes + it as an account. Only `create_lease` needs `lease_id` as an explicit + argument because the `Lease` PDA does not exist yet to derive it + from. +- All account struct definitions live with their handlers; all of them + are re-exported from `instructions/mod.rs` so `lib.rs` can use them + by name. + +--- + +## 5. Accounts and PDAs + +Three PDAs hold the entire lifecycle of one lease. + +### 5.1 `Lease` — the state account + +Seeded by `(b"lease", lessor, lease_id)`. One lessor can run as many +leases in parallel as they like by using different `lease_id` values. + +Fields (see `src/state/lease.rs` for the authoritative definition): + +| Field | Type | Meaning | +| --- | --- | --- | +| `lease_id` | `u64` | Caller-chosen id, part of the PDA seed. | +| `lessor` | `Pubkey` | Who owns this lease. Receives rent; recovers assets. | +| `lessee` | `Pubkey` | Set by `take_lease`. `Pubkey::default()` while `Listed`. | +| `leased_mint` | `Pubkey` | Mint of the tokens being rented out. | +| `leased_amount` | `u64` | Fixed at creation. Always the same amount is returned. | +| `collateral_mint` | `Pubkey` | Mint of the collateral. | +| `collateral_amount` | `u64` | Live balance of the collateral vault as the program sees it. Increases on top-up, decreases as rent is paid. | +| `required_collateral_amount` | `u64` | Amount the lessee had to post up front. Not the same as `collateral_amount` — the vault's balance changes over time. | +| `rent_per_second` | `u64` | Streaming rate in collateral base-units per second. | +| `duration_seconds` | `i64` | Length of the lease, set at creation. | +| `start_ts`, `end_ts` | `i64` | Filled by `take_lease`. `0` while `Listed`. | +| `last_rent_paid_ts` | `i64` | Point up to which rent has been settled. | +| `maintenance_margin_bps` | `u16` | Health threshold. Capped at `MAX_MAINTENANCE_MARGIN_BPS` = 50_000 (500%). | +| `liquidation_bounty_bps` | `u16` | Keeper bounty. Capped at `MAX_LIQUIDATION_BOUNTY_BPS` = 2_000 (20%). | +| `status` | `LeaseStatus` | `Listed` → `Active` → `Closed`/`Liquidated`. | +| `bump`, `leased_vault_bump`, `collateral_vault_bump` | `u8` | Cached bump seeds so CPIs can re-sign without re-deriving. | + +The lifecycle transitions (copied from the doc comment on +`LeaseStatus`): + +``` +Listed --take_lease--> Active +Active --return_lease--> Closed +Active --liquidate--> Liquidated +Listed --close_expired--> Closed (lessor cancels) +Active --close_expired--> Closed (defaulted lessee, after end_ts) +``` + +Why is it a PDA? So that: + +- There is a **canonical address** anyone can derive. A UI does not + need a database to find Alice's lease #1 — it computes + `find_program_address([b"lease", alice, 1u64.to_le_bytes()], program_id)` + and reads. +- The program can **act as authority** for the account without a + private key. No mnemonic to hide, no key to lose, and no way for + an attacker to forge a signature. +- Collisions are impossible: for the same `(lessor, lease_id)` pair + there is exactly one PDA. `create_lease` uses `init`, which fails + if the account already exists. + +### 5.2 `leased_vault` — escrow for the leased tokens + +An SPL token account. Seeded by `(b"leased_vault", lease)`. Authority +is itself (`token::authority = leased_vault`). + +- Holds the `leased_amount` while the lease is `Listed`. +- Drained on `take_lease` (tokens go to lessee) and on `return_lease` + (tokens go back to lessor via this vault). +- Empty during the `Active` phase (the lessee is holding the + tokens). +- Closed when the lease settles, freeing its rent-exempt lamports + back to the lessor. + +### 5.3 `collateral_vault` — escrow for the collateral + +An SPL token account. Seeded by `(b"collateral_vault", lease)`. +Authority is itself. + +- Funded on `take_lease` with `required_collateral_amount`. +- Grows on `top_up_collateral`. +- Shrinks on every `pay_rent` (rent flows lessor-ward). +- Split between lessor and keeper on `liquidate`. +- Fully drained on `return_lease` / `close_expired`. +- Closed at settlement, lamports to the lessor. + +### 5.4 Associated Token Accounts + +The program touches several **associated token accounts** (ATAs) — +token accounts deterministically derived from `(wallet, mint)`. You +will see `associated_token::mint = X, associated_token::authority = +Y` in account structs. These are not PDAs *of this program*; they are +PDAs of the Associated Token Account program, a standard way for any +wallet to have "the account" for any given mint. + +Many of the ATAs are marked `init_if_needed` — meaning "create the +account if it does not exist yet, otherwise use the existing one". +This is a small quality-of-life touch: the UI never has to +pre-create token accounts for users, the program does it when it +first needs one. The caller pays the rent for the new account. + +--- + +## 6. Pyth integration, in plain English + +### 6.1 What Pyth is + +**Pyth** is an oracle network. Professional trading firms, exchanges +and market makers run Pyth publisher nodes that submit prices several +times a second. On Solana, the aggregated result is written into +`PriceUpdateV2` accounts you can read from your program like any +other account. + +The key idea: Pyth does not guess prices. Real market participants +report prices from their own systems, Pyth aggregates, signs, and +posts. Your program treats the result as **ground truth for this +block**. + +### 6.2 The `PriceUpdateV2` account + +A `PriceUpdateV2` is a regular Solana account, owned by the Pyth +Solana Receiver program +(`rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ`). Its data layout is +(simplified): + +``` +| 8 bytes | Anchor discriminator for "PriceUpdateV2" +| 32 bytes | write_authority +| 1 byte | verification_level +| 32 bytes | feed_id +| 8 bytes | price (i64) +| 8 bytes | conf (u64) — not used here +| 4 bytes | exponent (i32) +| 8 bytes | publish_time (i64) +... more fields we don't read +``` + +A Pyth price is stored as an integer `price` plus an integer +`exponent`, such that the real price is `price × 10^exponent`. A +`price = 15_000, exponent = -4` means `1.5000`. This keeps the value +exact — no floats anywhere. + +### 6.3 Why our program decodes by hand + +Normally you would import `pyth-solana-receiver-sdk` to parse the +account. We don't, and the comment at the top of `liquidate.rs` +tells you why: + +> We do not pull in `pyth-solana-receiver-sdk` because that crate +> currently has a transitive `borsh` conflict with `anchor-lang` +> 1.0.0 (see `program-examples/.github/.ghaignore` — `oracles/pyth/anchor` +> is flagged for the same reason). + +Anchor 1.0 upgraded Borsh in a way that Pyth's SDK has not caught up +to yet. Rather than fight dependency resolution, we hard-code the +account layout and read the bytes ourselves: + +```rust +pub const PRICE_UPDATE_V2_DISCRIMINATOR: [u8; 8] = + [34, 241, 35, 99, 157, 126, 244, 205]; + +pub fn decode_price_update(data: &[u8]) -> Result { + const PRICE_OFFSET: usize = 73; + const EXPONENT_OFFSET: usize = PRICE_OFFSET + 8 + 8; + const PUBLISH_TIME_OFFSET: usize = EXPONENT_OFFSET + 4; + const MIN_LEN: usize = PUBLISH_TIME_OFFSET + 8; + + require!(data.len() >= MIN_LEN, AssetLeasingError::StalePrice); + require!( + data[..8] == PRICE_UPDATE_V2_DISCRIMINATOR, + AssetLeasingError::StalePrice + ); + // ... i64::from_le_bytes / i32::from_le_bytes out of fixed offsets +} +``` + +Two safety properties make this safe: + +1. The `Accounts` struct declares `#[account(owner = + PYTH_RECEIVER_PROGRAM_ID)]`. Solana's runtime refuses the + transaction if anyone passes an account not owned by Pyth's + receiver program. So the bytes we read are produced by Pyth, not + by an attacker. +2. The first 8 bytes are checked against the `PriceUpdateV2` + discriminator. That rejects any Pyth-owned account of a + *different* type (in case future Pyth versions add sibling + account types). + +If the SDK dependency issue ever gets resolved upstream, future-you +can swap the manual decode for a type-checked `PriceUpdateV2::try_from` +call. The handler's shape won't change. + +### 6.4 Staleness + +Prices are only useful if they are recent. An old price can make a +healthy position look unhealthy, or (much worse) let a keeper +liquidate based on a stale snapshot. + +From `constants.rs`: + +```rust +pub const PYTH_MAX_AGE_SECONDS: u64 = 60; +``` + +and from `is_underwater` in `liquidate.rs`: + +```rust +require!(price.publish_time <= now, AssetLeasingError::StalePrice); +let age = (now - price.publish_time) as u64; +require!(age <= PYTH_MAX_AGE_SECONDS, AssetLeasingError::StalePrice); +``` + +Three checks, for three threat models: + +- `publish_time <= now` — **No future-dated prices.** Guards against + a malicious keeper manufacturing a "future" price to game the + math. (In practice `publish_time` from Pyth is never future, but + defence-in-depth.) +- `age <= 60 s` — **Not too old.** If Pyth has stalled (perhaps a + degraded network), we refuse to liquidate rather than act on stale + data. +- `price > 0` — `NonPositivePrice`. A zero or negative price would + let a liquidator seize collateral for "free debt", obviously + wrong. + +### 6.5 The underwater check + +Here is the actual inequality: + +``` +collateral × 10_000 < debt × maintenance_margin_bps +^ left side ^ right side +``` + +Rearranging: the position is underwater when the collateral-to-debt +ratio falls below the margin. Everything is in integers (Pyth's +`exponent` is folded into one side of the inequality to keep the +scales balanced). See the `is_underwater` function if you want to +walk through the math — it is well-commented. + +--- + +## 7. Safety and edge cases worth knowing + +A few corners of the design worth thinking about. + +### 7.1 What if the lessee returns the tokens while liquidation is in flight? + +Both `return_lease` and `liquidate` mutate the same `Lease` account. +Only one transaction per slot can win — Solana serialises mutations +to each account. Whichever lands first flips the status +(`Closed` for return, `Liquidated` for liquidate). The loser hits the +`constraint = lease.status == LeaseStatus::Active` check and fails. + +So there is no "both happened" race. The user who reacts first wins. + +### 7.2 What if the Pyth oracle stops updating? + +The staleness check (`age <= 60 s`) simply fails. The `liquidate` +transaction aborts with `StalePrice`. Nothing bad happens — lessees +cannot be liquidated on stale data. The trade-off is that if prices +are unavailable and a lessee goes underwater, the lessor just has +to wait until prices are flowing again before a keeper can act. +That's usually preferable to the alternative. + +### 7.3 What if the collateral mint is the same as the leased mint? + +Nothing in `create_lease` currently forbids this. The handler would +succeed, and the resulting lease would be a "token A for token A" +rent — arguably pointless, but not dangerous. There is no +path where the two vaults would get mixed up (they are separate PDAs +with different seeds, even if they hold the same mint). If you +wanted to forbid it, a one-line `require!(leased_mint.key() != +collateral_mint.key(), ...)` in `create_lease` would do it. + +### 7.4 What if rent accrues for longer than the collateral can cover? + +Great question. This is the normal case just before a liquidation: +rent has eaten through the collateral faster than the lessee has +topped it up. + +The handler handles it gracefully: + +```rust +let payable = rent_amount.min(context.accounts.collateral_amount_available()); +``` + +Rent is **capped at the vault balance**. The program never tries to +move tokens that aren't there. The unpaid rent becomes implicit debt, +which will trigger liquidation (or default recovery on `end_ts`) +because the position will fail the maintenance-margin check. In +other words: "lessee ran out of money" and "lessee is underwater" end +up in the same place. The math lines up. + +### 7.5 What if `end_ts` is reached but nobody calls anything? + +Nothing special happens automatically. `Active` leases continue to +exist forever until somebody calls `return_lease`, `liquidate`, or +`close_expired`. Rent stops accruing at `end_ts` because of the +`cutoff = now.min(end_ts)` clamp, so the lessor is not building up +infinite phantom debt. It is just a lease sitting in state waiting +to be cleaned up. + +In production you would usually run a small keeper that calls +`close_expired` shortly after `end_ts` on any `Active` lease, just +to reclaim the rent-exempt lamports and tidy up the account. + +### 7.6 Who pays for what? + +- **Lessor** pays for creating the `Lease`, the `leased_vault` and + the `collateral_vault` at listing (rent-exempt lamports). They + get those back on close, but also have to fund the lessor ATAs if + they did not already exist. +- **Lessee** pays transaction fees for `take_lease`, + `top_up_collateral` and `return_lease`, and funds their own ATA + for the leased mint on `take_lease`. On `return_lease` they also + fund the lessor's ATAs if these do not already exist. +- **Keeper** pays transaction fees for `liquidate`, and funds both + the lessor's and their own collateral ATAs on first use. They + get that back (and then some) via the bounty, so in practice the + cost is negligible. +- **Anyone** who calls `pay_rent` pays a transaction fee. Usually + this is the lessee or a keeper. + +### 7.7 Bounty and max-margin caps + +```rust +pub const MAX_MAINTENANCE_MARGIN_BPS: u16 = 50_000; // 500% +pub const MAX_LIQUIDATION_BOUNTY_BPS: u16 = 2_000; // 20% +``` + +These exist to prevent obvious griefing: + +- A lessor cannot set a 10,000% margin and immediately liquidate + their own lessee on day one. +- A lessor cannot set a 99% bounty that would funnel the lessee's + entire collateral to a friendly keeper, netting Alice zero and + pocketing the difference out of band. The 20% cap keeps the + majority of the collateral with the actual victim (the lessor). + +### 7.8 Anyone can call `pay_rent`, on purpose + +This is deliberate. If only the lessee could pay rent, a lazy (or +absent) lessee could let rent pile up implicitly until they are +liquidated — and the *accrued but unpaid* rent would still be +unpaid in the rent cap path. By letting keepers call `pay_rent` +before they call `liquidate`, they ensure the lessor has received +the owed rent first, regardless of the lessee's participation. + +--- + +## 8. How to build and test + +You need a working Solana + Anchor toolchain (`anchor --version`, +`solana --version` should both return something sensible). This +example was written against Anchor 1.0. ```bash +cd defi/asset-leasing/anchor anchor build cargo test ``` -The tests cover the full lifecycle, including a mocked Pyth price drop that -triggers liquidation and a healthy-position check that must refuse to -liquidate. +### What `anchor build` does + +Compiles the on-chain program (`programs/asset-leasing`) down to a +Berkeley Packet Filter `.so` binary under `target/deploy/`. Generates +an IDL (interface description) plus Rust types the tests then use. + +You have to run `anchor build` **before** `cargo test` — the tests +`include_bytes!` the compiled program so they can load it into +LiteSVM. If the `.so` does not exist, the tests won't compile. + +### What `cargo test` does (and what LiteSVM is) + +The tests live in `programs/asset-leasing/tests/test_asset_leasing.rs`. +They use **LiteSVM**, an in-memory Solana runtime. Think of it as a +"tiny local Solana" you spin up in milliseconds. No validator +process, no ledger on disk, no RPC round-trips. Each test gets a +fresh VM, mints its own tokens, deploys the program from the +`.so`, and runs a scenario. + +LiteSVM is fast enough that you can test the full lifecycle — +`create → take → pay_rent → liquidate` — in a single `#[test]`, +advancing the clock sysvar by hand with `advance_clock_by`. The +existing tests cover: + +| Test | Exercises | +| --- | --- | +| `create_lease_locks_tokens_and_lists` | Lessor funds the leased vault, lease account is created. | +| `take_lease_posts_collateral_and_delivers_tokens` | Collateral flows in, leased tokens flow out. | +| `pay_rent_streams_collateral_by_elapsed_time` | Rent math: elapsed × rate. | +| `top_up_collateral_increases_vault_balance` | Top-up actually increases the vault. | +| `return_lease_refunds_unused_collateral` | Happy path: rent paid, refund issued, accounts closed. | +| `liquidate_seizes_collateral_on_price_drop` | Mocked `PriceUpdateV2` that makes the position underwater. | +| `liquidate_rejects_healthy_position` | Fails with `PositionHealthy` when it would be wrong. | +| `close_expired_reclaims_collateral_after_end_ts` | Default recovery after `end_ts`. | +| `close_expired_cancels_listed_lease` | Lessor cancels an unclaimed listing. | + +The tests do not pull in the real Pyth SDK — they synthesise a +`PriceUpdateV2` body with the right discriminator and offsets, set +the owner to the Pyth receiver program id, and install it into the +LiteSVM account store. This is the whole reason the program decodes +by hand rather than through an SDK — it lets the tests mock oracle +data without a network. + +--- + +## 9. Extending this example + +Real systems always need more features. Here are ideas a learner +can sink their teeth into, ordered roughly by difficulty. + +### Easy + +- **Add `require!` guards for bad combinations.** e.g. reject + `create_lease` when `leased_mint == collateral_mint`, or when + `rent_per_second × duration_seconds` would overflow a `u64`. +- **Emit events.** Add Anchor `#[event]`s on lease creation, + take-up, liquidation — a UI can then subscribe without polling. + +### Medium + +- **Variable rent based on utilisation.** Instead of a fixed + `rent_per_second`, let the protocol compute rent from a curve: + e.g. more expensive when most of a lessor's inventory is + currently leased. This mimics how lending protocols price + borrow rates. +- **Whitelisted lessees.** A `whitelist` account that maps + `(lessor, lessee) -> allowed`. `take_lease` requires the entry + to exist. Basis for a KYC-gated product. +- **Protocol fee.** A small cut of every `pay_rent` goes to a + treasury account the program derives. Same shape as the keeper + bounty, but with a different destination. +- **Partial returns.** Allow the lessee to return part of the + leased tokens and get a proportional share of collateral back. + Tricky: you need to also decide whether the rent rate scales + with the reduced amount. + +### Harder + +- **Multi-token collateral.** The lessee posts a basket (e.g. 40% + SOL, 60% USDC) instead of a single mint. `is_underwater` now + sums each bucket's value using its own Pyth feed. +- **Pin a Pyth feed to a lease.** Instead of trusting the keeper to + supply a correct feed, store a `price_feed_id` on the `Lease` + and require the `PriceUpdateV2`'s `feed_id` field to match. Closes + the "wrong feed" loophole. +- **Dutch auction liquidation.** Instead of a fixed bounty, + the liquidation price starts steep and decays over time. Whoever + is willing to pay the least takes the trade. Better price + discovery, more complex bookkeeping. + +Pick one, try it, and run the existing tests plus a new one for +your feature. That is the honest way to learn this stuff. + +--- + +## 10. Further reading + +### Solana + Anchor + +- [Anchor Book](https://www.anchor-lang.com/) — the official guide, + especially the chapters on PDAs, CPIs and account constraints. +- [Anchor 1.0 release notes](https://github.com/coral-xyz/anchor/releases) + — what changed versus 0.30. +- [SPL Token program docs](https://spl.solana.com/token) — the + token mint / account / transfer model this example builds on. +- [Solana Cookbook — PDAs](https://solanacookbook.com/core-concepts/pdas.html) + — if §2.9 felt too short, start here. + +### Pyth + +- [Pyth Network docs](https://docs.pyth.network/) — what a price + feed is, what publishers look like. +- [Pyth Solana Receiver](https://docs.pyth.network/price-feeds/use-real-time-data/solana) + — how the on-chain `PriceUpdateV2` accounts are produced. +- [Anchor / Pyth borsh conflict tracking issue](https://github.com/pyth-network/pyth-crosschain/issues) + — watch this if you want to eventually drop the manual decode. + +### Sibling examples in this repo + +- [`tokens/escrow/anchor`](../../../tokens/escrow/anchor) — the + simplest "lock tokens until a condition is met" Anchor program. + Good warm-up if the PDA / vault pattern here felt new. +- [`defi/clob/anchor`](../../clob/anchor) — an on-chain central + limit order book. Order matching instead of lease lifecycle. +- Hunt around in `defi/` and `tokens/` — each folder's README is + a little self-contained tutorial. + +### Finance concepts, for the curious + +- Investopedia on **maintenance margin**, **basis points** and + **liquidation** — plain English definitions. +- Aave / Compound technical papers — production lending protocols + use the same collateral-and-liquidation vocabulary. Reading + theirs after this will feel familiar. + +--- + +_This README is a teaching document. If any claim about the program +contradicts what the code actually does, file an issue — the code is +the source of truth._ From 4fb8cc0dc47779a110cc1ae80d65b8728165a059 Mon Sep 17 00:00:00 2001 From: "Edward (Edwardbot)" Date: Sun, 19 Apr 2026 00:10:56 +0000 Subject: [PATCH 03/41] refactor(asset-leasing): extract close_vault helper to shared.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three instruction handlers (liquidate, return_lease, close_expired) had near-identical `close_vault` helpers. The only difference was the destination parameter type (`&Signer` in close_expired, `&UncheckedAccount` in the other two), which was cosmetic — both ultimately called `.to_account_info()`. Move the helper to shared.rs with the destination as `&AccountInfo<'info>` so callers pass `.to_account_info()` at the call site. Deletes 3x15 lines of boilerplate. No behaviour change. All 9 litesvm tests still pass. --- .../src/instructions/close_expired.rs | 26 +++-------------- .../src/instructions/liquidate.rs | 29 +++++-------------- .../src/instructions/return_lease.rs | 26 +++-------------- .../asset-leasing/src/instructions/shared.rs | 28 +++++++++++++++++- 4 files changed, 42 insertions(+), 67 deletions(-) diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs index fff5905ac..5f09d006b 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs @@ -1,13 +1,13 @@ use anchor_lang::prelude::*; use anchor_spl::{ associated_token::AssociatedToken, - token_interface::{close_account, CloseAccount, Mint, TokenAccount, TokenInterface}, + token_interface::{Mint, TokenAccount, TokenInterface}, }; use crate::{ constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, errors::AssetLeasingError, - instructions::shared::transfer_tokens_from_vault, + instructions::shared::{close_vault, transfer_tokens_from_vault}, state::{Lease, LeaseStatus}, }; @@ -141,13 +141,13 @@ pub fn handle_close_expired(context: Context) -> Result<()> { close_vault( &context.accounts.leased_vault, - &context.accounts.lessor, + &context.accounts.lessor.to_account_info(), &context.accounts.token_program, &[leased_vault_seeds], )?; close_vault( &context.accounts.collateral_vault, - &context.accounts.lessor, + &context.accounts.lessor.to_account_info(), &context.accounts.token_program, &[collateral_vault_seeds], )?; @@ -157,21 +157,3 @@ pub fn handle_close_expired(context: Context) -> Result<()> { Ok(()) } - -fn close_vault<'info>( - vault: &InterfaceAccount<'info, TokenAccount>, - destination: &Signer<'info>, - token_program: &Interface<'info, TokenInterface>, - signer_seeds: &[&[&[u8]]], -) -> Result<()> { - let accounts = CloseAccount { - account: vault.to_account_info(), - destination: destination.to_account_info(), - authority: vault.to_account_info(), - }; - close_account(CpiContext::new_with_signer( - token_program.key(), - accounts, - signer_seeds, - )) -} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs index 2259245c3..ea0b70d6a 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs @@ -1,7 +1,7 @@ use anchor_lang::prelude::*; use anchor_spl::{ associated_token::AssociatedToken, - token_interface::{close_account, CloseAccount, Mint, TokenAccount, TokenInterface}, + token_interface::{Mint, TokenAccount, TokenInterface}, }; use crate::{ @@ -10,7 +10,10 @@ use crate::{ PYTH_MAX_AGE_SECONDS, }, errors::AssetLeasingError, - instructions::{pay_rent::compute_rent_due, shared::transfer_tokens_from_vault}, + instructions::{ + pay_rent::compute_rent_due, + shared::{close_vault, transfer_tokens_from_vault}, + }, state::{Lease, LeaseStatus}, }; @@ -233,13 +236,13 @@ pub fn handle_liquidate(context: Context) -> Result<()> { // the rent-exempt lamports. close_vault( &context.accounts.leased_vault, - &context.accounts.lessor, + &context.accounts.lessor.to_account_info(), &context.accounts.token_program, &[leased_vault_seeds], )?; close_vault( &context.accounts.collateral_vault, - &context.accounts.lessor, + &context.accounts.lessor.to_account_info(), &context.accounts.token_program, &[collateral_vault_seeds], )?; @@ -302,21 +305,3 @@ fn ten_pow(exponent: u32) -> Result { .checked_pow(exponent) .ok_or(AssetLeasingError::MathOverflow.into()) } - -fn close_vault<'info>( - vault: &InterfaceAccount<'info, TokenAccount>, - destination: &UncheckedAccount<'info>, - token_program: &Interface<'info, TokenInterface>, - signer_seeds: &[&[&[u8]]], -) -> Result<()> { - let accounts = CloseAccount { - account: vault.to_account_info(), - destination: destination.to_account_info(), - authority: vault.to_account_info(), - }; - close_account(CpiContext::new_with_signer( - token_program.key(), - accounts, - signer_seeds, - )) -} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs index 86ff3c2c0..cb5bbb242 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs @@ -1,7 +1,7 @@ use anchor_lang::prelude::*; use anchor_spl::{ associated_token::AssociatedToken, - token_interface::{close_account, CloseAccount, Mint, TokenAccount, TokenInterface}, + token_interface::{Mint, TokenAccount, TokenInterface}, }; use crate::{ @@ -9,7 +9,7 @@ use crate::{ errors::AssetLeasingError, instructions::{ pay_rent::{compute_rent_due, update_last_paid_ts}, - shared::{transfer_tokens_from_user, transfer_tokens_from_vault}, + shared::{close_vault, transfer_tokens_from_user, transfer_tokens_from_vault}, }, state::{Lease, LeaseStatus}, }; @@ -183,13 +183,13 @@ pub fn handle_return_lease(context: Context) -> Result<()> { // lessor — the lessee only pays for the temporary state they held. close_vault( &context.accounts.leased_vault, - &context.accounts.lessor, + &context.accounts.lessor.to_account_info(), &context.accounts.token_program, &[leased_vault_seeds], )?; close_vault( &context.accounts.collateral_vault, - &context.accounts.lessor, + &context.accounts.lessor.to_account_info(), &context.accounts.token_program, &[collateral_vault_seeds], )?; @@ -200,21 +200,3 @@ pub fn handle_return_lease(context: Context) -> Result<()> { Ok(()) } - -fn close_vault<'info>( - vault: &InterfaceAccount<'info, TokenAccount>, - destination: &UncheckedAccount<'info>, - token_program: &Interface<'info, TokenInterface>, - signer_seeds: &[&[&[u8]]], -) -> Result<()> { - let accounts = CloseAccount { - account: vault.to_account_info(), - destination: destination.to_account_info(), - authority: vault.to_account_info(), - }; - close_account(CpiContext::new_with_signer( - token_program.key(), - accounts, - signer_seeds, - )) -} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs index f40c7e50c..443e9ce11 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs @@ -1,6 +1,7 @@ use anchor_lang::prelude::*; use anchor_spl::token_interface::{ - transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, + close_account, transfer_checked, CloseAccount, Mint, TokenAccount, TokenInterface, + TransferChecked, }; /// Transfer SPL tokens from a user-controlled account to a program-controlled @@ -49,3 +50,28 @@ pub fn transfer_tokens_from_vault<'info>( mint.decimals, ) } + +/// Close a PDA-owned SPL token vault and forward its rent-exempt lamports to +/// `destination`. The vault is its own token-account authority, so the caller +/// just passes the same vault `AccountInfo` as both the account and the +/// authority, with the vault's signer seeds for the CPI. +/// +/// `destination` is an `AccountInfo` so callers can pass whichever wrapper +/// they hold (`Signer`, `UncheckedAccount`, etc.) via `.to_account_info()`. +pub fn close_vault<'info>( + vault: &InterfaceAccount<'info, TokenAccount>, + destination: &AccountInfo<'info>, + token_program: &Interface<'info, TokenInterface>, + signer_seeds: &[&[&[u8]]], +) -> Result<()> { + let accounts = CloseAccount { + account: vault.to_account_info(), + destination: destination.clone(), + authority: vault.to_account_info(), + }; + close_account(CpiContext::new_with_signer( + token_program.key(), + accounts, + signer_seeds, + )) +} From 28a715d37c2b5ee313cae6a4d4e4bc0d2fe206ba Mon Sep 17 00:00:00 2001 From: "Edward (Edwardbot)" Date: Sun, 19 Apr 2026 00:12:22 +0000 Subject: [PATCH 04/41] fix(asset-leasing): reject leased_mint == collateral_mint on create_lease MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If both mints are the same SPL mint, the two vaults' PDA derivations still collapse to different addresses (their seeds differ) but they hold the same asset — rent streams out of the same token supply the lessee posted as collateral, and the 'what do I owe vs what do I hold' invariant breaks. Guard the case at the top of `handle_create_lease` with a new error, `LeasedMintEqualsCollateralMint`. New litesvm test `create_lease_rejects_same_mint_for_leased_and_collateral` verifies the rejection using a handcrafted instruction that sets both mint fields to the leased mint. 10 tests now pass (was 9). --- .../programs/asset-leasing/src/errors.rs | 2 + .../src/instructions/create_lease.rs | 10 ++++ .../asset-leasing/tests/test_asset_leasing.rs | 57 +++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs index f89f06d98..f90d96b2e 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs @@ -30,4 +30,6 @@ pub enum AssetLeasingError { MathOverflow, #[msg("Signer is not authorised for this action")] Unauthorised, + #[msg("Leased mint and collateral mint must be different")] + LeasedMintEqualsCollateralMint, } diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs index 4f456568b..759473830 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs @@ -80,6 +80,16 @@ pub fn handle_create_lease( maintenance_margin_bps: u16, liquidation_bounty_bps: u16, ) -> Result<()> { + // Reject leased_mint == collateral_mint. Allowing both to be the same SPL + // mint would collapse the two vaults' seed derivations into one shared + // token-balance pool, making rent-vs-collateral accounting ambiguous and + // enabling griefing paths where the lessee's "collateral" is the same + // asset they already hold as the lease principal. + require!( + context.accounts.leased_mint.key() != context.accounts.collateral_mint.key(), + AssetLeasingError::LeasedMintEqualsCollateralMint + ); + require!(leased_amount > 0, AssetLeasingError::InvalidLeasedAmount); require!( required_collateral_amount > 0, diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs b/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs index 96e1be72f..bf3c47989 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs @@ -878,3 +878,60 @@ fn close_expired_cancels_listed_lease() { assert!(sc.svm.get_account(&leased_vault).is_none()); assert!(sc.svm.get_account(&collateral_vault).is_none()); } + +#[test] +fn create_lease_rejects_same_mint_for_leased_and_collateral() { + // Collapsing leased_mint and collateral_mint into a single SPL mint would + // also collapse the two vaults into one token-balance pool (same mint, + // same authority seed pattern) and make rent-vs-collateral accounting + // ambiguous. The program rejects this up-front with + // `LeasedMintEqualsCollateralMint`. + let mut sc = full_setup(); + let lease_id = 42u64; + + // Build a `create_lease` instruction where the collateral_mint field + // carries the same mint as leased_mint. We bypass `build_create_lease_ix` + // because that helper always wires the two mints from the scenario. + let (lease, leased_vault, collateral_vault) = + lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + let ix = Instruction::new_with_bytes( + sc.program_id, + &asset_leasing::instruction::CreateLease { + lease_id, + leased_amount: LEASED_AMOUNT, + required_collateral_amount: REQUIRED_COLLATERAL, + rent_per_second: RENT_PER_SECOND, + duration_seconds: DURATION_SECONDS, + maintenance_margin_bps: MAINTENANCE_MARGIN_BPS, + liquidation_bounty_bps: LIQUIDATION_BOUNTY_BPS, + } + .data(), + asset_leasing::accounts::CreateLease { + lessor: sc.lessor.pubkey(), + leased_mint: sc.leased_mint, + // Same mint on both sides — should be rejected. + collateral_mint: sc.leased_mint, + lessor_leased_account: sc.lessor_leased_ata, + lease, + leased_vault, + collateral_vault, + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + + let result = send_transaction_from_instructions( + &mut sc.svm, + vec![ix], + &[&sc.lessor], + &sc.lessor.pubkey(), + ); + + let err = result.expect_err("create_lease must reject identical leased/collateral mints"); + let rendered = format!("{err:?}"); + assert!( + rendered.contains("LeasedMintEqualsCollateralMint") || rendered.contains("0x177e"), + "unexpected failure mode: {rendered}" + ); +} From 29f5e002c09a9704ab64fe8bfc537dea540808d0 Mon Sep 17 00:00:00 2001 From: "Edward (Edwardbot)" Date: Sun, 19 Apr 2026 00:15:02 +0000 Subject: [PATCH 05/41] fix(asset-leasing): pin Pyth feed_id on Lease and enforce at liquidate The previous liquidate handler trusted whatever Pyth `PriceUpdateV2` the keeper passed in, provided the account was owned by the Pyth receiver program. A keeper could therefore substitute a completely unrelated feed (e.g. a volatile pair that happened to be dipping) and force a spurious liquidation against a healthy lease. Changes * `Lease` gains `feed_id: [u8; 32]`, persisted at create_lease. * `create_lease` takes `feed_id` as a parameter (updates lib.rs). * `DecodedPriceUpdate` exposes `feed_id`; `decode_price_update` reads bytes 41..73 of the account data. * `handle_liquidate` compares decoded feed_id to `lease.feed_id` and returns `PriceFeedMismatch` on mismatch. * Test mock builder parameterises feed_id; all existing tests pin FEED_ID = [0xAB; 32] and hand the matching value to mock price updates. * New test `liquidate_rejects_mismatched_price_feed` confirms a foreign-feed price update is rejected even when the price would otherwise flag the position as underwater. 11 tests pass (was 10). --- .../programs/asset-leasing/src/errors.rs | 2 + .../src/instructions/create_lease.rs | 2 + .../src/instructions/liquidate.rs | 22 ++++- .../anchor/programs/asset-leasing/src/lib.rs | 2 + .../programs/asset-leasing/src/state/lease.rs | 7 ++ .../asset-leasing/tests/test_asset_leasing.rs | 95 ++++++++++++++++++- 6 files changed, 122 insertions(+), 8 deletions(-) diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs index f90d96b2e..2b227f3a9 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs @@ -32,4 +32,6 @@ pub enum AssetLeasingError { Unauthorised, #[msg("Leased mint and collateral mint must be different")] LeasedMintEqualsCollateralMint, + #[msg("Price update does not match the feed pinned on this lease")] + PriceFeedMismatch, } diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs index 759473830..a5ddd52e1 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs @@ -79,6 +79,7 @@ pub fn handle_create_lease( duration_seconds: i64, maintenance_margin_bps: u16, liquidation_bounty_bps: u16, + feed_id: [u8; 32], ) -> Result<()> { // Reject leased_mint == collateral_mint. Allowing both to be the same SPL // mint would collapse the two vaults' seed derivations into one shared @@ -139,6 +140,7 @@ pub fn handle_create_lease( last_rent_paid_ts: 0, maintenance_margin_bps, liquidation_bounty_bps, + feed_id, status: LeaseStatus::Listed, bump: context.bumps.lease, leased_vault_bump: context.bumps.leased_vault, diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs index ea0b70d6a..96079660b 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs @@ -109,15 +109,17 @@ pub struct Liquidate<'info> { /// feed_id(32) | price(i64) | conf(u64) | exponent(i32) | publish_time(i64) | /// prev_publish_time(i64) | ema_price(i64) | ema_conf(u64) | posted_slot(u64)]`. pub struct DecodedPriceUpdate { + pub feed_id: [u8; 32], pub price: i64, pub exponent: i32, pub publish_time: i64, } pub fn decode_price_update(data: &[u8]) -> Result { - // Discriminator (8) + write_authority (32) + verification_level (1) + - // feed_id (32) = 73 bytes before the fields we care about. - const PRICE_OFFSET: usize = 73; + // Discriminator (8) + write_authority (32) + verification_level (1) = 41. + const FEED_ID_OFFSET: usize = 41; + // feed_id (32) starts at 41, price i64 at 41 + 32 = 73. + const PRICE_OFFSET: usize = FEED_ID_OFFSET + 32; const EXPONENT_OFFSET: usize = PRICE_OFFSET + 8 + 8; // price + conf const PUBLISH_TIME_OFFSET: usize = EXPONENT_OFFSET + 4; // exponent const MIN_LEN: usize = PUBLISH_TIME_OFFSET + 8; @@ -128,6 +130,9 @@ pub fn decode_price_update(data: &[u8]) -> Result { AssetLeasingError::StalePrice ); + let mut feed_id = [0u8; 32]; + feed_id.copy_from_slice(&data[FEED_ID_OFFSET..FEED_ID_OFFSET + 32]); + let price = i64::from_le_bytes(data[PRICE_OFFSET..PRICE_OFFSET + 8].try_into().unwrap()); let exponent = i32::from_le_bytes( data[EXPONENT_OFFSET..EXPONENT_OFFSET + 4] @@ -141,6 +146,7 @@ pub fn decode_price_update(data: &[u8]) -> Result { ); Ok(DecodedPriceUpdate { + feed_id, price, exponent, publish_time, @@ -153,6 +159,16 @@ pub fn handle_liquidate(context: Context) -> Result<()> { let decoded = decode_price_update(&price_data)?; drop(price_data); + // Feed pinning: reject any `PriceUpdateV2` whose feed_id does not match + // the one the lessor committed to at `create_lease`. Without this guard, + // a keeper could pass in any feed the Pyth Receiver program owns — e.g. + // a wildly volatile pair that dips enough to flag the position as + // underwater — and trigger a spurious liquidation. + require!( + decoded.feed_id == context.accounts.lease.feed_id, + AssetLeasingError::PriceFeedMismatch + ); + require!( is_underwater(&context.accounts.lease, &decoded, now)?, AssetLeasingError::PositionHealthy diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs index 0b9e6495c..235617a0d 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs @@ -27,6 +27,7 @@ pub mod asset_leasing { duration_seconds: i64, maintenance_margin_bps: u16, liquidation_bounty_bps: u16, + feed_id: [u8; 32], ) -> Result<()> { instructions::create_lease::handle_create_lease( context, @@ -37,6 +38,7 @@ pub mod asset_leasing { duration_seconds, maintenance_margin_bps, liquidation_bounty_bps, + feed_id, ) } diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs index d50001dd9..f02ab7300 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs @@ -58,6 +58,13 @@ pub struct Lease { /// lease, expressed in basis points of `collateral_amount`. pub liquidation_bounty_bps: u16, + /// Pyth `PriceUpdateV2.feed_id` that this lease is pinned to. The + /// liquidation handler refuses price updates whose on-account `feed_id` + /// does not match this value, so a keeper cannot swap in an unrelated + /// feed (e.g. a cheaper or more volatile pair) to force a liquidation. + /// Chosen by the lessor at `create_lease`. + pub feed_id: [u8; 32], + /// Current lifecycle state. pub status: LeaseStatus, diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs b/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs index bf3c47989..bcfe9abee 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs @@ -171,6 +171,7 @@ fn build_create_lease_ix( duration_seconds: i64, maintenance_margin_bps: u16, liquidation_bounty_bps: u16, + feed_id: [u8; 32], ) -> Instruction { let (lease, leased_vault, collateral_vault) = lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); @@ -184,6 +185,7 @@ fn build_create_lease_ix( duration_seconds, maintenance_margin_bps, liquidation_bounty_bps, + feed_id, } .data(), asset_leasing::accounts::CreateLease { @@ -349,7 +351,12 @@ fn build_close_expired_ix(sc: &Scenario, lease_id: u64) -> Instruction { /// Build a minimal `PriceUpdateV2` account body with the requested price and /// exponent, timestamped `publish_time`. Fields not used by the program are /// filled with zero bytes. -fn build_price_update_data(price: i64, exponent: i32, publish_time: i64) -> Vec { +fn build_price_update_data( + feed_id: [u8; 32], + price: i64, + exponent: i32, + publish_time: i64, +) -> Vec { // Size layout: // 8 disc + 32 write_authority + 1 verification_level + 32 feed_id + // 8 price + 8 conf + 4 exponent + 8 publish_time + 8 prev_publish_time + @@ -361,8 +368,7 @@ fn build_price_update_data(price: i64, exponent: i32, publish_time: i64) -> Vec< data.extend_from_slice(&[0u8; 32]); // verification_level = Full (1). data.push(1); - // feed_id — arbitrary; not checked by the program. - data.extend_from_slice(&[0xAB; 32]); + data.extend_from_slice(&feed_id); data.extend_from_slice(&price.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); // conf data.extend_from_slice(&exponent.to_le_bytes()); @@ -378,11 +384,12 @@ fn build_price_update_data(price: i64, exponent: i32, publish_time: i64) -> Vec< fn mock_price_update( svm: &mut LiteSVM, address: Pubkey, + feed_id: [u8; 32], price: i64, exponent: i32, publish_time: i64, ) { - let data = build_price_update_data(price, exponent, publish_time); + let data = build_price_update_data(feed_id, price, exponent, publish_time); let lamports = svm.minimum_balance_for_rent_exemption(data.len()); let owner: Pubkey = PYTH_RECEIVER_PROGRAM_ID_STR.parse().unwrap(); svm.set_account( @@ -409,6 +416,11 @@ const RENT_PER_SECOND: u64 = 10; // 10 base-units / sec const DURATION_SECONDS: i64 = 60 * 60 * 24; // 24h const MAINTENANCE_MARGIN_BPS: u16 = 12_000; // 120% const LIQUIDATION_BOUNTY_BPS: u16 = 500; // 5% +// Arbitrary 32-byte Pyth feed id the tests pin their leases to. The +// mocked `PriceUpdateV2` accounts carry the same id so the feed-pinning +// check in liquidate passes. `liquidate_rejects_mismatched_price_feed` +// flips one byte of this to prove the check rejects foreign feeds. +const FEED_ID: [u8; 32] = [0xAB; 32]; #[test] fn create_lease_locks_tokens_and_lists() { @@ -424,6 +436,7 @@ fn create_lease_locks_tokens_and_lists() { DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, + FEED_ID, ); send_transaction_from_instructions(&mut sc.svm, vec![ix], &[&sc.lessor], &sc.lessor.pubkey()) .unwrap(); @@ -467,6 +480,7 @@ fn take_lease_posts_collateral_and_delivers_tokens() { DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, + FEED_ID, ); send_transaction_from_instructions( &mut sc.svm, @@ -520,6 +534,7 @@ fn pay_rent_streams_collateral_by_elapsed_time() { DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, + FEED_ID, ); let take_ix = build_take_lease_ix(&sc, lease_id); send_transaction_from_instructions( @@ -569,6 +584,7 @@ fn top_up_collateral_increases_vault_balance() { DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, + FEED_ID, ); let take_ix = build_take_lease_ix(&sc, lease_id); send_transaction_from_instructions( @@ -610,6 +626,7 @@ fn return_lease_refunds_unused_collateral() { DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, + FEED_ID, ); let take_ix = build_take_lease_ix(&sc, lease_id); send_transaction_from_instructions( @@ -675,6 +692,7 @@ fn liquidate_seizes_collateral_on_price_drop() { DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, + FEED_ID, ); let take_ix = build_take_lease_ix(&sc, lease_id); send_transaction_from_instructions( @@ -698,6 +716,7 @@ fn liquidate_seizes_collateral_on_price_drop() { mock_price_update( &mut sc.svm, price_update_key.pubkey(), + FEED_ID, 4, 0, now, // fresh publish_time @@ -751,6 +770,7 @@ fn liquidate_rejects_healthy_position() { DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, + FEED_ID, ); let take_ix = build_take_lease_ix(&sc, lease_id); send_transaction_from_instructions( @@ -766,7 +786,7 @@ fn liquidate_rejects_healthy_position() { // to fail with `PositionHealthy`. let price_update_key = Keypair::new(); let now = current_clock(&sc.svm); - mock_price_update(&mut sc.svm, price_update_key.pubkey(), 1, 0, now); + mock_price_update(&mut sc.svm, price_update_key.pubkey(), FEED_ID, 1, 0, now); let liq_ix = build_liquidate_ix(&sc, lease_id, price_update_key.pubkey()); let result = send_transaction_from_instructions( @@ -778,6 +798,68 @@ fn liquidate_rejects_healthy_position() { assert!(result.is_err(), "healthy liquidation must fail"); } +#[test] +fn liquidate_rejects_mismatched_price_feed() { + // The lessor pinned FEED_ID; we hand the handler a price update whose + // internal feed_id is different. Even when the price would push the + // position underwater, the liquidate call must bail with + // `PriceFeedMismatch` before running the undercollateralisation check. + let mut sc = full_setup(); + let lease_id = 100u64; + + let create_ix = build_create_lease_ix( + &sc, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + RENT_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BPS, + LIQUIDATION_BOUNTY_BPS, + FEED_ID, + ); + let take_ix = build_take_lease_ix(&sc, lease_id); + send_transaction_from_instructions( + &mut sc.svm, + vec![create_ix, take_ix], + &[&sc.lessor, &sc.lessee], + &sc.lessor.pubkey(), + ) + .unwrap(); + + // Flip every byte — any 32-byte feed id other than FEED_ID should do. + let wrong_feed_id = [0xCD; 32]; + + // Price that *would* trigger liquidation (debt 400 vs 200 collateral, + // same as `liquidate_seizes_collateral_on_price_drop`) — except this + // update carries the wrong feed id. + let price_update_key = Keypair::new(); + let now = current_clock(&sc.svm); + mock_price_update( + &mut sc.svm, + price_update_key.pubkey(), + wrong_feed_id, + 4, + 0, + now, + ); + + let liq_ix = build_liquidate_ix(&sc, lease_id, price_update_key.pubkey()); + let result = send_transaction_from_instructions( + &mut sc.svm, + vec![liq_ix], + &[&sc.keeper], + &sc.keeper.pubkey(), + ); + let err = result.expect_err("liquidate must reject foreign price feeds"); + let rendered = format!("{err:?}"); + // PriceFeedMismatch is the 16th error in the enum (index 15) → 0x177f. + assert!( + rendered.contains("PriceFeedMismatch") || rendered.contains("0x177f"), + "unexpected failure mode: {rendered}" + ); +} + #[test] fn close_expired_reclaims_collateral_after_end_ts() { let mut sc = full_setup(); @@ -792,6 +874,7 @@ fn close_expired_reclaims_collateral_after_end_ts() { DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, + FEED_ID, ); let take_ix = build_take_lease_ix(&sc, lease_id); send_transaction_from_instructions( @@ -848,6 +931,7 @@ fn close_expired_cancels_listed_lease() { DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, + FEED_ID, ); send_transaction_from_instructions( &mut sc.svm, @@ -904,6 +988,7 @@ fn create_lease_rejects_same_mint_for_leased_and_collateral() { duration_seconds: DURATION_SECONDS, maintenance_margin_bps: MAINTENANCE_MARGIN_BPS, liquidation_bounty_bps: LIQUIDATION_BOUNTY_BPS, + feed_id: FEED_ID, } .data(), asset_leasing::accounts::CreateLease { From a92d2fb16eab4df591b6b093450bb35b76a3cd58 Mon Sep 17 00:00:00 2001 From: "Edward (Edwardbot)" Date: Sun, 19 Apr 2026 00:15:41 +0000 Subject: [PATCH 06/41] fix(asset-leasing): settle last_rent_paid_ts on close_expired default path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default branch of `close_expired` (lessee ghosted past end_ts, lessor takes the collateral) previously left `last_rent_paid_ts` at whatever the most recent `pay_rent` wrote, which could be strictly less than `min(now, end_ts)`. The invariant `last_rent_paid_ts <= min(now, end_ts)` held, but the stronger invariant 'timestamp points at the latest settled instant' did not. Bump `last_rent_paid_ts` via `update_last_paid_ts` on the `Active` branch. Behaviour is unchanged (the lease account is closed in the same ix) but future versions that want to split the collateral differently on default — pro-rata rent, partial refund, haircut for unused time — can now trust that everything up to `now` is already settled rather than having to re-derive it. No-op on the `Listed` branch: rent never started accruing there. All 11 tests still pass. --- .../src/instructions/close_expired.rs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs index 5f09d006b..f936a456d 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs @@ -7,7 +7,10 @@ use anchor_spl::{ use crate::{ constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, errors::AssetLeasingError, - instructions::shared::{close_vault, transfer_tokens_from_vault}, + instructions::{ + pay_rent::update_last_paid_ts, + shared::{close_vault, transfer_tokens_from_vault}, + }, state::{Lease, LeaseStatus}, }; @@ -152,6 +155,23 @@ pub fn handle_close_expired(context: Context) -> Result<()> { &[collateral_vault_seeds], )?; + // Settle rent accounting on the default path. + // + // We are not forwarding any accrued rent to the lessor here — on default + // the lessor takes the whole collateral vault as compensation — but we + // still bump \`last_rent_paid_ts\` so the invariant + // \`last_rent_paid_ts <= now.min(end_ts)\` stays intact. That matters for + // any future version of the program that wants to split the collateral + // differently (pro-rata rent, partial refund on default, haircut to the + // lessee for unused time): such a version can read + // \`last_rent_paid_ts\` and trust that everything up to \`now\` is already + // settled, rather than having to reason about whether this branch ever + // bumped the timestamp. + // + // No-op on the \`Listed\` branch because rent never started accruing. + if status == LeaseStatus::Active { + update_last_paid_ts(&mut context.accounts.lease, now); + } context.accounts.lease.collateral_amount = 0; context.accounts.lease.status = LeaseStatus::Closed; From 04367b8a9e0ddf1865a750a171c3f7d89fcad80e Mon Sep 17 00:00:00 2001 From: "Edward (Edwardbot)" Date: Sun, 19 Apr 2026 00:43:23 +0000 Subject: [PATCH 07/41] docs(asset-leasing): rewrite README to the repo-wide quality bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full rewrite. The previous README was explanatory but relied on the car-rental analogy as its spine. This version restructures around the sections the repo-wide overhaul uses everywhere: 1. What does this program do? (plain English first; analogies only after the onchain mechanics, with each tradfi term defined briefly where it appears) 2. Glossary (account, PDA, signer, CPI, Anchor constraint, bps, keeper, oracle, feed_id, exponent, etc.) 3. Accounts and PDAs (state + vault tables; full field list of `Lease`; lifecycle diagram) 4. Instruction lifecycle walkthrough (one subsection per ix, with signers / accounts / PDAs / token-flow diagrams / state changes / checks — in the order a user actually encounters them) 5. Full-lifecycle worked examples (happy path, liquidation path, default-by-expiry, listed cancel — concrete numbers throughout) 6. Safety and edge cases (full error-code table, guarded design choices, what the program does *not* guard) 7. Running the tests (+ CI note confirming `anchor build` runs before `anchor test` in .github/workflows/anchor.yml, which covers the `include_bytes!` concern for §6) 8. Extending the program (easy / moderate / harder tiers) Reflects the code changes in this branch: * Fix 1 (close_vault helper extracted) in §Code layout * Fix 3 (LeasedMintEqualsCollateralMint) in §4.1 and §6.1 * Fix 4 (feed_id pinning) in §2, §3, §4.6, §6 * Fix 5 (last_rent_paid_ts on default path) in §4.7 and §5.3 * Fix 6 (CI) confirmed in §7 1200 lines. --- defi/asset-leasing/anchor/README.md | 1966 ++++++++++++++------------- 1 file changed, 1017 insertions(+), 949 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index f99c5d018..9db196880 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -1,1132 +1,1200 @@ # Asset Leasing -Fixed-term leasing of SPL tokens on Solana, with SPL collateral, rent that -streams by the second, and Pyth-priced liquidation when the lessee stops -posting enough collateral. +A fixed-term SPL-token lease on Solana, with a second-by-second rent +stream, a separate collateral deposit, and a Pyth-oracle-triggered +seizure path when the collateral is no longer worth enough. -This README is written as a **teaching document**. It assumes you have -written code before (you know what a function is, you can read basic Rust -or are happy to Google as you go) but it does **not** assume you know -anything about traditional finance. No previous Solana programs required -either — "this is my first program" is exactly the audience. +This README is a teaching document. If you have never written a Solana +program before and have no background in finance, you are the target +reader — every term that might be unfamiliar is explained the first time +it appears, and every instruction is walked through step by step with +the exact token movements it causes. -If you already know what collateral, maintenance margin, basis points, -liquidation and oracles mean, skip straight to the [Instructions -reference](#4-instructions-reference) or the [Lifecycle walk-through -](#3-the-full-lifecycle-walked-through-with-numbers). +If you already know what collateral, a maintenance margin and an oracle +are, you can skip straight to the [Accounts and PDAs](#3-accounts-and-pdas) +or [Instruction lifecycle walkthrough](#4-instruction-lifecycle-walkthrough) +sections. --- ## Table of contents -1. [What is asset leasing?](#1-what-is-asset-leasing) -2. [Key concepts, explained from scratch](#2-key-concepts-explained-from-scratch) -3. [The full lifecycle, walked through with numbers](#3-the-full-lifecycle-walked-through-with-numbers) -4. [Instructions reference](#4-instructions-reference) -5. [Accounts and PDAs](#5-accounts-and-pdas) -6. [Pyth integration, in plain English](#6-pyth-integration-in-plain-english) -7. [Safety and edge cases worth knowing](#7-safety-and-edge-cases-worth-knowing) -8. [How to build and test](#8-how-to-build-and-test) -9. [Extending this example](#9-extending-this-example) -10. [Further reading](#10-further-reading) +1. [What does this program do?](#1-what-does-this-program-do) +2. [Glossary](#2-glossary) +3. [Accounts and PDAs](#3-accounts-and-pdas) +4. [Instruction lifecycle walkthrough](#4-instruction-lifecycle-walkthrough) +5. [Full-lifecycle worked examples](#5-full-lifecycle-worked-examples) +6. [Safety and edge cases](#6-safety-and-edge-cases) +7. [Running the tests](#7-running-the-tests) +8. [Extending the program](#8-extending-the-program) --- -## 1. What is asset leasing? - -The short version: **renting a token instead of buying it, with a refundable -security deposit that keeps the renter honest.** - -### A real-world analogy - -Imagine you want to drive a car for a week but you do not want to buy one. -You walk into a rental place. They hand you the keys, but only after you -leave a **security deposit** on your credit card and agree to a **daily -rate**. Every day you keep the car, they bill you for another day. If the -car's value suddenly crashes (they discover it has a bent chassis), they -might ask for a **bigger deposit** so they are still protected. If you -never return the car, they **keep your deposit** to cover the loss. If -you return it on time and pay the rent you owe, they hand your deposit -back, minus the days you used. - -Asset leasing on Solana is the same shape: - -- The **lessor** (the car rental company) owns some SPL tokens and is - happy to lend them out for a fee. -- The **lessee** (the renter) wants those tokens for a while but does not - want to buy them outright. -- The lessee posts **collateral** (security deposit) in a different SPL - token, and in exchange gets the leased tokens. -- **Rent** ticks away per second, paid out of that collateral into the - lessor's wallet. -- If the market value of the collateral drops versus the leased tokens, - the lessee must **top up** or risk being **liquidated** — a third - party ("keeper") takes the collateral, the lessor is made whole, and - the keeper earns a small fee for the trouble. -- If the lessee disappears and never returns the tokens, the lessor - **keeps the whole collateral** to compensate for the loss. - -### Why would anyone want to do this? - -A concrete example — **governance leasing**. - -> Alice owns 10,000 tokens of a DAO's governance token. She does **not -> want to sell** them — they grow in value, she likes the project, and -> she wants to keep her long-term position. But right now she is not -> voting on anything and the tokens sit idle. -> -> Bob, meanwhile, wants to vote on an upcoming proposal. He does not -> want to spend the cash to buy 10,000 tokens just to vote once. What -> he does want is **temporary voting power for a week**. -> -> They lease. Alice posts her 10,000 tokens for rent. Bob takes the -> lease, hands over USDC as collateral, gets the 10,000 governance -> tokens, votes, and returns them a week later. Alice earns a week of -> rent in USDC for doing nothing. Bob got voting power without burning -> capital on tokens he does not want to hold long-term. Everyone is -> happier than they would have been otherwise. - -The same pattern works for: - -- **NFT utility rental** — rent a game item, an access pass, or a - profile-picture NFT for a weekend. -- **Collateral rental** — borrow an asset to use as collateral elsewhere - (rehypothecation, in traditional-finance speak), return it later. -- **Voting / boost rental** — "ve-token" systems where holding the - token gives extra yield; a DAO that wants a temporary boost can lease - instead of buying. - -The onchain bit matters because there is **no trusted middleman**. The -program enforces the rules: the collateral is locked, the rent is paid -automatically, and if either party misbehaves the other has an -onchain remedy. You do not have to trust Alice not to run off with -Bob's deposit, and Alice does not have to trust Bob to return the -tokens — the program enforces both sides. +## 1. What does this program do? + +Two users, a **lessor** and a **lessee**, want to swap SPL tokens +temporarily: + +- The lessor has some number of tokens of SPL mint **A** (call it the + "leased mint") they would like to hand over for a fixed period of + time. +- The lessee has tokens of a different SPL mint **B** (the "collateral + mint") they can lock up as a security deposit. + +The program acts as a neutral escrow. It: + +1. Takes the lessor's A tokens and locks them in a program-owned vault + until a lessee shows up. +2. When a lessee calls `take_lease`, the program locks the lessee's B + tokens as collateral and hands the A tokens to the lessee. +3. While the lease is live, a second-by-second **rent stream** pays the + lessor out of the collateral vault. +4. If the price of A (measured in B) moves against the lessee far enough + that the locked collateral is no longer enough to cover the cost of + re-acquiring the leased tokens, anyone can call `liquidate` — the + collateral is seized, most of it goes to the lessor, a small + percentage goes to whoever called the liquidation. +5. If the lessee returns the full A amount before the deadline, they get + back whatever collateral is left after rent. +6. If the lessee ghosts past the deadline without returning anything, + the lessor calls `close_expired` and sweeps the collateral as + compensation. + +Nothing mysterious: the program is a pair of vaults, a small piece of +state that tracks how much rent has been paid, and an oracle check. It +is written in Anchor. + +### The tradfi picture, briefly + +For readers who have never encountered a real-world leasing or margin +arrangement — two quick analogies. They are strictly optional; the +program is fully described above in Solana terms. + +- **Think of hiring a car.** You pay the rental firm a refundable + deposit and a daily fee. If you return the car on time and in one + piece, you get the deposit back. If you drive off and disappear, they + keep the deposit. Here the lessor is the rental firm, the lessee is + you, the leased tokens are the car, and the collateral is the + deposit. + +- **Think of a pawn shop loan.** You hand over something valuable + (collateral), you borrow something in return. If the value of what + you handed over drops — for example, if you pawned gold and the gold + price collapsed — the shop can sell your collateral before it's worth + less than they lent you. On Solana, a price oracle tells the program + when that moment has arrived, and `liquidate` does the selling. + +Neither analogy is exact (a car rental doesn't usually charge rent in +the same asset it took as a deposit, a pawn shop doesn't usually set a +hard deadline). The onchain mechanics are what matters below. + +### What this example is not + +- **It is not a deployed, audited production program.** Treat it as a + learning example. It makes simplifying choices (see §6) that a + production lease protocol would need to revisit. +- **It does not pretend to match mainnet Pyth behaviour exactly.** The + LiteSVM tests install a hand-rolled `PriceUpdateV2` account; on + mainnet you would use the real Pyth Receiver crate. --- -## 2. Key concepts, explained from scratch +## 2. Glossary + +Terms appearing anywhere below, explained in terms of what they are +mechanically. + +**Account** +: On Solana, every piece of state — a user wallet, a token balance, a +program's config — is an *account*. An account has an address (a +32-byte public key), a length, some lamports holding it rent-exempt, an +owner program (the only program that can mutate the bytes), and a +byte buffer (`data`). + +**Lamport** +: The smallest unit of SOL. 1 SOL = 10⁹ lamports. Accounts must hold +enough lamports to be "rent-exempt" for their size; the program +reimburses these lamports when it closes an account. + +**Signer** +: An account whose private key signed the transaction. Only signers can +authorise transfers out of accounts they own (including normal +wallets). The list of signers is attached to every transaction. + +**SPL token** +: Solana's equivalent of an ERC-20. An SPL *mint* account describes +the token (its supply, decimals, authority). Each user's balance of a +given mint lives in a separate *token account* owned by the SPL Token +program. + +**Token account** +: An account that holds a balance of a specific SPL mint, controlled by +an *authority* (usually a user's wallet pubkey, but can be a PDA). In +this program, the two vaults are token accounts whose authority is the +vault PDA itself. + +**Associated Token Account (ATA)** +: A conventional, deterministic token account address for a given +`(wallet, mint)` pair. Derived by the SPL Associated Token Account +program. When you send USDC to "someone's wallet", you really mean +their ATA for the USDC mint. The program creates lessor/lessee ATAs on +demand (`init_if_needed`) so callers don't have to pre-create them. + +**PDA (Program Derived Address)** +: A deterministic address derived from a list of "seeds" plus a +program id, via `Pubkey::find_program_address`. PDAs have no private +key. A program can *sign* as a PDA in a CPI by producing the seeds — +that's the only way to move tokens out of a PDA-owned vault. In this +program there are three PDAs per lease: the `Lease` state account, the +`leased_vault` token account, and the `collateral_vault` token +account. + +**Seeds** +: The byte strings that, together with the program id, deterministically +derive a PDA. For this program the seeds are `[b"lease", lessor, +lease_id]` for the state account and `[b"leased_vault", lease]` / +`[b"collateral_vault", lease]` for the vaults. + +**Bump** +: A one-byte offset that, together with the seeds, produces an address +that is *not* on the Ed25519 curve (i.e. has no corresponding private +key). `find_program_address` finds the highest bump that yields an +off-curve address. Stored on the `Lease` account so the program doesn't +have to recompute it every time it signs. + +**CPI (Cross-Program Invocation)** +: One program calling another within the same transaction. The SPL +Token program's `TransferChecked` and `CloseAccount` instructions are +the CPIs used here. + +**Anchor** +: A Rust framework for writing Solana programs. The `#[derive(Accounts)]` +macro generates the account-validation boilerplate — ownership checks, +signer checks, PDA derivation, constraint checks like `has_one` — from +struct definitions. The `#[account]` macro handles serialising program +state accounts with an 8-byte discriminator prefix so the program can +tell different account types apart. + +**Anchor constraint** +: An attribute on an account field in a `#[derive(Accounts)]` struct, +like `mut`, `seeds = [...]`, `has_one = lessor`, or +`constraint = lease.status == LeaseStatus::Active`. Each one expands +into a check that runs before the handler executes. If any check fails +the transaction is rejected. + +**Discriminator** +: The first 8 bytes of an Anchor account, equal to the first 8 bytes of +`sha256("account:")`. Anchor writes them at initialisation +and checks them on every deserialisation so one struct's bytes cannot +be mistaken for another's. + +**Rent (Solana)** +: The lamports deposit that keeps an account alive. Since it's always +paid up-front (rent-exempt), you can think of it as a refundable +security deposit from a payer. When an account is closed the lamports +are returned to whichever account is specified as `close = ...` in +Anchor. + +**Rent (this program)** +: The per-second payment the lessee owes the lessor for holding the +leased tokens. Measured in collateral-mint base units, streams from +the collateral vault to the lessor's collateral ATA on every +`pay_rent`. *Unrelated to Solana account rent* — same word, different +meaning. Context usually makes it obvious. + +**Vault** +: In this codebase, one of the two program-owned token accounts (leased +or collateral). Their authority is the PDA itself, so the program is +the only thing that can move funds out of them, and it does so by +producing the vault's PDA seeds when making the transfer CPI. + +**Basis point (bps)** +: 1/100 of a percent. 10 000 bps = 100%. Used here for the maintenance +margin and liquidation bounty. Integer-only bps arithmetic keeps all +percentage calculations free of floating-point error. + +**Maintenance margin** +: A ratio. The liquidation check asks: is the collateral's value (in +collateral-mint units) at least `maintenance_margin_bps / 10_000` +times the debt's value (the leased amount, priced into the same +units)? For `maintenance_margin_bps = 12_000` that is 120%. Drop below +and the position is liquidatable. This is the "how much cushion must +the lessee keep on top of the raw value of the leased asset". + +**Liquidation** +: The instruction (`liquidate`) that closes an underwater lease. Rent +is first paid from the collateral vault; then a percentage (the +*liquidation bounty*) of whatever collateral is left goes to the +keeper who called the instruction, and the remainder goes to the +lessor. Lease status becomes `Liquidated`. + +**Keeper** +: Any party — usually a bot — that calls a permissionless instruction +to keep the protocol healthy. Here the keeper calls `liquidate` when +they spot an underwater lease. They are paid the `liquidation_bounty` +for their trouble. + +**Oracle** +: An onchain account whose bytes are periodically updated with +information from the outside world — for this program, the current +price of the leased mint priced in units of the collateral mint. We +use Pyth's `PriceUpdateV2` accounts. + +**Pyth `PriceUpdateV2`** +: The Pyth receiver program owns a set of accounts, each with a fixed +layout: discriminator (8) + write_authority (32) + verification_level +(1) + `feed_id` (32) + price (i64, 8) + conf (u64, 8) + exponent +(i32, 4) + publish_time (i64, 8) + …. This program only reads +`feed_id`, `price`, `exponent` and `publish_time`. + +**Feed id** +: A 32-byte identifier for a specific Pyth price feed (e.g. +"BONK/USD"). Pinned on the `Lease` at creation so a keeper cannot swap +in a different feed during a liquidation call to force an underwater +verdict. + +**Exponent** +: Pyth prices are integer pairs `(price, exponent)`; the real price is +`price * 10^exponent`. For example `(12345, -2)` means 123.45. All of +this program's math is integer and folds the exponent into whichever +side of the inequality doesn't already have the denominator applied. -Before reading the code, it helps to know what these words mean. None of -them are complicated — they are just jargon the finance world uses, and -the code re-uses the same vocabulary. - -### 2.1 SPL tokens - -On Ethereum you have ERC-20 tokens. **SPL tokens are the Solana -equivalent** — a common standard so any wallet, any program, and any UI -can speak to any token the same way. Both the leased asset and the -collateral in this example are SPL tokens. USDC on Solana is an SPL -token. Wrapped SOL is an SPL token. A DAO's governance token is -(probably) an SPL token. You can mint your own in a few lines. The -program does not care which mint is used — only that both sides agreed -on them at listing time. - -### 2.2 Collateral - -Collateral is a **security deposit**. It is something valuable the -borrower gives up temporarily to reassure the lender. - -Why demand it? Because once the lessee has your tokens, nothing except -collateral stops them from walking away. "Skin in the game" is the -phrase — the lessee has something to lose, so cooperating (returning -the tokens on time, paying rent) is more profitable than defecting -(keeping the tokens and losing the deposit). - -In this program the collateral lives in a program-owned `collateral_vault`. -The lessee cannot touch it. The program will only release it under the -rules defined in the code (return, top-up withdrawal, liquidation, or -expiry). - -### 2.3 Maintenance margin - -This is where the finance vocabulary bites, but the idea is simple. - -**The collateral must stay worth more than the thing being borrowed.** -How much more? That is the maintenance margin, expressed as a -percentage (well, a basis-point number — see §2.6). - -In this program, the margin is stored as `maintenance_margin_bps`. If -you set it to `12_000`, you are saying: +--- -> collateral value must be ≥ 120% of the leased-asset value at all -> times. If it falls below 120%, the position is **underwater** and can -> be liquidated. +## 3. Accounts and PDAs -Worked example (same mint for simplicity): +Every call to the program touches some subset of these accounts. The +three PDAs are created on `create_lease` and destroyed on `return_lease` +/ `liquidate` / `close_expired`. -- Alice lists 100 GOV tokens. -- The maintenance margin is 120% (`12_000` bps). -- Right now 1 GOV = 1 USDC. So the debt is 100 USDC. -- Required collateral to stay healthy: 100 × 120% = **120 USDC**. -- Bob posts 200 USDC to be safe. +### State / data accounts -A week later GOV pumps to 1.80 USDC: +| Account | PDA? | Seeds | Kind | Authority | Holds | +|---|---|---|---|---|---| +| `Lease` | yes | `["lease", lessor, lease_id]` | data | program | all the lease parameters and current lifecycle state (see below) | -- Debt value is now 100 × 1.80 = 180 USDC. -- Required collateral: 180 × 120% = **216 USDC**. -- Bob only has 200 locked up. He is now **underwater**. Unless he tops - up, a keeper can liquidate him. - -Why the margin exists: price quotes are stale by the time you see them, -and a 0.01% cushion is not enough to cover even a small move. A 120% -or 150% margin gives the lessor a buffer against the inevitable price -swings between liquidation checks. - -### 2.4 Liquidation +### Token vaults -Liquidation is **the eject button**. When a position breaches the -maintenance margin, the protocol seizes the collateral, pays the -lessor what they are owed, and closes the position. +| Account | PDA? | Seeds | Kind | Authority | Holds | +|---|---|---|---|---|---| +| `leased_vault` | yes | `["leased_vault", lease]` | SPL token account | itself (PDA-signed) | `leased_amount` while `Listed`; 0 while `Active` (lessee has the tokens); full amount again briefly inside `return_lease` | +| `collateral_vault` | yes | `["collateral_vault", lease]` | SPL token account | itself (PDA-signed) | 0 while `Listed`; `collateral_amount` while `Active`, decreasing as rent streams out and increasing on `top_up_collateral` | -Why does the lessee not just volunteer to close? Because by the time -they are underwater, defaulting might be cheaper for them than -returning the tokens. Liquidation makes sure the lessor still gets -paid even when the lessee disappears. - -**Here is a crucial Solana detail**: Solana programs cannot run -themselves. They have no background thread, no cron, no "trigger when -price changes". Every bit of code runs only because someone (a wallet, -a bot) sent a transaction that called an instruction. +### User accounts passed in -So the program cannot "automatically" liquidate anyone. It has to wait -for someone to send a `liquidate` transaction, providing fresh price -data, and *then* it decides whether the liquidation is valid. This -is why we need keepers. +| Account | Owner | Purpose | +|---|---|---| +| `lessor` wallet | user | `create_lease` signer, receives rent and final recovery | +| `lessee` wallet | user | `take_lease` / `top_up_collateral` / `return_lease` signer | +| `keeper` wallet | user | `liquidate` signer, receives the bounty | +| `payer` wallet | user | `pay_rent` signer (can be anyone, not just the lessee) | +| `lessor_leased_account` | SPL Token | lessor's ATA for the leased mint; source on `create_lease`, destination on `return_lease` / `close_expired` | +| `lessor_collateral_account` | SPL Token | lessor's ATA for the collateral mint; destination for rent and liquidation proceeds | +| `lessee_leased_account` | SPL Token | lessee's ATA for the leased mint; destination on `take_lease`, source on `return_lease` | +| `lessee_collateral_account` | SPL Token | lessee's ATA for the collateral mint; source on `take_lease` / `top_up_collateral`, destination for collateral refund on `return_lease` | +| `keeper_collateral_account` | SPL Token | keeper's ATA for the collateral mint; receives the liquidation bounty | +| `price_update` | Pyth Receiver program | `PriceUpdateV2` account for the feed the lease is pinned to | -### 2.5 Keepers and the keeper bounty +### Fields on `Lease` -A **keeper** is a bot (or a person) that watches the chain, spots -leases that have gone underwater, and sends `liquidate` transactions -to clean them up. Keepers are not special — they are not on some -whitelist, they have no privileged access. They are just accounts -with a script. +From [`state/lease.rs`](programs/asset-leasing/src/state/lease.rs): -Why would anyone run a keeper? Because the program pays them. On a -successful liquidation, a configurable **bounty** (a small share of -the seized collateral, capped at 20% by `MAX_LIQUIDATION_BOUNTY_BPS`) -goes to the keeper. Everyone else's share is smaller as a result, so -the lessor does not love paying it, but it is strictly better than -having no keeper at all and letting the position rot. - -This is a common DeFi pattern: **economic incentives replace trusted -operators**. Instead of hiring someone to watch positions, you write -a rule that pays whoever notices first, and the market takes care of -the rest. Keepers compete on speed; the lessor pays a tiny toll to -keep the system honest. - -### 2.6 Basis points (bps) - -The finance world avoids "percent" when precision matters. Instead it -uses **basis points**: 1 bp = 0.01%, 100 bps = 1%, **10,000 bps = 100%**. +```rust +pub struct Lease { + pub lease_id: u64, // caller-supplied id so one lessor can run many leases + pub lessor: Pubkey, // who listed it, gets paid rent + pub lessee: Pubkey, // who took it; Pubkey::default() while Listed -Why? Mostly two reasons: + pub leased_mint: Pubkey, + pub leased_amount: u64, // locked at creation, unchanging -- **Clarity.** "Raise rates by 25 bps" is unambiguous. "Raise rates by - 0.25 percent" could mean 0.25 percentage points (reasonable) or - 0.25% of the current rate (much smaller). Bps never have that - problem. -- **Integer arithmetic.** On Solana, floating-point is slow and has - rounding surprises. Integer math is cheap and exact. If you express - ratios as bps you can do `amount * bps / 10_000` with plain `u64`s - and never touch a float. + pub collateral_mint: Pubkey, + pub collateral_amount: u64, // increases on top_up, decreases as rent pays out + pub required_collateral_amount: u64, // what the lessee must post on take_lease -This program uses bps everywhere a ratio appears: + pub rent_per_second: u64, // denominated in collateral units + pub duration_seconds: i64, + pub start_ts: i64, // 0 while Listed + pub end_ts: i64, // 0 while Listed; start_ts + duration once Active + pub last_rent_paid_ts: i64, // rent accrues from here to min(now, end_ts) -- `maintenance_margin_bps` — e.g. `12_000` for 120%. -- `liquidation_bounty_bps` — e.g. `500` for 5%. -- The constant `BPS_DENOMINATOR` (= `10_000`) is the divisor. + pub maintenance_margin_bps: u16, // e.g. 12_000 = 120% + pub liquidation_bounty_bps: u16, // e.g. 500 = 5% -### 2.7 Oracles — why programs need them + pub feed_id: [u8; 32], // Pyth feed_id this lease is pinned to -A Solana program is a pure function: given the accounts you hand it, -it computes a result. It **cannot call out** to an external API, it -**cannot read** a price from CoinGecko, and it does not have a -"world model" with current prices baked in. - -So how does the program know whether a position is underwater? It -needs someone to **push the price onchain** for it to read. That -someone is an **oracle**. - -**Pyth** is one of the main oracle networks on Solana. A set of -trusted publishers submit their prices every few seconds to the Pyth -program on Solana. Pyth then aggregates them into a single "official" -price, and stores the result in a special account you can read like -any other — the `PriceUpdateV2` account. - -When the keeper calls `liquidate`, they pass in a freshly-updated -`PriceUpdateV2` account. The program: - -1. Checks the account is actually owned by Pyth's receiver program - (not a malicious account someone minted). -2. Reads the price out of it. -3. Checks that the price is not stale (more on this in §2.8). -4. Uses the price to compute `collateral_value / debt_value`. -5. Compares that to the maintenance margin. - -Without an oracle, the program has no idea what "worth more" means — -it only sees token amounts, not token values. The price feed is the -bridge from "100 GOV, 200 USDC" to "the collateral is worth 2× -the debt". - -> **Note.** The program trusts the keeper to supply a feed that -> actually quotes the leased asset in collateral units. Nothing in the -> code pins a specific feed to a specific lease — that would be a -> sensible upgrade (see §9). - -### 2.8 Per-second rent - -You could imagine a lease where the lessee pays a flat lump sum up -front, regardless of whether they use it for a minute or a month. -That is simple but wasteful: a lessee who returns early gets nothing -back, a lessee who runs late pays the same as one who returns on time. - -This program instead does **streaming rent**: rent accrues by the -second. Every time anyone calls `pay_rent`, the program computes -`rent_per_second × seconds_elapsed_since_last_payment`, pulls that -out of the collateral vault, and sends it to the lessor. - -From the code: + pub status: LeaseStatus, // Listed | Active | Liquidated | Closed -```rust -pub fn compute_rent_due(lease: &Lease, now: i64) -> Result { - let cutoff = now.min(lease.end_ts); - if cutoff <= lease.last_rent_paid_ts { - return Ok(0); - } - let elapsed = (cutoff - lease.last_rent_paid_ts) as u64; - elapsed - .checked_mul(lease.rent_per_second) - .ok_or(AssetLeasingError::MathOverflow.into()) + pub bump: u8, + pub leased_vault_bump: u8, + pub collateral_vault_bump: u8, } ``` -Note two details: +### Lifecycle diagram -- `cutoff = now.min(end_ts)` — rent stops accruing after the lease - ends. If the lessee is 3 days late, they only owe rent up to - `end_ts`, not for the late days. (The lessor gets the whole - collateral by default, so "late fees" are not needed here.) -- The caller sends `pay_rent` whenever they like, but `now` on Solana - is taken from the **clock sysvar**, not from the caller. So the - lessee cannot cheat by sending early transactions with a fake - timestamp. +``` + create_lease + +---------------+ + (no lease) -> | Listed | + +---------------+ + | | + take_lease | | close_expired (lessor cancels) + v v + +---------------+ +--------+ + | Active | ----> | Closed | + +---------------+ +--------+ + | | | + return_lease| | | close_expired (after end_ts) + | | liquidate + v v v + +--------+ +-----------+ + | Closed | | Liquidated| + +--------+ +-----------+ +``` -### 2.9 PDAs — how a program owns things without a private key +The `Closed` and `Liquidated` states are not directly observable +onchain: all three of `return_lease`, `liquidate` and `close_expired` +close the `Lease` account in the same instruction (`close = lessor`), +returning the rent-exempt lamports to the lessor. The in-memory +`status` field is set *before* the close so the transaction logs +record the terminal state, but the account disappears at the end. -A Program Derived Address is a **public key with no private key**. -It is deterministically computed from a program id plus some "seeds" -(arbitrary byte strings you choose). Because there is no private key, -no wallet can sign for it — except the program that owns it, which -can sign by replaying the seeds. +--- -In this codebase you will see seeds like: +## 4. Instruction lifecycle walkthrough -```rust -pub const LEASE_SEED: &[u8] = b"lease"; -pub const LEASED_VAULT_SEED: &[u8] = b"leased_vault"; -pub const COLLATERAL_VAULT_SEED: &[u8] = b"collateral_vault"; -``` +The program has seven instructions. The natural order a user encounters +them — the order below — is: -And in the account constraints, something like: +1. `create_lease` (lessor) +2. `take_lease` (lessee) +3. `pay_rent` (anyone) +4. `top_up_collateral` (lessee) +5. `return_lease` (lessee) — **happy path** +6. `liquidate` (keeper) — **adversarial path** +7. `close_expired` (lessor) — **default / cancel path** -```rust -seeds = [LEASE_SEED, lessor.key().as_ref(), &lease_id.to_le_bytes()], -``` +For each, the shape is the same: who signs, what accounts go in, which +PDAs get created or closed, which tokens move, what state changes, what +checks the program runs. -That address is **deterministic**: given the program id, the lessor's -pubkey, and `lease_id`, everyone in the world can compute the same -PDA. Convenient for UIs ("the lease for (lessor=X, id=7) lives -*here*") and impossible to spoof (nobody can beat the program to the -address). +Token-flow diagrams use the following shorthand: -### 2.10 Vault accounts +``` + --[amount of ]--> +``` -The `leased_vault` and `collateral_vault` are **SPL token accounts -whose authority is a PDA**. Ordinary tokens sit in wallets. These -tokens sit in accounts the program controls: only the program can -sign for moves out of them, and only under the rules the program -encodes. +### 4.1 `create_lease` -From `create_lease.rs`: +**Who calls it:** the lessor. They want to offer some number of leased +tokens for a fixed term against collateral of a different mint. -```rust -#[account( - init, - payer = lessor, - seeds = [LEASED_VAULT_SEED, lease.key().as_ref()], - bump, - token::mint = leased_mint, - token::authority = leased_vault, // <- vault is its own authority - token::token_program = token_program, -)] -pub leased_vault: Box>, -``` +**Signers:** `lessor`. -Note the authority is the vault itself — a self-authorising PDA. The -handler moves tokens out by re-deriving the seeds and using them to -"sign" the CPI (cross-program invocation) to the token program: +**Parameters:** ```rust -let leased_vault_seeds: &[&[u8]] = &[ - LEASED_VAULT_SEED, - lease_key.as_ref(), - core::slice::from_ref(&leased_vault_bump), -]; -let signer_seeds = [leased_vault_seeds]; -transfer_tokens_from_vault( - &context.accounts.leased_vault, - &context.accounts.lessee_leased_account, - leased_amount, - &context.accounts.leased_mint, - &context.accounts.leased_vault.to_account_info(), - &context.accounts.token_program, - &signer_seeds, -)?; +pub fn create_lease( + context: Context, + lease_id: u64, + leased_amount: u64, + required_collateral_amount: u64, + rent_per_second: u64, + duration_seconds: i64, + maintenance_margin_bps: u16, + liquidation_bounty_bps: u16, + feed_id: [u8; 32], +) -> Result<()> ``` -The "signature" here is not a crypto signature in the traditional -sense — it is the runtime saying "yes, this program is allowed to -move these tokens because it re-derived the seeds that produced the -authority, so it must be the program that set them up". - ---- +**Accounts in:** -## 3. The full lifecycle, walked through with numbers +- `lessor` (signer, mut — pays account rent) +- `leased_mint`, `collateral_mint` (read-only) +- `lessor_leased_account` (mut, lessor's ATA for the leased mint — source) +- `lease` (PDA, **init**) — created here +- `leased_vault` (PDA, **init**, token account) — created here +- `collateral_vault` (PDA, **init**, token account) — created here +- `token_program`, `system_program` -Let's follow a single lease through every path the program supports. -To keep the numbers friendly, we will use two fake SPL tokens: +**PDAs created:** -- **GOV** — a governance token, 6 decimals. Current price: 1 USDC each. -- **USDC** — everyone's favourite stablecoin, 6 decimals. Worth 1 USDC - (obviously). +- `lease` with seeds `[b"lease", lessor, lease_id.to_le_bytes()]` +- `leased_vault` with seeds `[b"leased_vault", lease]`, authority = itself +- `collateral_vault` with seeds `[b"collateral_vault", lease]`, authority = itself -Meet the cast: +**Checks (from `handle_create_lease`):** -- **Alice** — lessor. Owns 1,000 GOV and does not want to sell. -- **Bob** — lessee. Wants GOV temporarily, has some USDC. -- **Kim** — keeper. Runs a bot. Has no direct interest in either - side, but will happily pocket a bounty for noticing if Bob goes - underwater. +- `leased_mint != collateral_mint` → `LeasedMintEqualsCollateralMint` +- `leased_amount > 0` → `InvalidLeasedAmount` +- `required_collateral_amount > 0` → `InvalidCollateralAmount` +- `rent_per_second > 0` → `InvalidRentPerSecond` +- `duration_seconds > 0` → `InvalidDuration` +- `0 < maintenance_margin_bps <= 50_000` → `InvalidMaintenanceMargin` +- `liquidation_bounty_bps <= 2_000` → `InvalidLiquidationBounty` -All amounts below are in whole-token units for readability; in the -actual test the same logic runs in 6-decimal base units. +**Token movements:** -### 3.1 Listing — `create_lease` +``` + lessor_leased_account --[leased_amount of leased_mint]--> leased_vault PDA +``` -Alice calls `create_lease` with: +**State changes:** -| Parameter | Value | Meaning | -| --- | --- | --- | -| `lease_id` | `1` | Unique per-lessor id so she can run multiple leases | -| `leased_amount` | `1_000` GOV | Amount she wants to lease out | -| `required_collateral_amount` | `1_200` USDC | Deposit Bob must post | -| `rent_per_second` | `1` USDC | Rent rate — ridiculously high for clarity | -| `duration_seconds` | `604_800` | One week | -| `maintenance_margin_bps` | `12_000` | 120% | -| `liquidation_bounty_bps` | `500` | 5% of the remaining collateral | +- New `Lease` account written with `status = Listed`, `lessee = + Pubkey::default()`, `collateral_amount = 0`, `start_ts = 0`, + `end_ts = 0`, `last_rent_paid_ts = 0`, and the given parameters + including `feed_id`. All three bumps stored. -What happens: +**Why lock the leased tokens up-front rather than on `take_lease`?** So a +lessee who calls `take_lease` cannot possibly fail because the lessor +doesn't have the tokens any more — the atomicity guarantee is +transferred to the PDA the moment the lease is listed. -1. The program creates three new accounts: the `Lease` PDA, the - `leased_vault` (for GOV), and the `collateral_vault` (for USDC). -2. It transfers 1,000 GOV from Alice's wallet into the `leased_vault`. - **This happens at listing, not at take-up**, so a lessee can never - accept a lease where the lessor does not actually have the goods. -3. It records all the terms on the `Lease` account, with `status = - Listed`, `lessee = Pubkey::default()`, `collateral_amount = 0`. +### 4.2 `take_lease` -At this point Alice's wallet has 1,000 fewer GOV. They are escrowed. +**Who calls it:** the lessee. They have seen the `Lease` account on +chain (somehow — an indexer, a direct lookup, whatever) and want to +take delivery. -### 3.2 Taking — `take_lease` +**Signers:** `lessee`. -Bob sees Alice's listing (via a UI that queries `Lease` accounts). -He decides to take it. He calls `take_lease`. The program: +**Accounts in:** -1. Verifies the lease is `Listed` and that the mints match. -2. Transfers 1,200 USDC from Bob's wallet into the `collateral_vault`. -3. Transfers 1,000 GOV out of the `leased_vault` into Bob's GOV ATA - (created on the fly if he did not have one). -4. Updates the `Lease`: `lessee = Bob`, `collateral_amount = 1_200`, - `start_ts = now`, `end_ts = now + 604_800`, `last_rent_paid_ts = - now`, `status = Active`. +- `lessee` (signer, mut) +- `lessor` (UncheckedAccount — read for PDA seed derivation only, no + signature required) +- `lease` (mut, `has_one = lessor`, `has_one = leased_mint`, + `has_one = collateral_mint`, must be `Listed`) +- `leased_mint`, `collateral_mint` +- `leased_vault`, `collateral_vault` (both mut, both PDA-derived) +- `lessee_collateral_account` (mut, lessee's ATA — source) +- `lessee_leased_account` (mut, **init_if_needed** — destination) +- `token_program`, `associated_token_program`, `system_program` -Now: +**Checks:** -- The `leased_vault` is empty (Bob has the GOV). -- The `collateral_vault` holds 1,200 USDC. -- Alice has neither the GOV nor the USDC; she has an onchain claim - to rent and, eventually, the GOV back. +- `lease.status == Listed` → `InvalidLeaseStatus` +- `lease.lessor == lessor.key()` (Anchor `has_one`) +- `lease.leased_mint == leased_mint.key()` (Anchor `has_one`) +- `lease.collateral_mint == collateral_mint.key()` (Anchor `has_one`) -### 3.3 Happy path — return on time +**Token movements (in order):** -Two days in (172,800 seconds) Bob has finished his governance voting -and wants to return the tokens. A few things might have happened in -between: +``` + lessee_collateral_account --[required_collateral_amount of collateral_mint]--> collateral_vault PDA + leased_vault PDA --[leased_amount of leased_mint]-----------------> lessee_leased_account +``` -- Someone (Bob, or a public-spirited keeper, or Alice herself) may - have called `pay_rent` from time to time, streaming rent from the - vault to Alice. It is not required — the tally is cumulative. -- The price of GOV may have moved either way. As long as the position - stayed above the maintenance margin, nothing special happened. +Collateral is deposited *first* so if the leased-token transfer fails +for any reason the whole transaction reverts and the lessee gets their +collateral back. -Bob calls `return_lease`. The program: +**State changes:** -1. Transfers 1,000 GOV from Bob back into the `leased_vault`, then - straight out to Alice. -2. Computes accrued rent since `last_rent_paid_ts`. In this example: - 172,800 s × 1 USDC/s = 172,800 USDC of rent — clearly more than - the 1,200 USDC collateral, so the program caps it at the vault - balance. (Obviously you would choose a sensible rent rate in - practice; these numbers are just to illustrate.) -3. Pays the rent to Alice, refunds **the rest of the collateral** to - Bob. -4. Closes both vaults and the `Lease` account, sending the - rent-exempt lamports back to Alice. +- `lease.lessee = lessee.key()` +- `lease.collateral_amount = required_collateral_amount` +- `lease.start_ts = now` +- `lease.end_ts = now + duration_seconds` (checked add, errors on overflow) +- `lease.last_rent_paid_ts = now` (nothing has accrued yet) +- `lease.status = Active` -Key point: **Bob never pays rent for time he did not use.** The -`cutoff = now.min(end_ts)` in `compute_rent_due` and the "return -early" code path together guarantee it. +### 4.3 `pay_rent` -(In realistic numbers — say `rent_per_second = 10` base-units of -USDC — 172,800 s of rent is 1.728 USDC, Alice gets that, Bob gets -the other ~1,198.27 USDC back. Much happier arithmetic.) +**Who calls it:** anyone. The lessee's incentive is obvious (keep the +lease from going underwater); a keeper bot may also push rent before a +liquidation check so healthy leases stay healthy. -### 3.4 Margin-call path — Bob tops up +**Signers:** `payer` (any signer). -Halfway through the week, GOV moons from 1.00 to 1.50 USDC. Now: +**Accounts in:** -- Debt value: 1,000 GOV × 1.50 = 1,500 USDC. -- Required cushion at 120%: 1,800 USDC. -- Bob only has 1,200 USDC locked. +- `payer` (signer, mut — pays for `init_if_needed` of the lessor ATA) +- `lessor` (UncheckedAccount, read-only — used for `has_one` check) +- `lease` (mut, must be `Active`) +- `collateral_mint`, `collateral_vault` +- `lessor_collateral_account` (mut, **init_if_needed**) +- `token_program`, `associated_token_program`, `system_program` -If any keeper calls `liquidate` with a fresh price, the program will -agree the position is underwater and seize the collateral. Bob does -not want that — he wants to finish his week. So he calls -`top_up_collateral` with `amount = 700` USDC. That tops the vault up -to 1,900 USDC, back above the 1,800 requirement. Any liquidation -attempt will now be rejected with `PositionHealthy`. - -The code here is small and boring — exactly the sign of a good -function: +**Rent math:** ```rust -pub fn handle_top_up_collateral(context: Context, amount: u64) -> Result<()> { - require!(amount > 0, AssetLeasingError::InvalidCollateralAmount); - transfer_tokens_from_user(...)?; - context.accounts.lease.collateral_amount = context - .accounts - .lease - .collateral_amount - .checked_add(amount) - .ok_or(AssetLeasingError::MathOverflow)?; - Ok(()) +pub fn compute_rent_due(lease: &Lease, now: i64) -> Result { + let cutoff = now.min(lease.end_ts); + if cutoff <= lease.last_rent_paid_ts { + return Ok(0); + } + let elapsed = (cutoff - lease.last_rent_paid_ts) as u64; + elapsed.checked_mul(lease.rent_per_second) + .ok_or(AssetLeasingError::MathOverflow.into()) } ``` -Note only the `lessee` can top up their own lease (`constraint = -lease.lessee == lessee.key()`). - -### 3.5 Liquidation path — Bob does nothing - -Same setup, but Bob is either asleep, out of USDC, or hoping the -price will come back. He does not top up. A keeper (Kim) is watching -and notices the lease is now underwater. She submits `liquidate` with -a recent `PriceUpdateV2` account quoting GOV at 1.50 USDC. - -The program: - -1. Verifies the `PriceUpdateV2` account is owned by the Pyth receiver - program (rejects anything else). -2. Decodes the price and publish time. Rejects if the price is - stale (> 60 s old) or in the future or non-positive. -3. Computes whether the position is underwater: - `collateral_value × 10_000 < debt_value × margin_bps`. - With 1,200 USDC vs. 1,500×1.20 = 1,800 USDC, yes — underwater. -4. Pays accrued rent to Alice first (so she gets paid for the time - Bob did use). -5. Takes the **remaining** collateral and slices off the bounty: - 5% × remaining → Kim. The rest → Alice. -6. Closes both vaults and the lease account. `status = Liquidated`, - `collateral_amount = 0`. - -Notice: the leased vault is empty in this path. Bob kept the GOV. -Alice's compensation is purely in collateral. That is by design — -the collateral exists specifically to cover this case. If the -margin was set high enough to begin with, Alice is whole. - -### 3.6 Default path — the week runs out, Bob ghosts - -Bob never returns the tokens and never gets liquidated (maybe the -price held steady, so no keeper had cause to liquidate him). A week -later `end_ts` passes. The lease is still `Active`, but by its own -terms it has expired. - -Alice calls `close_expired`. The program: - -1. Checks `lease.status` is `Active` (or `Listed` — same instruction - handles both). -2. If `Active`, requires `now >= end_ts`. -3. Drains whatever is in the leased vault back to Alice. (In this - default case: zero — Bob has the GOV.) -4. Drains the collateral vault back to Alice. (In this case: the - full 1,200 USDC, because no rent was settled.) -5. Closes both vaults and the lease. - -Alice is out 1,000 GOV but has gained 1,200 USDC. As long as the -lease was priced correctly when created (collateral > leased value), -that is a fair outcome for her. - -> **Heads up — a subtlety:** `close_expired` does not settle rent. -> In the default path all the collateral goes to Alice anyway, so -> the distinction does not matter. But if you ever extend the -> instruction (e.g. to refund *partial* collateral to the lessee), -> you will need to decide whether to call `compute_rent_due` first. - -### 3.7 Cancelled listing — Alice changes her mind - -Alice lists the lease and then decides not to rent it out. She can -reclaim her GOV any time before anyone takes it: - -- She calls `close_expired` on the `Listed` lease. -- The `now >= end_ts` check does **not** apply to `Listed` leases - (look at the `if status == Active` guard in the handler — listed - leases skip it). -- The leased vault holds her 1,000 GOV; they go straight back to - her. The collateral vault was never funded, so that part is a - no-op. Both vaults and the lease close. +Rent does not accrue past `end_ts`. Past the deadline the lessee is +either returning the tokens (via `return_lease`), being liquidated, or +defaulting — no more rent is owed. ---- +**Token movements:** -## 4. Instructions reference - -There are seven instructions. They map one-to-one onto a file under -`programs/asset-leasing/src/instructions/`. - -| Instruction | Who calls | What it does | Why it exists | -| --- | --- | --- | --- | -| `create_lease` | Lessor | Locks the leased tokens in a program vault and records the terms. Lease starts `Listed`. | Listing up front (rather than on take-up) means a lessee cannot accept a lease the lessor can no longer honour. The tokens are real, escrowed, and nobody but the program can move them. | -| `take_lease` | Lessee | Deposits `required_collateral_amount`, receives the leased tokens. Sets `start_ts`, `end_ts`, `last_rent_paid_ts`. Status → `Active`. | Two-step listing-then-take lets the lessor advertise terms without pre-committing to a specific lessee; anyone can take it first. | -| `pay_rent` | **Anyone** | Computes rent since `last_rent_paid_ts`, transfers it from the collateral vault to the lessor, updates `last_rent_paid_ts`. | Keeping the rent-accrual separate from return/liquidation lets the lessor (or anyone else) settle rent whenever — handy for long leases. The caller pays transaction fees, but no state is otherwise affected if there is no rent to move. | -| `top_up_collateral` | Lessee | Adds more collateral to the vault. | Market prices move. Without a top-up, a short-lived dip during a volatile hour would liquidate every lessee. Top-ups give the lessee a chance to defend their position. | -| `return_lease` | Lessee | Returns the full `leased_amount`, pays final rent, refunds remaining collateral, closes the lease. | The cooperative way to end a lease early or on time. Runs all the settlements in one transaction so there is no window where, say, the tokens have been returned but the collateral is still locked. | -| `liquidate` | Keeper | Verifies the supplied Pyth price, checks the position is underwater, pays accrued rent + bounty + lessor share, closes the lease. Status → `Liquidated`. | The non-cooperative way to end the lease when the collateral no longer covers the debt. Lessor is compensated, keeper is rewarded for doing the work. | -| `close_expired` | Lessor | Two modes: (1) cancel a `Listed` lease to reclaim the leased tokens, (2) sweep collateral + any remaining tokens after a defaulted `Active` lease's `end_ts`. | Lessors need an unclog path — "nobody is taking this, give me my tokens back" — and a default recovery path — "they never returned the tokens, pay me the collateral". Same instruction covers both; the branch is on `lease.status`. | - -Each instruction has its own `Accounts` struct listing every account it -touches, with Anchor constraints (`has_one = lessor`, `constraint = -lease.status == LeaseStatus::Active`, etc.) that run **before** the -handler body. If any constraint fails, the transaction aborts and no -state changes. This is the usual Anchor pattern: put the validation in -the struct, keep the handler body about the business logic. - -### Calling conventions - -- `create_lease`, `take_lease`, `top_up_collateral`, `pay_rent`, - `return_lease` and `close_expired` take the `lease_id` implicit in - the seed-derived `Lease` PDA — the client derives the PDA and passes - it as an account. Only `create_lease` needs `lease_id` as an explicit - argument because the `Lease` PDA does not exist yet to derive it - from. -- All account struct definitions live with their handlers; all of them - are re-exported from `instructions/mod.rs` so `lib.rs` can use them - by name. +``` + collateral_vault PDA --[min(rent_due, collateral_amount) of collateral_mint]--> lessor_collateral_account +``` ---- +If the vault does not have enough collateral to cover the full +`rent_due`, the handler pays out whatever is there and leaves the +residual as a debt the next liquidation (or `close_expired`) will +clean up. -## 5. Accounts and PDAs +**State changes:** -Three PDAs hold the entire lifecycle of one lease. +- `lease.collateral_amount -= payable` +- `lease.last_rent_paid_ts = now.min(end_ts)` -### 5.1 `Lease` — the state account +### 4.4 `top_up_collateral` -Seeded by `(b"lease", lessor, lease_id)`. One lessor can run as many -leases in parallel as they like by using different `lease_id` values. +**Who calls it:** the lessee — to defend against a looming liquidation +by adding more of the collateral mint to the vault. -Fields (see `src/state/lease.rs` for the authoritative definition): +**Signers:** `lessee`. -| Field | Type | Meaning | -| --- | --- | --- | -| `lease_id` | `u64` | Caller-chosen id, part of the PDA seed. | -| `lessor` | `Pubkey` | Who owns this lease. Receives rent; recovers assets. | -| `lessee` | `Pubkey` | Set by `take_lease`. `Pubkey::default()` while `Listed`. | -| `leased_mint` | `Pubkey` | Mint of the tokens being rented out. | -| `leased_amount` | `u64` | Fixed at creation. Always the same amount is returned. | -| `collateral_mint` | `Pubkey` | Mint of the collateral. | -| `collateral_amount` | `u64` | Live balance of the collateral vault as the program sees it. Increases on top-up, decreases as rent is paid. | -| `required_collateral_amount` | `u64` | Amount the lessee had to post up front. Not the same as `collateral_amount` — the vault's balance changes over time. | -| `rent_per_second` | `u64` | Streaming rate in collateral base-units per second. | -| `duration_seconds` | `i64` | Length of the lease, set at creation. | -| `start_ts`, `end_ts` | `i64` | Filled by `take_lease`. `0` while `Listed`. | -| `last_rent_paid_ts` | `i64` | Point up to which rent has been settled. | -| `maintenance_margin_bps` | `u16` | Health threshold. Capped at `MAX_MAINTENANCE_MARGIN_BPS` = 50_000 (500%). | -| `liquidation_bounty_bps` | `u16` | Keeper bounty. Capped at `MAX_LIQUIDATION_BOUNTY_BPS` = 2_000 (20%). | -| `status` | `LeaseStatus` | `Listed` → `Active` → `Closed`/`Liquidated`. | -| `bump`, `leased_vault_bump`, `collateral_vault_bump` | `u8` | Cached bump seeds so CPIs can re-sign without re-deriving. | +**Accounts in:** -The lifecycle transitions (copied from the doc comment on -`LeaseStatus`): +- `lessee` (signer) +- `lessor` (UncheckedAccount, read-only) +- `lease` (mut, `has_one = lessor`, `has_one = collateral_mint`, + `constraint lease.lessee == lessee.key()`, must be `Active`) +- `collateral_mint`, `collateral_vault` +- `lessee_collateral_account` (mut, source) +- `token_program` -``` -Listed --take_lease--> Active -Active --return_lease--> Closed -Active --liquidate--> Liquidated -Listed --close_expired--> Closed (lessor cancels) -Active --close_expired--> Closed (defaulted lessee, after end_ts) -``` +**Parameter:** `amount: u64` — how much to add. -Why is it a PDA? So that: - -- There is a **canonical address** anyone can derive. A UI does not - need a database to find Alice's lease #1 — it computes - `find_program_address([b"lease", alice, 1u64.to_le_bytes()], program_id)` - and reads. -- The program can **act as authority** for the account without a - private key. No mnemonic to hide, no key to lose, and no way for - an attacker to forge a signature. -- Collisions are impossible: for the same `(lessor, lease_id)` pair - there is exactly one PDA. `create_lease` uses `init`, which fails - if the account already exists. - -### 5.2 `leased_vault` — escrow for the leased tokens - -An SPL token account. Seeded by `(b"leased_vault", lease)`. Authority -is itself (`token::authority = leased_vault`). - -- Holds the `leased_amount` while the lease is `Listed`. -- Drained on `take_lease` (tokens go to lessee) and on `return_lease` - (tokens go back to lessor via this vault). -- Empty during the `Active` phase (the lessee is holding the - tokens). -- Closed when the lease settles, freeing its rent-exempt lamports - back to the lessor. - -### 5.3 `collateral_vault` — escrow for the collateral - -An SPL token account. Seeded by `(b"collateral_vault", lease)`. -Authority is itself. - -- Funded on `take_lease` with `required_collateral_amount`. -- Grows on `top_up_collateral`. -- Shrinks on every `pay_rent` (rent flows lessor-ward). -- Split between lessor and keeper on `liquidate`. -- Fully drained on `return_lease` / `close_expired`. -- Closed at settlement, lamports to the lessor. - -### 5.4 Associated Token Accounts - -The program touches several **associated token accounts** (ATAs) — -token accounts deterministically derived from `(wallet, mint)`. You -will see `associated_token::mint = X, associated_token::authority = -Y` in account structs. These are not PDAs *of this program*; they are -PDAs of the Associated Token Account program, a standard way for any -wallet to have "the account" for any given mint. - -Many of the ATAs are marked `init_if_needed` — meaning "create the -account if it does not exist yet, otherwise use the existing one". -This is a small quality-of-life touch: the UI never has to -pre-create token accounts for users, the program does it when it -first needs one. The caller pays the rent for the new account. +**Checks:** ---- +- `amount > 0` → `InvalidCollateralAmount` +- `lease.lessee == lessee.key()` → `Unauthorised` +- `lease.status == Active` → `InvalidLeaseStatus` -## 6. Pyth integration, in plain English +**Token movements:** -### 6.1 What Pyth is +``` + lessee_collateral_account --[amount of collateral_mint]--> collateral_vault PDA +``` -**Pyth** is an oracle network. Professional trading firms, exchanges -and market makers run Pyth publisher nodes that submit prices several -times a second. On Solana, the aggregated result is written into -`PriceUpdateV2` accounts you can read from your program like any -other account. +**State changes:** -The key idea: Pyth does not guess prices. Real market participants -report prices from their own systems, Pyth aggregates, signs, and -posts. Your program treats the result as **ground truth for this -block**. +- `lease.collateral_amount += amount` (checked add) -### 6.2 The `PriceUpdateV2` account +### 4.5 `return_lease` -A `PriceUpdateV2` is a regular Solana account, owned by the Pyth -Solana Receiver program -(`rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ`). Its data layout is -(simplified): +**Who calls it:** the lessee, while the lease is still `Active` and +before or after `end_ts` (the only timing rule is that `status == +Active`; rent only accrues up to `end_ts` so returning after the +deadline does not pile on extra charges). -``` -| 8 bytes | Anchor discriminator for "PriceUpdateV2" -| 32 bytes | write_authority -| 1 byte | verification_level -| 32 bytes | feed_id -| 8 bytes | price (i64) -| 8 bytes | conf (u64) — not used here -| 4 bytes | exponent (i32) -| 8 bytes | publish_time (i64) -... more fields we don't read -``` +**Signers:** `lessee`. -A Pyth price is stored as an integer `price` plus an integer -`exponent`, such that the real price is `price × 10^exponent`. A -`price = 15_000, exponent = -4` means `1.5000`. This keeps the value -exact — no floats anywhere. +**Accounts in:** -### 6.3 Why our program decodes by hand +- `lessee` (signer, mut) +- `lessor` (UncheckedAccount, mut — receives Lease and vault rent-exempt + lamports via `close = lessor`) +- `lease` (mut, `close = lessor`, must be `Active`, `lessee == lessee.key()`) +- `leased_mint`, `collateral_mint` +- `leased_vault`, `collateral_vault` (both mut) +- `lessee_leased_account` (mut, source for the return) +- `lessee_collateral_account` (mut, destination for the refund) +- `lessor_leased_account` (mut, **init_if_needed**) +- `lessor_collateral_account` (mut, **init_if_needed**) +- `token_program`, `associated_token_program`, `system_program` -Normally you would import `pyth-solana-receiver-sdk` to parse the -account. We don't, and the comment at the top of `liquidate.rs` -tells you why: +**Checks:** -> We do not pull in `pyth-solana-receiver-sdk` because that crate -> currently has a transitive `borsh` conflict with `anchor-lang` -> 1.0.0 (see `program-examples/.github/.ghaignore` — `oracles/pyth/anchor` -> is flagged for the same reason). +- `lease.status == Active` → `InvalidLeaseStatus` +- `lease.lessee == lessee.key()` → `Unauthorised` -Anchor 1.0 upgraded Borsh in a way that Pyth's SDK has not caught up -to yet. Rather than fight dependency resolution, we hard-code the -account layout and read the bytes ourselves: +**Token movements (in order):** -```rust -pub const PRICE_UPDATE_V2_DISCRIMINATOR: [u8; 8] = - [34, 241, 35, 99, 157, 126, 244, 205]; - -pub fn decode_price_update(data: &[u8]) -> Result { - const PRICE_OFFSET: usize = 73; - const EXPONENT_OFFSET: usize = PRICE_OFFSET + 8 + 8; - const PUBLISH_TIME_OFFSET: usize = EXPONENT_OFFSET + 4; - const MIN_LEN: usize = PUBLISH_TIME_OFFSET + 8; - - require!(data.len() >= MIN_LEN, AssetLeasingError::StalePrice); - require!( - data[..8] == PRICE_UPDATE_V2_DISCRIMINATOR, - AssetLeasingError::StalePrice - ); - // ... i64::from_le_bytes / i32::from_le_bytes out of fixed offsets -} +``` + lessee_leased_account --[leased_amount of leased_mint]----------> leased_vault PDA + leased_vault PDA --[leased_amount of leased_mint]----------> lessor_leased_account + collateral_vault PDA --[rent_payable of collateral_mint]-------> lessor_collateral_account + collateral_vault PDA --[collateral_after_rent of collateral_mint]--> lessee_collateral_account ``` -Two safety properties make this safe: +The leased tokens hop through the vault rather than going direct +lessee→lessor because the vault's token account is already set up and +the program can reuse its PDA signing path. The atomic round-trip keeps +the vault's post-ix balance at 0 so it can be closed. -1. The `Accounts` struct declares `#[account(owner = - PYTH_RECEIVER_PROGRAM_ID)]`. Solana's runtime refuses the - transaction if anyone passes an account not owned by Pyth's - receiver program. So the bytes we read are produced by Pyth, not - by an attacker. -2. The first 8 bytes are checked against the `PriceUpdateV2` - discriminator. That rejects any Pyth-owned account of a - *different* type (in case future Pyth versions add sibling - account types). +After the transfers: -If the SDK dependency issue ever gets resolved upstream, future-you -can swap the manual decode for a type-checked `PriceUpdateV2::try_from` -call. The handler's shape won't change. +- Both vaults are closed via `close_account` CPIs; their rent-exempt + lamports go to the lessor. +- The `Lease` account is closed via Anchor's `close = lessor` + constraint; its rent-exempt lamports go to the lessor too. -### 6.4 Staleness +**State changes before close:** -Prices are only useful if they are recent. An old price can make a -healthy position look unhealthy, or (much worse) let a keeper -liquidate based on a stale snapshot. +- `lease.last_rent_paid_ts = now.min(end_ts)` +- `lease.collateral_amount = 0` +- `lease.status = Closed` -From `constants.rs`: +### 4.6 `liquidate` -```rust -pub const PYTH_MAX_AGE_SECONDS: u64 = 60; -``` +**Who calls it:** a keeper, when they can prove the position is +underwater. -and from `is_underwater` in `liquidate.rs`: +**Signers:** `keeper`. -```rust -require!(price.publish_time <= now, AssetLeasingError::StalePrice); -let age = (now - price.publish_time) as u64; -require!(age <= PYTH_MAX_AGE_SECONDS, AssetLeasingError::StalePrice); -``` +**Accounts in:** -Three checks, for three threat models: +- `keeper` (signer, mut — pays `init_if_needed` cost for both ATAs) +- `lessor` (UncheckedAccount, mut — receives rent + lessor_share + the + `Lease` and vault rent-exempt lamports) +- `lease` (mut, `close = lessor`, must be `Active`) +- `leased_mint`, `collateral_mint` +- `leased_vault`, `collateral_vault` (both mut) +- `lessor_collateral_account` (mut, **init_if_needed**) +- `keeper_collateral_account` (mut, **init_if_needed**) +- `price_update` (UncheckedAccount, constrained to `owner = + PYTH_RECEIVER_PROGRAM_ID`) +- `token_program`, `associated_token_program`, `system_program` -- `publish_time <= now` — **No future-dated prices.** Guards against - a malicious keeper manufacturing a "future" price to game the - math. (In practice `publish_time` from Pyth is never future, but - defence-in-depth.) -- `age <= 60 s` — **Not too old.** If Pyth has stalled (perhaps a - degraded network), we refuse to liquidate rather than act on stale - data. -- `price > 0` — `NonPositivePrice`. A zero or negative price would - let a liquidator seize collateral for "free debt", obviously - wrong. +**Checks (in order, early-out on failure):** -### 6.5 The underwater check +1. `price_update.owner == Pyth Receiver program id` (Anchor `owner =`) +2. Account data decodes as `PriceUpdateV2` (first 8 bytes match + `PRICE_UPDATE_V2_DISCRIMINATOR`; length ≥ 89 bytes) — else + `StalePrice` +3. `decoded.feed_id == lease.feed_id` → `PriceFeedMismatch` +4. `publish_time <= now` (no future stamps) and + `now - publish_time <= 60 seconds` → `StalePrice` +5. `price > 0` → `NonPositivePrice` +6. `is_underwater(lease, price, now) == true` → `PositionHealthy` +7. `lease.status == Active` (Anchor constraint on the `lease` field) -Here is the actual inequality: +The underwater check, in integers: ``` -collateral × 10_000 < debt × maintenance_margin_bps -^ left side ^ right side + collateral_value_in_colla_units * 10_000 + < debt_value_in_colla_units * maintenance_margin_bps ``` -Rearranging: the position is underwater when the collateral-to-debt -ratio falls below the margin. Everything is in integers (Pyth's -`exponent` is folded into one side of the inequality to keep the -scales balanced). See the `is_underwater` function if you want to -walk through the math — it is well-commented. +where `debt_value = leased_amount * price * 10^exponent` (with the +exponent folded into whichever side keeps the math non-negative, see +[`is_underwater`](programs/asset-leasing/src/instructions/liquidate.rs)). ---- - -## 7. Safety and edge cases worth knowing +**Token movements:** -A few corners of the design worth thinking about. +``` + collateral_vault PDA --[rent_payable of collateral_mint]---------------------> lessor_collateral_account + collateral_vault PDA --[bounty = remaining * bounty_bps / 10_000]-----------> keeper_collateral_account + collateral_vault PDA --[remaining - bounty of collateral_mint]--------------> lessor_collateral_account + leased_vault PDA --[0 of leased_mint] (empty — lessee kept the tokens) close only +``` -### 7.1 What if the lessee returns the tokens while liquidation is in flight? +After the three outbound collateral transfers (rent, bounty, lessor +share) the collateral_vault is empty. Both vaults are then closed — +their rent-exempt lamports go to the lessor. The `Lease` account is +closed the same way (Anchor `close = lessor`). -Both `return_lease` and `liquidate` mutate the same `Lease` account. -Only one transaction per slot can win — Solana serialises mutations -to each account. Whichever lands first flips the status -(`Closed` for return, `Liquidated` for liquidate). The loser hits the -`constraint = lease.status == LeaseStatus::Active` check and fails. +**State changes before close:** -So there is no "both happened" race. The user who reacts first wins. +- `lease.collateral_amount = 0` +- `lease.last_rent_paid_ts = now.min(end_ts)` +- `lease.status = Liquidated` -### 7.2 What if the Pyth oracle stops updating? +### 4.7 `close_expired` -The staleness check (`age <= 60 s`) simply fails. The `liquidate` -transaction aborts with `StalePrice`. Nothing bad happens — lessees -cannot be liquidated on stale data. The trade-off is that if prices -are unavailable and a lessee goes underwater, the lessor just has -to wait until prices are flowing again before a keeper can act. -That's usually preferable to the alternative. +**Who calls it:** the lessor. Two very different situations collapse +into this single instruction: -### 7.3 What if the collateral mint is the same as the leased mint? +- **Cancel a `Listed` lease** — the lessor changes their mind, no-one + has taken the lease yet. Allowed any time. +- **Reclaim collateral after default** — the lease is `Active`, `now >= + end_ts`, the lessee has not called `return_lease`. The lessor takes + the whole collateral vault as compensation. -Nothing in `create_lease` currently forbids this. The handler would -succeed, and the resulting lease would be a "token A for token A" -rent — arguably pointless, but not dangerous. There is no -path where the two vaults would get mixed up (they are separate PDAs -with different seeds, even if they hold the same mint). If you -wanted to forbid it, a one-line `require!(leased_mint.key() != -collateral_mint.key(), ...)` in `create_lease` would do it. +**Signers:** `lessor`. -### 7.4 What if rent accrues for longer than the collateral can cover? +**Accounts in:** -Great question. This is the normal case just before a liquidation: -rent has eaten through the collateral faster than the lessee has -topped it up. +- `lessor` (signer, mut — also the rent destination for all three closes) +- `lease` (mut, `close = lessor`, status ∈ `{Listed, Active}`) +- `leased_mint`, `collateral_mint` +- `leased_vault`, `collateral_vault` (both mut) +- `lessor_leased_account` (mut, **init_if_needed**) +- `lessor_collateral_account` (mut, **init_if_needed**) +- `token_program`, `associated_token_program`, `system_program` -The handler handles it gracefully: +**Checks:** -```rust -let payable = rent_amount.min(context.accounts.collateral_amount_available()); -``` +- `status ∈ {Listed, Active}` (Anchor `constraint matches!(...)`) → + `InvalidLeaseStatus` +- If `status == Active`, also `now >= end_ts` → `LeaseNotExpired` -Rent is **capped at the vault balance**. The program never tries to -move tokens that aren't there. The unpaid rent becomes implicit debt, -which will trigger liquidation (or default recovery on `end_ts`) -because the position will fail the maintenance-margin check. In -other words: "lessee ran out of money" and "lessee is underwater" end -up in the same place. The math lines up. - -### 7.5 What if `end_ts` is reached but nobody calls anything? - -Nothing special happens automatically. `Active` leases continue to -exist forever until somebody calls `return_lease`, `liquidate`, or -`close_expired`. Rent stops accruing at `end_ts` because of the -`cutoff = now.min(end_ts)` clamp, so the lessor is not building up -infinite phantom debt. It is just a lease sitting in state waiting -to be cleaned up. - -In production you would usually run a small keeper that calls -`close_expired` shortly after `end_ts` on any `Active` lease, just -to reclaim the rent-exempt lamports and tidy up the account. - -### 7.6 Who pays for what? - -- **Lessor** pays for creating the `Lease`, the `leased_vault` and - the `collateral_vault` at listing (rent-exempt lamports). They - get those back on close, but also have to fund the lessor ATAs if - they did not already exist. -- **Lessee** pays transaction fees for `take_lease`, - `top_up_collateral` and `return_lease`, and funds their own ATA - for the leased mint on `take_lease`. On `return_lease` they also - fund the lessor's ATAs if these do not already exist. -- **Keeper** pays transaction fees for `liquidate`, and funds both - the lessor's and their own collateral ATAs on first use. They - get that back (and then some) via the bounty, so in practice the - cost is negligible. -- **Anyone** who calls `pay_rent` pays a transaction fee. Usually - this is the lessee or a keeper. - -### 7.7 Bounty and max-margin caps +**Token movements:** -```rust -pub const MAX_MAINTENANCE_MARGIN_BPS: u16 = 50_000; // 500% -pub const MAX_LIQUIDATION_BOUNTY_BPS: u16 = 2_000; // 20% +For a `Listed` cancel: +``` + leased_vault PDA --[leased_amount of leased_mint]--> lessor_leased_account + collateral_vault PDA is empty (0 transferred) ``` -These exist to prevent obvious griefing: +For an `Active` default: +``` + leased_vault PDA is empty (lessee kept the tokens) + collateral_vault PDA --[collateral_amount of collateral_mint]--> lessor_collateral_account +``` -- A lessor cannot set a 10,000% margin and immediately liquidate - their own lessee on day one. -- A lessor cannot set a 99% bounty that would funnel the lessee's - entire collateral to a friendly keeper, netting Alice zero and - pocketing the difference out of band. The 20% cap keeps the - majority of the collateral with the actual victim (the lessor). +In both cases both vaults are then closed and the `Lease` account is +closed; all three rent-exempt lamport refunds go to the lessor. -### 7.8 Anyone can call `pay_rent`, on purpose +**State changes before close:** -This is deliberate. If only the lessee could pay rent, a lazy (or -absent) lessee could let rent pile up implicitly until they are -liquidated — and the *accrued but unpaid* rent would still be -unpaid in the rent cap path. By letting keepers call `pay_rent` -before they call `liquidate`, they ensure the lessor has received -the owed rent first, regardless of the lessee's participation. +- If `Active`: `lease.last_rent_paid_ts = now.min(end_ts)` + (settles the accounting so any future program version that wants + to split the default pot differently has a correct timestamp to + start from) +- `lease.collateral_amount = 0` +- `lease.status = Closed` --- -## 8. How to build and test +## 5. Full-lifecycle worked examples + +All three use the same starting numbers so the arithmetic is easy to +follow. Both mints are 6-decimal SPL tokens. "LEASED" means one base +unit of the leased mint; "COLLA" means one base unit of the collateral +mint. + +- `leased_amount = 100_000_000` LEASED (100 tokens). +- `required_collateral_amount = 200_000_000` COLLA (200 tokens). +- `rent_per_second = 10` COLLA. +- `duration_seconds = 86_400` (24 hours). +- `maintenance_margin_bps = 12_000` (120%). +- `liquidation_bounty_bps = 500` (5% of post-rent collateral). +- `feed_id = [0xAB; 32]` (arbitrary, consistent across all calls). + +Lessor starts with 1 000 000 000 LEASED in their ATA. Lessee starts +with 1 000 000 000 COLLA in theirs. + +### 5.1 Happy path — lessee returns on time + +Calls, in order: + +1. **`create_lease`** — lessor posts 100 LEASED into `leased_vault`, + parameters written to `lease`. + ``` + lessor_leased_account --[100_000_000 LEASED]--> leased_vault PDA + ``` + Balances after: lessor has 900 000 000 LEASED, `leased_vault` has + 100 000 000 LEASED, `collateral_vault` has 0. + +2. **`take_lease`** — lessee posts 200 COLLA, receives 100 LEASED. + ``` + lessee_collateral_account --[200_000_000 COLLA]--> collateral_vault PDA + leased_vault PDA --[100_000_000 LEASED]--> lessee_leased_account + ``` + `lease.status = Active`, `start_ts = T`, `end_ts = T + 86_400`. + +3. **`pay_rent`** called at `T + 120` seconds. Rent due = 120 × 10 = + 1 200 COLLA. + ``` + collateral_vault PDA --[1_200 COLLA]--> lessor_collateral_account + ``` + `collateral_amount = 200_000_000 − 1_200 = 199_998_800`. + +4. **`top_up_collateral(amount = 50_000_000)`** at `T + 600`. Lessee + decides to add a cushion. + ``` + lessee_collateral_account --[50_000_000 COLLA]--> collateral_vault PDA + ``` + `collateral_amount = 199_998_800 + 50_000_000 = 249_998_800`. + +5. **`return_lease`** called at `T + 3_600` (one hour in). Total rent + from `start_ts` to `now` is 3 600 × 10 = 36 000 COLLA; 1 200 of that + was paid in step 3. Residual rent = 36 000 − 1 200 = 34 800 COLLA. + ``` + lessee_leased_account --[100_000_000 LEASED]--> leased_vault PDA + leased_vault PDA --[100_000_000 LEASED]--> lessor_leased_account + collateral_vault PDA --[34_800 COLLA]--------> lessor_collateral_account + collateral_vault PDA --[249_964_000 COLLA]---> lessee_collateral_account + ``` + Where `249_964_000 = 249_998_800 − 34_800`. + + Both vaults close, their rent-exempt lamports go to the lessor. The + `Lease` account closes via `close = lessor`. + +**Final balances:** + +- Lessor: 1 000 000 000 LEASED (full return), 36 000 COLLA (total rent + received in steps 3 + 5), plus the lamports from three account closes. +- Lessee: 100 000 000 LEASED → 0 (all returned), COLLA: started with + 1 000 000 000, spent 200 000 000 on initial deposit + 50 000 000 on + top-up, got back 249 964 000, so holds 999 964 000 COLLA (net cost + of 36 000 — exactly the total rent paid). + +### 5.2 Liquidation path + +Same setup. Steps 1 and 2 run identically. + +3. Time jumps to `T + 300`. A keeper observes a new Pyth price update: + the leased-in-collateral price has spiked to 4.0 (exponent 0, price + = 4). At that price, the debt value is `100_000_000 × 4 = + 400_000_000` COLLA. The collateral is still ~`200_000_000` COLLA + (minus some streamed rent). Maintenance ratio = `200/400 = 50%`, + well below the required 120%. + + The keeper calls `pay_rent` first is *not* required — `liquidate` + settles accrued rent itself. It goes straight to `liquidate`. + +4. **`liquidate`** at `T + 300`: + - Rent due = 300 × 10 = 3 000 COLLA; collateral_amount = 200 000 000 + so `rent_payable = 3 000`. + ``` + collateral_vault PDA --[3_000 COLLA]--> lessor_collateral_account + ``` + - Remaining = 200 000 000 − 3 000 = 199 997 000 COLLA. + - Bounty = 199 997 000 × 500 / 10 000 = 9 999 850 COLLA. + ``` + collateral_vault PDA --[9_999_850 COLLA]--> keeper_collateral_account + ``` + - Lessor share = 199 997 000 − 9 999 850 = 189 997 150 COLLA. + ``` + collateral_vault PDA --[189_997_150 COLLA]--> lessor_collateral_account + ``` + - Both vaults close; Lease closes. Status recorded as `Liquidated`. + +**Final balances:** + +- Lessor: 900 000 000 LEASED (never got the 100 back — the lessee kept + them), `3 000 + 189 997 150 = 190 000 150` COLLA, plus rent-exempt + lamports from three closes. +- Lessee: *still* has 100 000 000 LEASED. Spent 200 000 000 COLLA on + deposit, got nothing back. Net: they walk away with the leased tokens + but forfeited the entire collateral minus the keeper's cut. +- Keeper: 9 999 850 COLLA for their trouble. + +(This is the key asymmetry: liquidation does *not* reclaim the leased +tokens. The collateral pays the lessor for the lost asset. The lessee +has effectively bought the leased tokens at the forfeit price.) + +### 5.3 Default / expiry path — `close_expired` on an `Active` lease + +Same setup. Steps 1 and 2 run as usual. The lessee takes the tokens, +posts collateral, then disappears. + +3. `pay_rent` is never called. Clock advances all the way past + `end_ts = T + 86_400`. + +4. **`close_expired`** called by the lessor at `T + 100_000`: + - `status == Active` and `now >= end_ts` → the default branch runs. + - `leased_vault` is empty (lessee kept the tokens). No transfer. + - `collateral_vault` has 200 000 000 COLLA. All of it goes to the + lessor: + ``` + collateral_vault PDA --[200_000_000 COLLA]--> lessor_collateral_account + ``` + - Both vaults close; Lease closes. + - `last_rent_paid_ts = min(now, end_ts) = end_ts` (step added in + Fix 5). + +**Final balances:** + +- Lessor: 900 000 000 LEASED, 200 000 000 COLLA (the whole collateral + as compensation), plus three account-close refunds. +- Lessee: 100 000 000 LEASED, −200 000 000 COLLA. They paid the whole + collateral and kept the leased tokens. + +### 5.4 Default / expiry path — `close_expired` on a `Listed` lease + +This is the cheap cancel path. No lessee ever showed up. + +1. `create_lease` as above. +2. `close_expired` called by the lessor immediately. + - `status == Listed` → no expiry check. + - `leased_vault` holds 100 000 000 LEASED. Drain back: + ``` + leased_vault PDA --[100_000_000 LEASED]--> lessor_leased_account + ``` + - `collateral_vault` is empty. No transfer. + - Both vaults close; Lease closes. + +**Final balances:** lessor is back to 1 000 000 000 LEASED; nothing +else moved. -You need a working Solana + Anchor toolchain (`anchor --version`, -`solana --version` should both return something sensible). This -example was written against Anchor 1.0. +--- -```bash -cd defi/asset-leasing/anchor -anchor build -cargo test -``` +## 6. Safety and edge cases + +### 6.1 What the program refuses to do + +All of the following come from [`errors.rs`](programs/asset-leasing/src/errors.rs) +and are enforced by either an Anchor constraint or a `require!` in the +handler: + +| Error | When | +|---|---| +| `InvalidLeaseStatus` | Action tried against a lease in the wrong state (e.g. `take_lease` on a lease that is already `Active`) | +| `InvalidDuration` | `duration_seconds <= 0` on `create_lease` | +| `InvalidLeasedAmount` | `leased_amount == 0` on `create_lease` | +| `InvalidCollateralAmount` | `required_collateral_amount == 0` on `create_lease`; `amount == 0` on `top_up_collateral` | +| `InvalidRentPerSecond` | `rent_per_second == 0` on `create_lease` | +| `InvalidMaintenanceMargin` | `maintenance_margin_bps == 0` or `> 50_000` on `create_lease` | +| `InvalidLiquidationBounty` | `liquidation_bounty_bps > 2_000` on `create_lease` | +| `LeaseExpired` | Reserved; not currently used (rent accrual naturally caps at `end_ts`) | +| `LeaseNotExpired` | `close_expired` called on an `Active` lease before `end_ts` | +| `PositionHealthy` | `liquidate` called on a lease that passes the maintenance-margin check | +| `StalePrice` | Pyth price update older than 60 s, or has a future `publish_time`, or fails discriminator / length check | +| `NonPositivePrice` | Pyth price is `<= 0` | +| `MathOverflow` | Any of the `checked_*` arithmetic returned `None` | +| `Unauthorised` | Lease-modifying instruction called by someone who is not the registered lessee (`top_up_collateral`, `return_lease`) | +| `LeasedMintEqualsCollateralMint` | `create_lease` called with the same mint for both sides | +| `PriceFeedMismatch` | `liquidate` called with a Pyth update whose `feed_id` does not match `lease.feed_id` | + +### 6.2 Guarded design choices worth knowing + +- **Leased tokens are locked up-front.** `create_lease` moves the tokens + into the `leased_vault` immediately, so a lessee calling `take_lease` + cannot fail because the lessor spent the funds elsewhere in the + meantime. + +- **Leased mint ≠ collateral mint.** If both sides used the same SPL + mint, the two vaults would hold the same asset and the + "what-do-I-owe-vs-what-do-I-hold" accounting would collapse. The + guard is cheap and the error message is explicit. + +- **Feed pinning.** The Pyth `feed_id` is stored on the `Lease` at + creation and enforced on every `liquidate`. A keeper cannot pass in a + random unrelated price feed (like a volatile pair that happens to be + dipping) to force a spurious liquidation. + +- **Staleness window.** Pyth `publish_time` older than 60 seconds is + rejected, and `publish_time > now` is rejected too (keepers must not + front-run the validator clock). + +- **Integer-only math.** Every percentage and price calculation folds + into a `checked_mul` / `checked_div` of `u128` — no floats, no + surprising NaN. `BPS_DENOMINATOR = 10 000` is the only + "percentage denominator" anywhere; cross-check against `constants.rs` + if you're porting the math. + +- **Authority-is-self vaults.** `leased_vault.authority == + leased_vault.key()` (and likewise for `collateral_vault`). The + program signs as the vault using its own seeds, which means the + `Lease` account is not involved in signing any of the token moves. + This keeps the signer-seed array small (one seed list, not two). + +- **Max maintenance margin = 500%.** Without an upper bound a lessor + could set a margin that is unreachable on day one and liquidate the + lessee instantly. 50 000 bps is generous — enough for truly + speculative leases — while still blocking the pathological 10 000× + trap. + +- **Max liquidation bounty = 20%.** Higher than 20% and the keeper's + cut would dwarf the lessor's recovery on default. The cap keeps + liquidation economics roughly in line with lender-first semantics. + +### 6.3 Things the program does *not* guard against + +A production lease protocol would want more, but this is an example: + +- **Price feed correctness.** The program verifies the owner + (`PYTH_RECEIVER_PROGRAM_ID`), the discriminator, the layout and the + feed id, but it cannot know whether the feed the lessor pinned + quotes the right pair. Supplying the wrong feed at creation is the + lessor's problem — it won't cause a liquidation to succeed against a + truly healthy position (the feed id check would fail), but it will + mean *no* liquidation can succeed, so a lessee could drain the + collateral via rent and walk away. A production version would cross- + check the price feed's `feed_id` against a protocol registry. + +- **Rent dust accumulation.** Rent is paid in whole base units per + second of `rent_per_second`. Choose a small `rent_per_second` and + short-lived leases can settle 0 rent if no-one calls `pay_rent` for + a very short period. Not a security issue — the accrual ts only + moves forward when rent is actually settled — but worth knowing. + +- **Griefing on `init_if_needed`.** `take_lease`, `pay_rent`, + `liquidate`, `return_lease` and `close_expired` all do + `init_if_needed` on one or more ATAs. If the caller does not fund + the rent-exempt reserve for those accounts, the transaction fails. + This is the intended behaviour (the caller pays for the state they + require) but can surprise a lessee on a tight SOL budget. + +- **No partial rent refund on default.** When `close_expired` runs on + an `Active` lease, the lessor takes the entire collateral regardless + of how much rent had actually accrued by then. This is a deliberate + simplification — the `last_rent_paid_ts` bookkeeping in Fix 5 is in + place precisely so a future version can split the pot correctly. + +- **No pause / upgrade authority.** The program has no admin and no + upgrade authority-bound feature flags. It runs or it doesn't. -### What `anchor build` does +--- -Compiles the on-chain program (`programs/asset-leasing`) down to a -Berkeley Packet Filter `.so` binary under `target/deploy/`. Generates -an IDL (interface description) plus Rust types the tests then use. +## 7. Running the tests -You have to run `anchor build` **before** `cargo test` — the tests -`include_bytes!` the compiled program so they can load it into -LiteSVM. If the `.so` does not exist, the tests won't compile. +All the tests are LiteSVM-based Rust integration tests under +[`programs/asset-leasing/tests/`](programs/asset-leasing/tests/). They +exercise every instruction through `include_bytes!("../../../target/deploy/asset_leasing.so")`, +so a fresh build must produce the `.so` first. -### What `cargo test` does (and what LiteSVM is) +### Prerequisites -The tests live in `programs/asset-leasing/tests/test_asset_leasing.rs`. -They use **LiteSVM**, an in-memory Solana runtime. Think of it as a -"tiny local Solana" you spin up in milliseconds. No validator -process, no ledger on disk, no RPC round-trips. Each test gets a -fresh VM, mints its own tokens, deploys the program from the -`.so`, and runs a scenario. +- Anchor 1.0.0 (`anchor --version`) +- Solana CLI (`solana -V`) +- Rust stable (the `rust-toolchain.toml` at the repo root pins the + compiler) -LiteSVM is fast enough that you can test the full lifecycle — -`create → take → pay_rent → liquidate` — in a single `#[test]`, -advancing the clock sysvar by hand with `advance_clock_by`. The -existing tests cover: +### Commands -| Test | Exercises | -| --- | --- | -| `create_lease_locks_tokens_and_lists` | Lessor funds the leased vault, lease account is created. | -| `take_lease_posts_collateral_and_delivers_tokens` | Collateral flows in, leased tokens flow out. | -| `pay_rent_streams_collateral_by_elapsed_time` | Rent math: elapsed × rate. | -| `top_up_collateral_increases_vault_balance` | Top-up actually increases the vault. | -| `return_lease_refunds_unused_collateral` | Happy path: rent paid, refund issued, accounts closed. | -| `liquidate_seizes_collateral_on_price_drop` | Mocked `PriceUpdateV2` that makes the position underwater. | -| `liquidate_rejects_healthy_position` | Fails with `PositionHealthy` when it would be wrong. | -| `close_expired_reclaims_collateral_after_end_ts` | Default recovery after `end_ts`. | -| `close_expired_cancels_listed_lease` | Lessor cancels an unclaimed listing. | - -The tests do not pull in the real Pyth SDK — they synthesise a -`PriceUpdateV2` body with the right discriminator and offsets, set -the owner to the Pyth receiver program id, and install it into the -LiteSVM account store. This is the whole reason the program decodes -by hand rather than through an SDK — it lets the tests mock oracle -data without a network. +From this directory (`defi/asset-leasing/anchor/`): ---- +```bash +# 1. Build the BPF .so — writes to target/deploy/asset_leasing.so +anchor build -## 9. Extending this example +# 2. Run the LiteSVM tests (just cargo under the hood; `anchor test` +# also works because Anchor.toml scripts.test = "cargo test") +cargo test --manifest-path programs/asset-leasing/Cargo.toml -Real systems always need more features. Here are ideas a learner -can sink their teeth into, ordered roughly by difficulty. +# Or, equivalently: +anchor test --skip-local-validator +``` -### Easy +Expected output: -- **Add `require!` guards for bad combinations.** e.g. reject - `create_lease` when `leased_mint == collateral_mint`, or when - `rent_per_second × duration_seconds` would overflow a `u64`. -- **Emit events.** Add Anchor `#[event]`s on lease creation, - take-up, liquidation — a UI can then subscribe without polling. - -### Medium - -- **Variable rent based on utilisation.** Instead of a fixed - `rent_per_second`, let the protocol compute rent from a curve: - e.g. more expensive when most of a lessor's inventory is - currently leased. This mimics how lending protocols price - borrow rates. -- **Whitelisted lessees.** A `whitelist` account that maps - `(lessor, lessee) -> allowed`. `take_lease` requires the entry - to exist. Basis for a KYC-gated product. -- **Protocol fee.** A small cut of every `pay_rent` goes to a - treasury account the program derives. Same shape as the keeper - bounty, but with a different destination. -- **Partial returns.** Allow the lessee to return part of the - leased tokens and get a proportional share of collateral back. - Tricky: you need to also decide whether the rent rate scales - with the reduced amount. +``` +running 11 tests +test close_expired_cancels_listed_lease ... ok +test close_expired_reclaims_collateral_after_end_ts ... ok +test create_lease_locks_tokens_and_lists ... ok +test create_lease_rejects_same_mint_for_leased_and_collateral ... ok +test liquidate_rejects_healthy_position ... ok +test liquidate_rejects_mismatched_price_feed ... ok +test liquidate_seizes_collateral_on_price_drop ... ok +test pay_rent_streams_collateral_by_elapsed_time ... ok +test return_lease_refunds_unused_collateral ... ok +test take_lease_posts_collateral_and_delivers_tokens ... ok +test top_up_collateral_increases_vault_balance ... ok +``` -### Harder +### What each test exercises -- **Multi-token collateral.** The lessee posts a basket (e.g. 40% - SOL, 60% USDC) instead of a single mint. `is_underwater` now - sums each bucket's value using its own Pyth feed. -- **Pin a Pyth feed to a lease.** Instead of trusting the keeper to - supply a correct feed, store a `price_feed_id` on the `Lease` - and require the `PriceUpdateV2`'s `feed_id` field to match. Closes - the "wrong feed" loophole. -- **Dutch auction liquidation.** Instead of a fixed bounty, - the liquidation price starts steep and decays over time. Whoever - is willing to pay the least takes the trade. Better price - discovery, more complex bookkeeping. - -Pick one, try it, and run the existing tests plus a new one for -your feature. That is the honest way to learn this stuff. +| Test | Exercises | +|---|---| +| `create_lease_locks_tokens_and_lists` | Lessor funds vault, `Lease` created, collateral vault empty | +| `create_lease_rejects_same_mint_for_leased_and_collateral` | Guard against `leased_mint == collateral_mint` | +| `take_lease_posts_collateral_and_delivers_tokens` | Collateral deposit + leased-token payout in one ix | +| `pay_rent_streams_collateral_by_elapsed_time` | Rent math: `elapsed * rent_per_second`, rent transferred to lessor | +| `top_up_collateral_increases_vault_balance` | Collateral balance after `top_up` equals deposit + top-up | +| `return_lease_refunds_unused_collateral` | Happy path round-trip — leased tokens returned, residual collateral refunded, accounts closed | +| `liquidate_seizes_collateral_on_price_drop` | Price-induced underwater position → rent + bounty + lessor share paid, accounts closed | +| `liquidate_rejects_healthy_position` | Program refuses to liquidate a position that passes the margin check | +| `liquidate_rejects_mismatched_price_feed` | Program refuses a `PriceUpdateV2` whose `feed_id` ≠ `lease.feed_id` | +| `close_expired_reclaims_collateral_after_end_ts` | Default path — lessor seizes the collateral | +| `close_expired_cancels_listed_lease` | Lessor-initiated cancel of an unrented lease | + +### Note on CI + +The repo's `.github/workflows/anchor.yml` runs `anchor build` before +`anchor test` for every changed anchor project. That's important for +this project: the Rust integration tests include the BPF artefact via +`include_bytes!`, so a stale or missing `.so` would break the tests. +CI is already covered. --- -## 10. Further reading - -### Solana + Anchor +## 8. Extending the program -- [Anchor Book](https://www.anchor-lang.com/) — the official guide, - especially the chapters on PDAs, CPIs and account constraints. -- [Anchor 1.0 release notes](https://github.com/coral-xyz/anchor/releases) - — what changed versus 0.30. -- [SPL Token program docs](https://spl.solana.com/token) — the - token mint / account / transfer model this example builds on. -- [Solana Cookbook — PDAs](https://solanacookbook.com/core-concepts/pdas.html) - — if §2.9 felt too short, start here. +A few directions that are genuinely educational rather than cargo-cult +extensions: -### Pyth +### Easy -- [Pyth Network docs](https://docs.pyth.network/) — what a price - feed is, what publishers look like. -- [Pyth Solana Receiver](https://docs.pyth.network/price-feeds/use-real-time-data/solana) - — how the on-chain `PriceUpdateV2` accounts are produced. -- [Anchor / Pyth borsh conflict tracking issue](https://github.com/pyth-network/pyth-crosschain/issues) - — watch this if you want to eventually drop the manual decode. +- **Add a `lease_view` read-only helper.** An off-chain indexer-style + struct that returns `{ collateral_value, debt_value, ratio_bps, + is_underwater }` given the same inputs `is_underwater` uses. Useful + for UIs that want to show "you are 15% away from liquidation". + +- **Cap rent at collateral.** Currently `pay_rent` pays `min(rent_due, + collateral_amount)` and silently leaves a debt. Add an explicit + `RentDebtOutstanding` error so the caller is warned when the stream + has stalled, rather than inferring it from a non-zero `rent_due` + after settlement. + +### Moderate + +- **Partial-refund default.** In `close_expired` on `Active`, instead + of giving the lessor the entire collateral, split it: + `rent_due` to the lessor, the rest stays with the lessee up to some + `default_haircut_bps`. `last_rent_paid_ts` is already bumped by + Fix 5, so the timestamp invariants are ready. + +- **Multiple outstanding leases per `(lessor, lessee)` pair with the + same mint pair.** Already supported via `lease_id`, but add an + instruction-level index account that lists open lease ids for a + given lessor so off-chain tools don't have to `getProgramAccounts` + scan. + +- **Quote asset ≠ collateral mint.** Rent and liquidation math assume + debt is priced in *collateral units*. Generalise to a third "quote" + mint by taking the price pair at creation and carrying a + `quote_mint` pubkey on `Lease`. Requires updates to + `is_underwater` and a second oracle feed. -### Sibling examples in this repo +### Harder -- [`tokens/escrow/anchor`](../../../tokens/escrow/anchor) — the - simplest "lock tokens until a condition is met" Anchor program. - Good warm-up if the PDA / vault pattern here felt new. -- [`defi/clob/anchor`](../../clob/anchor) — an on-chain central - limit order book. Order matching instead of lease lifecycle. -- Hunt around in `defi/` and `tokens/` — each folder's README is - a little self-contained tutorial. +- **Keeper auction.** Replace the fixed `liquidation_bounty_bps` with a + Dutch auction that grows the bounty linearly over some window after + the position first becomes underwater. Keeps liquidators honest on + tight feeds and gives lessees a chance to `top_up_collateral` before + a keeper has an economic reason to move. -### Finance concepts, for the curious +- **Flash liquidation.** Let the keeper settle the debt in the same + transaction as the liquidation — borrow the leased amount from a + separate liquidity pool, hand it to the lessor, take the full + collateral, repay the pool, keep the spread. Requires integrating a + second program. -- Investopedia on **maintenance margin**, **basis points** and - **liquidation** — plain English definitions. -- Aave / Compound technical papers — production lending protocols - use the same collateral-and-liquidation vocabulary. Reading - theirs after this will feel familiar. +- **Token-2022 support.** The program already uses the `TokenInterface` + trait so it accepts both SPL Token and Token-2022 mints. A real + extension would test against Token-2022 mint extensions + (transfer-fee, interest-bearing) and document which are compatible + with the rent / collateral flows. --- -_This README is a teaching document. If any claim about the program -contradicts what the code actually does, file an issue — the code is -the source of truth._ +## Code layout + +``` +defi/asset-leasing/anchor/ +├── Anchor.toml +├── Cargo.toml +├── README.md (this file) +└── programs/asset-leasing/ + ├── Cargo.toml + ├── src/ + │ ├── constants.rs PDA seeds, bps limits, Pyth age cap + │ ├── errors.rs + │ ├── lib.rs #[program] entry points + │ ├── instructions/ + │ │ ├── mod.rs + │ │ ├── shared.rs transfer / close helpers + │ │ ├── create_lease.rs + │ │ ├── take_lease.rs + │ │ ├── pay_rent.rs + │ │ ├── top_up_collateral.rs + │ │ ├── return_lease.rs + │ │ ├── liquidate.rs + │ │ └── close_expired.rs + │ └── state/ + │ ├── mod.rs + │ └── lease.rs + └── tests/ + └── test_asset_leasing.rs LiteSVM tests +``` From d2e0d4dd12542575de52f601202598a6e2478bfd Mon Sep 17 00:00:00 2001 From: "Edward (Mike's AI sidekick)" Date: Tue, 21 Apr 2026 22:50:40 +0000 Subject: [PATCH 08/41] feat(asset-leasing): add Quasar port and apply Mike's README feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why add a Quasar port: Every other example in this repo has a Quasar sibling. Asset-leasing shipped as Anchor-only, breaking that parity. The Quasar port covers all seven instruction handlers (create_lease, take_lease, pay_rent, top_up_collateral, return_lease, liquidate, close_expired) and all eleven LiteSVM tests from the Anchor version, using the same Pyth PriceUpdateV2 layout and the same two-mint vault design. Why rewrite the README: - "Token" not "SPL Token". Tokens are the default; no qualifier needed unless contrasting with native SOL. The old phrasing treated "SPL Token" as if it were a distinct product rather than the norm. - "Instruction handler" not "instruction" when referring to the Rust function that processes the call. An *instruction* is the INPUT to a program (transaction call data); the *instruction handler* is the code. Conflating them confuses readers who are learning how the runtime dispatches work. - Dropped the Glossary. Solana already defines lamports, signers, accounts, PDAs, CPIs, etc. at https://solana.com/docs/terminology. Redefining them here is both redundant and drifts over time. The README now links there once and inline-defines only genuinely project-specific terms (maintenance margin, liquidation bounty, keeper, rent-the-stream vs rent-the-account). - Removed the Ethereum reference ("Solana's equivalent of an ERC-20"). Readers cannot be assumed to know Ethereum, and Solana is explainable on its own terms. - Rewrote the "tradfi picture" analogies. Car rentals and pawn shops are not finance — nobody at Hertz or a pawn shop says they work in finance. Replaced with real financial-markets analogies: leasing gold bars from a bullion dealer, and securities lending (borrowing stock to short). These are the actual tradfi patterns this program models. - Added a Quasar port section documenting how to build and test the new port alongside Anchor. Why the code comment sweep: Same terminology fixes applied in-code — "SPL token"/"SPL mint"/ "SPL vault" → "token"/"mint"/"vault" in doc comments across constants.rs, create_lease.rs, shared.rs, and the test file. No function, file, or struct names changed. Local validation: - Quasar: cargo test --release → 12/12 green (11 ported + test_id). - Anchor: build succeeds with --ignore-keys; the pre-existing duplicate-entrypoint linker error on the local host is unrelated to this change (present on 04367b8a before any edits). CI builds both sides cleanly. --- defi/asset-leasing/anchor/README.md | 427 ++++---- .../programs/asset-leasing/src/constants.rs | 6 +- .../src/instructions/create_lease.rs | 2 +- .../asset-leasing/src/instructions/shared.rs | 8 +- .../asset-leasing/tests/test_asset_leasing.rs | 2 +- defi/asset-leasing/quasar/Cargo.toml | 36 + defi/asset-leasing/quasar/Quasar.toml | 22 + defi/asset-leasing/quasar/src/constants.rs | 28 + defi/asset-leasing/quasar/src/errors.rs | 26 + .../quasar/src/instructions/close_expired.rs | 156 +++ .../quasar/src/instructions/create_lease.rs | 155 +++ .../quasar/src/instructions/liquidate.rs | 325 ++++++ .../quasar/src/instructions/mod.rs | 20 + .../quasar/src/instructions/pay_rent.rs | 115 +++ .../quasar/src/instructions/return_lease.rs | 177 ++++ .../quasar/src/instructions/take_lease.rs | 117 +++ .../src/instructions/top_up_collateral.rs | 75 ++ defi/asset-leasing/quasar/src/lib.rs | 89 ++ defi/asset-leasing/quasar/src/state.rs | 88 ++ defi/asset-leasing/quasar/src/tests.rs | 936 ++++++++++++++++++ 20 files changed, 2568 insertions(+), 242 deletions(-) create mode 100644 defi/asset-leasing/quasar/Cargo.toml create mode 100644 defi/asset-leasing/quasar/Quasar.toml create mode 100644 defi/asset-leasing/quasar/src/constants.rs create mode 100644 defi/asset-leasing/quasar/src/errors.rs create mode 100644 defi/asset-leasing/quasar/src/instructions/close_expired.rs create mode 100644 defi/asset-leasing/quasar/src/instructions/create_lease.rs create mode 100644 defi/asset-leasing/quasar/src/instructions/liquidate.rs create mode 100644 defi/asset-leasing/quasar/src/instructions/mod.rs create mode 100644 defi/asset-leasing/quasar/src/instructions/pay_rent.rs create mode 100644 defi/asset-leasing/quasar/src/instructions/return_lease.rs create mode 100644 defi/asset-leasing/quasar/src/instructions/take_lease.rs create mode 100644 defi/asset-leasing/quasar/src/instructions/top_up_collateral.rs create mode 100644 defi/asset-leasing/quasar/src/lib.rs create mode 100644 defi/asset-leasing/quasar/src/state.rs create mode 100644 defi/asset-leasing/quasar/src/tests.rs diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 9db196880..8d8f349dd 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -1,44 +1,47 @@ # Asset Leasing -A fixed-term SPL-token lease on Solana, with a second-by-second rent -stream, a separate collateral deposit, and a Pyth-oracle-triggered -seizure path when the collateral is no longer worth enough. +A fixed-term token lease on Solana, with a second-by-second rent stream, +a separate collateral deposit, and a Pyth-oracle-triggered seizure path +when the collateral is no longer worth enough. This README is a teaching document. If you have never written a Solana program before and have no background in finance, you are the target -reader — every term that might be unfamiliar is explained the first time -it appears, and every instruction is walked through step by step with +reader — every instruction handler is walked through step by step with the exact token movements it causes. If you already know what collateral, a maintenance margin and an oracle -are, you can skip straight to the [Accounts and PDAs](#3-accounts-and-pdas) -or [Instruction lifecycle walkthrough](#4-instruction-lifecycle-walkthrough) +are, you can skip straight to the [Accounts and PDAs](#2-accounts-and-pdas) +or [Instruction handler lifecycle walkthrough](#3-instruction-handler-lifecycle-walkthrough) sections. +Solana terminology is defined at https://solana.com/docs/terminology. +Terms specific to this program are explained inline when they first +appear. + --- ## Table of contents 1. [What does this program do?](#1-what-does-this-program-do) -2. [Glossary](#2-glossary) -3. [Accounts and PDAs](#3-accounts-and-pdas) -4. [Instruction lifecycle walkthrough](#4-instruction-lifecycle-walkthrough) -5. [Full-lifecycle worked examples](#5-full-lifecycle-worked-examples) -6. [Safety and edge cases](#6-safety-and-edge-cases) -7. [Running the tests](#7-running-the-tests) +2. [Accounts and PDAs](#2-accounts-and-pdas) +3. [Instruction handler lifecycle walkthrough](#3-instruction-handler-lifecycle-walkthrough) +4. [Full-lifecycle worked examples](#4-full-lifecycle-worked-examples) +5. [Safety and edge cases](#5-safety-and-edge-cases) +6. [Running the tests](#6-running-the-tests) +7. [Quasar port](#7-quasar-port) 8. [Extending the program](#8-extending-the-program) --- ## 1. What does this program do? -Two users, a **lessor** and a **lessee**, want to swap SPL tokens +Two users, a **lessor** and a **lessee**, want to swap tokens temporarily: -- The lessor has some number of tokens of SPL mint **A** (call it the +- The lessor has some number of tokens of mint **A** (call it the "leased mint") they would like to hand over for a fixed period of time. -- The lessee has tokens of a different SPL mint **B** (the "collateral +- The lessee has tokens of a different mint **B** (the "collateral mint") they can lock up as a security deposit. The program acts as a neutral escrow. It: @@ -48,50 +51,68 @@ The program acts as a neutral escrow. It: 2. When a lessee calls `take_lease`, the program locks the lessee's B tokens as collateral and hands the A tokens to the lessee. 3. While the lease is live, a second-by-second **rent stream** pays the - lessor out of the collateral vault. + lessor out of the collateral vault. "Rent" here is the per-second + payment the lessee owes the lessor for use of the leased tokens; it + is unrelated to Solana account rent (the lamports deposit that keeps + an account alive). Same word, different meaning — context usually + makes the intent obvious, and where it doesn't the text says so. 4. If the price of A (measured in B) moves against the lessee far enough that the locked collateral is no longer enough to cover the cost of re-acquiring the leased tokens, anyone can call `liquidate` — the - collateral is seized, most of it goes to the lessor, a small - percentage goes to whoever called the liquidation. + collateral is seized, most of it goes to the lessor, and a small + percentage (the **liquidation bounty**) goes to whoever called it. + Such a caller is known as a **keeper** — a bot or anyone else who + watches the chain for positions that have gone underwater and earns + the bounty by cleaning them up. 5. If the lessee returns the full A amount before the deadline, they get back whatever collateral is left after rent. 6. If the lessee ghosts past the deadline without returning anything, the lessor calls `close_expired` and sweeps the collateral as compensation. +The trigger for step 4 is the **maintenance margin**: a ratio, +expressed in basis points (1 bp = 1/100 of a percent), of required +collateral value to debt value. `maintenance_margin_bps = 12_000` is +120%, meaning the collateral must stay worth at least 1.2× the leased +tokens. Drop below and the position becomes liquidatable. + Nothing mysterious: the program is a pair of vaults, a small piece of state that tracks how much rent has been paid, and an oracle check. It is written in Anchor. ### The tradfi picture, briefly -For readers who have never encountered a real-world leasing or margin -arrangement — two quick analogies. They are strictly optional; the -program is fully described above in Solana terms. - -- **Think of hiring a car.** You pay the rental firm a refundable - deposit and a daily fee. If you return the car on time and in one - piece, you get the deposit back. If you drive off and disappear, they - keep the deposit. Here the lessor is the rental firm, the lessee is - you, the leased tokens are the car, and the collateral is the - deposit. - -- **Think of a pawn shop loan.** You hand over something valuable - (collateral), you borrow something in return. If the value of what - you handed over drops — for example, if you pawned gold and the gold - price collapsed — the shop can sell your collateral before it's worth - less than they lent you. On Solana, a price oracle tells the program - when that moment has arrived, and `liquidate` does the selling. - -Neither analogy is exact (a car rental doesn't usually charge rent in -the same asset it took as a deposit, a pawn shop doesn't usually set a -hard deadline). The onchain mechanics are what matters below. +For readers who have never encountered a real-world margin or +securities-lending arrangement — two quick analogies from finance. +They are strictly optional; the program is fully described above in +Solana terms. + +- **Leasing gold bars from a bullion dealer.** The dealer hands over a + fixed amount of physical gold for a fixed period; the counterparty + pays a per-day leasing fee and posts cash collateral worth more than + the gold. If the gold price rises enough that the posted cash no + longer covers the value of the bars, the dealer can seize the cash + before the position goes further underwater. The leased tokens here + play the role of the gold; the collateral plays the role of the cash; + the oracle plays the role of a live gold price feed. + +- **Securities lending — borrowing stock to short.** A broker lends + shares (say, NVIDIA) to a short seller for a fee. The short seller + posts cash collateral worth more than the shares. If NVIDIA rallies, + the collateral ratio falls; if it falls far enough, the broker issues + a margin call and, if unmet, liquidates the position by buying back + the shares from the collateral. This program's `liquidate` + instruction handler is the on-chain equivalent of that forced + buy-back. + +Neither analogy is exact — real bullion leases and real securities +lending add features this example doesn't model (recall rights, rebate +rates, haircuts). The on-chain mechanics are what matters below. ### What this example is not - **It is not a deployed, audited production program.** Treat it as a - learning example. It makes simplifying choices (see §6) that a + learning example. It makes simplifying choices (see §5) that a production lease protocol would need to revisit. - **It does not pretend to match mainnet Pyth behaviour exactly.** The LiteSVM tests install a hand-rolled `PriceUpdateV2` account; on @@ -99,169 +120,7 @@ hard deadline). The onchain mechanics are what matters below. --- -## 2. Glossary - -Terms appearing anywhere below, explained in terms of what they are -mechanically. - -**Account** -: On Solana, every piece of state — a user wallet, a token balance, a -program's config — is an *account*. An account has an address (a -32-byte public key), a length, some lamports holding it rent-exempt, an -owner program (the only program that can mutate the bytes), and a -byte buffer (`data`). - -**Lamport** -: The smallest unit of SOL. 1 SOL = 10⁹ lamports. Accounts must hold -enough lamports to be "rent-exempt" for their size; the program -reimburses these lamports when it closes an account. - -**Signer** -: An account whose private key signed the transaction. Only signers can -authorise transfers out of accounts they own (including normal -wallets). The list of signers is attached to every transaction. - -**SPL token** -: Solana's equivalent of an ERC-20. An SPL *mint* account describes -the token (its supply, decimals, authority). Each user's balance of a -given mint lives in a separate *token account* owned by the SPL Token -program. - -**Token account** -: An account that holds a balance of a specific SPL mint, controlled by -an *authority* (usually a user's wallet pubkey, but can be a PDA). In -this program, the two vaults are token accounts whose authority is the -vault PDA itself. - -**Associated Token Account (ATA)** -: A conventional, deterministic token account address for a given -`(wallet, mint)` pair. Derived by the SPL Associated Token Account -program. When you send USDC to "someone's wallet", you really mean -their ATA for the USDC mint. The program creates lessor/lessee ATAs on -demand (`init_if_needed`) so callers don't have to pre-create them. - -**PDA (Program Derived Address)** -: A deterministic address derived from a list of "seeds" plus a -program id, via `Pubkey::find_program_address`. PDAs have no private -key. A program can *sign* as a PDA in a CPI by producing the seeds — -that's the only way to move tokens out of a PDA-owned vault. In this -program there are three PDAs per lease: the `Lease` state account, the -`leased_vault` token account, and the `collateral_vault` token -account. - -**Seeds** -: The byte strings that, together with the program id, deterministically -derive a PDA. For this program the seeds are `[b"lease", lessor, -lease_id]` for the state account and `[b"leased_vault", lease]` / -`[b"collateral_vault", lease]` for the vaults. - -**Bump** -: A one-byte offset that, together with the seeds, produces an address -that is *not* on the Ed25519 curve (i.e. has no corresponding private -key). `find_program_address` finds the highest bump that yields an -off-curve address. Stored on the `Lease` account so the program doesn't -have to recompute it every time it signs. - -**CPI (Cross-Program Invocation)** -: One program calling another within the same transaction. The SPL -Token program's `TransferChecked` and `CloseAccount` instructions are -the CPIs used here. - -**Anchor** -: A Rust framework for writing Solana programs. The `#[derive(Accounts)]` -macro generates the account-validation boilerplate — ownership checks, -signer checks, PDA derivation, constraint checks like `has_one` — from -struct definitions. The `#[account]` macro handles serialising program -state accounts with an 8-byte discriminator prefix so the program can -tell different account types apart. - -**Anchor constraint** -: An attribute on an account field in a `#[derive(Accounts)]` struct, -like `mut`, `seeds = [...]`, `has_one = lessor`, or -`constraint = lease.status == LeaseStatus::Active`. Each one expands -into a check that runs before the handler executes. If any check fails -the transaction is rejected. - -**Discriminator** -: The first 8 bytes of an Anchor account, equal to the first 8 bytes of -`sha256("account:")`. Anchor writes them at initialisation -and checks them on every deserialisation so one struct's bytes cannot -be mistaken for another's. - -**Rent (Solana)** -: The lamports deposit that keeps an account alive. Since it's always -paid up-front (rent-exempt), you can think of it as a refundable -security deposit from a payer. When an account is closed the lamports -are returned to whichever account is specified as `close = ...` in -Anchor. - -**Rent (this program)** -: The per-second payment the lessee owes the lessor for holding the -leased tokens. Measured in collateral-mint base units, streams from -the collateral vault to the lessor's collateral ATA on every -`pay_rent`. *Unrelated to Solana account rent* — same word, different -meaning. Context usually makes it obvious. - -**Vault** -: In this codebase, one of the two program-owned token accounts (leased -or collateral). Their authority is the PDA itself, so the program is -the only thing that can move funds out of them, and it does so by -producing the vault's PDA seeds when making the transfer CPI. - -**Basis point (bps)** -: 1/100 of a percent. 10 000 bps = 100%. Used here for the maintenance -margin and liquidation bounty. Integer-only bps arithmetic keeps all -percentage calculations free of floating-point error. - -**Maintenance margin** -: A ratio. The liquidation check asks: is the collateral's value (in -collateral-mint units) at least `maintenance_margin_bps / 10_000` -times the debt's value (the leased amount, priced into the same -units)? For `maintenance_margin_bps = 12_000` that is 120%. Drop below -and the position is liquidatable. This is the "how much cushion must -the lessee keep on top of the raw value of the leased asset". - -**Liquidation** -: The instruction (`liquidate`) that closes an underwater lease. Rent -is first paid from the collateral vault; then a percentage (the -*liquidation bounty*) of whatever collateral is left goes to the -keeper who called the instruction, and the remainder goes to the -lessor. Lease status becomes `Liquidated`. - -**Keeper** -: Any party — usually a bot — that calls a permissionless instruction -to keep the protocol healthy. Here the keeper calls `liquidate` when -they spot an underwater lease. They are paid the `liquidation_bounty` -for their trouble. - -**Oracle** -: An onchain account whose bytes are periodically updated with -information from the outside world — for this program, the current -price of the leased mint priced in units of the collateral mint. We -use Pyth's `PriceUpdateV2` accounts. - -**Pyth `PriceUpdateV2`** -: The Pyth receiver program owns a set of accounts, each with a fixed -layout: discriminator (8) + write_authority (32) + verification_level -(1) + `feed_id` (32) + price (i64, 8) + conf (u64, 8) + exponent -(i32, 4) + publish_time (i64, 8) + …. This program only reads -`feed_id`, `price`, `exponent` and `publish_time`. - -**Feed id** -: A 32-byte identifier for a specific Pyth price feed (e.g. -"BONK/USD"). Pinned on the `Lease` at creation so a keeper cannot swap -in a different feed during a liquidation call to force an underwater -verdict. - -**Exponent** -: Pyth prices are integer pairs `(price, exponent)`; the real price is -`price * 10^exponent`. For example `(12345, -2)` means 123.45. All of -this program's math is integer and folds the exponent into whichever -side of the inequality doesn't already have the denominator applied. - ---- - -## 3. Accounts and PDAs +## 2. Accounts and PDAs Every call to the program touches some subset of these accounts. The three PDAs are created on `create_lease` and destroyed on `return_lease` @@ -277,8 +136,8 @@ three PDAs are created on `create_lease` and destroyed on `return_lease` | Account | PDA? | Seeds | Kind | Authority | Holds | |---|---|---|---|---|---| -| `leased_vault` | yes | `["leased_vault", lease]` | SPL token account | itself (PDA-signed) | `leased_amount` while `Listed`; 0 while `Active` (lessee has the tokens); full amount again briefly inside `return_lease` | -| `collateral_vault` | yes | `["collateral_vault", lease]` | SPL token account | itself (PDA-signed) | 0 while `Listed`; `collateral_amount` while `Active`, decreasing as rent streams out and increasing on `top_up_collateral` | +| `leased_vault` | yes | `["leased_vault", lease]` | token account | itself (PDA-signed) | `leased_amount` while `Listed`; 0 while `Active` (lessee has the tokens); full amount again briefly inside `return_lease` | +| `collateral_vault` | yes | `["collateral_vault", lease]` | token account | itself (PDA-signed) | 0 while `Listed`; `collateral_amount` while `Active`, decreasing as rent streams out and increasing on `top_up_collateral` | ### User accounts passed in @@ -355,16 +214,19 @@ pub struct Lease { The `Closed` and `Liquidated` states are not directly observable onchain: all three of `return_lease`, `liquidate` and `close_expired` -close the `Lease` account in the same instruction (`close = lessor`), +close the `Lease` account in the same transaction (`close = lessor`), returning the rent-exempt lamports to the lessor. The in-memory `status` field is set *before* the close so the transaction logs record the terminal state, but the account disappears at the end. --- -## 4. Instruction lifecycle walkthrough +## 3. Instruction handler lifecycle walkthrough -The program has seven instructions. The natural order a user encounters +An *instruction* on Solana is the input sent in a transaction — a +program id, a list of accounts, and a byte payload. The Rust function +that runs when one arrives is the *instruction handler*. This program +has seven instruction handlers. The natural order a user encounters them — the order below — is: 1. `create_lease` (lessor) @@ -385,7 +247,7 @@ Token-flow diagrams use the following shorthand: --[amount of ]--> ``` -### 4.1 `create_lease` +### 3.1 `create_lease` **Who calls it:** the lessor. They want to offer some number of leased tokens for a fixed term against collateral of a different mint. @@ -452,7 +314,7 @@ lessee who calls `take_lease` cannot possibly fail because the lessor doesn't have the tokens any more — the atomicity guarantee is transferred to the PDA the moment the lease is listed. -### 4.2 `take_lease` +### 3.2 `take_lease` **Who calls it:** the lessee. They have seen the `Lease` account on chain (somehow — an indexer, a direct lookup, whatever) and want to @@ -500,7 +362,7 @@ collateral back. - `lease.last_rent_paid_ts = now` (nothing has accrued yet) - `lease.status = Active` -### 4.3 `pay_rent` +### 3.3 `pay_rent` **Who calls it:** anyone. The lessee's incentive is obvious (keep the lease from going underwater); a keeper bot may also push rent before a @@ -551,7 +413,7 @@ clean up. - `lease.collateral_amount -= payable` - `lease.last_rent_paid_ts = now.min(end_ts)` -### 4.4 `top_up_collateral` +### 3.4 `top_up_collateral` **Who calls it:** the lessee — to defend against a looming liquidation by adding more of the collateral mint to the vault. @@ -586,7 +448,7 @@ by adding more of the collateral mint to the vault. - `lease.collateral_amount += amount` (checked add) -### 4.5 `return_lease` +### 3.5 `return_lease` **Who calls it:** the lessee, while the lease is still `Active` and before or after `end_ts` (the only timing rule is that `status == @@ -641,7 +503,7 @@ After the transfers: - `lease.collateral_amount = 0` - `lease.status = Closed` -### 4.6 `liquidate` +### 3.6 `liquidate` **Who calls it:** a keeper, when they can prove the position is underwater. @@ -706,10 +568,10 @@ closed the same way (Anchor `close = lessor`). - `lease.last_rent_paid_ts = now.min(end_ts)` - `lease.status = Liquidated` -### 4.7 `close_expired` +### 3.7 `close_expired` **Who calls it:** the lessor. Two very different situations collapse -into this single instruction: +into this single handler: - **Cancel a `Listed` lease** — the lessor changes their mind, no-one has taken the lease yet. Allowed any time. @@ -763,7 +625,7 @@ closed; all three rent-exempt lamport refunds go to the lessor. --- -## 5. Full-lifecycle worked examples +## 4. Full-lifecycle worked examples All three use the same starting numbers so the arithmetic is easy to follow. Both mints are 6-decimal SPL tokens. "LEASED" means one base @@ -781,7 +643,7 @@ mint. Lessor starts with 1 000 000 000 LEASED in their ATA. Lessee starts with 1 000 000 000 COLLA in theirs. -### 5.1 Happy path — lessee returns on time +### 4.1 Happy path — lessee returns on time Calls, in order: @@ -837,7 +699,7 @@ Calls, in order: top-up, got back 249 964 000, so holds 999 964 000 COLLA (net cost of 36 000 — exactly the total rent paid). -### 5.2 Liquidation path +### 4.2 Liquidation path Same setup. Steps 1 and 2 run identically. @@ -882,7 +744,7 @@ Same setup. Steps 1 and 2 run identically. tokens. The collateral pays the lessor for the lost asset. The lessee has effectively bought the leased tokens at the forfeit price.) -### 5.3 Default / expiry path — `close_expired` on an `Active` lease +### 4.3 Default / expiry path — `close_expired` on an `Active` lease Same setup. Steps 1 and 2 run as usual. The lessee takes the tokens, posts collateral, then disappears. @@ -909,7 +771,7 @@ posts collateral, then disappears. - Lessee: 100 000 000 LEASED, −200 000 000 COLLA. They paid the whole collateral and kept the leased tokens. -### 5.4 Default / expiry path — `close_expired` on a `Listed` lease +### 4.4 Default / expiry path — `close_expired` on a `Listed` lease This is the cheap cancel path. No lessee ever showed up. @@ -928,9 +790,9 @@ else moved. --- -## 6. Safety and edge cases +## 5. Safety and edge cases -### 6.1 What the program refuses to do +### 5.1 What the program refuses to do All of the following come from [`errors.rs`](programs/asset-leasing/src/errors.rs) and are enforced by either an Anchor constraint or a `require!` in the @@ -951,11 +813,11 @@ handler: | `StalePrice` | Pyth price update older than 60 s, or has a future `publish_time`, or fails discriminator / length check | | `NonPositivePrice` | Pyth price is `<= 0` | | `MathOverflow` | Any of the `checked_*` arithmetic returned `None` | -| `Unauthorised` | Lease-modifying instruction called by someone who is not the registered lessee (`top_up_collateral`, `return_lease`) | +| `Unauthorised` | Lease-modifying handler called by someone who is not the registered lessee (`top_up_collateral`, `return_lease`) | | `LeasedMintEqualsCollateralMint` | `create_lease` called with the same mint for both sides | | `PriceFeedMismatch` | `liquidate` called with a Pyth update whose `feed_id` does not match `lease.feed_id` | -### 6.2 Guarded design choices worth knowing +### 5.2 Guarded design choices worth knowing - **Leased tokens are locked up-front.** `create_lease` moves the tokens into the `leased_vault` immediately, so a lessee calling `take_lease` @@ -998,7 +860,7 @@ handler: cut would dwarf the lessor's recovery on default. The cap keeps liquidation economics roughly in line with lender-first semantics. -### 6.3 Things the program does *not* guard against +### 5.3 Things the program does *not* guard against A production lease protocol would want more, but this is an example: @@ -1036,11 +898,11 @@ A production lease protocol would want more, but this is an example: --- -## 7. Running the tests +## 6. Running the tests All the tests are LiteSVM-based Rust integration tests under [`programs/asset-leasing/tests/`](programs/asset-leasing/tests/). They -exercise every instruction through `include_bytes!("../../../target/deploy/asset_leasing.so")`, +exercise every instruction handler through `include_bytes!("../../../target/deploy/asset_leasing.so")`, so a fresh build must produce the `.so` first. ### Prerequisites @@ -1109,6 +971,105 @@ CI is already covered. --- +## 7. Quasar port + +A parallel implementation of the same program using +[Quasar](https://github.com/blueshift-gg/quasar) lives in +[`../quasar/`](../quasar/). Quasar is a lightweight alternative to +Anchor that compiles to bare Solana program binaries without pulling in +`anchor-lang` — useful when you care about compute-unit budget, binary +size, or simply want fewer layers between your code and the runtime. + +The port implements the same seven instruction handlers, the same +`Lease` state account, the same PDA seed conventions, and produces the +same on-chain behaviour for every happy-path and adversarial test in +this README. + +### Building and testing + +From [`../quasar/`](../quasar/): + +```bash +# Build the .so using the quasar CLI. +quasar build + +# Run the LiteSVM-style tests directly with cargo. The tests call the +# compiled program from `target/deploy/quasar_asset_leasing.so`. +cargo test +``` + +The Quasar example in this repo's CI workflow +(`.github/workflows/quasar.yml`) runs exactly those two commands. + +### What differs from the Anchor version + +- **No Anchor account-validation macros.** In Quasar, account structs + use `#[derive(Accounts)]` with an almost-identical attribute + vocabulary (`seeds`, `bump`, `has_one`, `constraint`, + `init_if_needed`) but the checks are lowered to plain Rust, not + inserted by a procedural macro that calls into a support crate. + +- **Explicit instruction discriminators.** Each instruction handler + carries `#[instruction(discriminator = N)]` with `N` an explicit + integer — Quasar uses one-byte discriminators by default rather than + Anchor's 8-byte sha256 prefix. The wire format for every call is + `[discriminator: u8][borsh-serialised args]`. + +- **Tests talk to `QuasarSvm` directly.** Instead of the Anchor + `Instruction { ... }.data()` / `accounts::Foo { ... }.to_account_metas()` + helpers, the Quasar tests build each `Instruction` by hand with + `solana_instruction::AccountMeta` entries and a manually-assembled + byte payload. Account state is pre-populated on the SVM with + `QuasarSvm::new().with_program(...).with_token_program()` and + helpers from `quasar_svm::token` that synthesise `Mint` and + `TokenAccount` bytes without running the real token-program + initialisation instruction handlers. This keeps the tests fast but + means the setup code is more explicit. + +- **No cross-program-invocation into an associated-token-account + program for ATA creation.** The Anchor version uses `init_if_needed` + + `associated_token::...` to let callers pass in a lessor/lessee + wallet and get the token account created on demand. The Quasar port + accepts pre-created token accounts for the user side of every flow, + since doing `init_if_needed` correctly for ATAs in Quasar requires + wiring in the ATA program manually and adds noise that distracts + from the lease mechanics. Production code would want the ATA + convenience back. + +- **Classic Token only, not Token-2022.** The Anchor version declares + its token accounts as `InterfaceAccount` + `token_program: + Interface`, which accepts mints owned by either the + classic Token program or the Token-2022 program. The Quasar port + uses `Account` + `Program`, matching the simpler + pattern used by the other Quasar examples in this repo. Adding + Token-2022 support is a type-parameter swap away. + +- **State layout is the same, byte for byte.** The `Lease` discriminator + and field order match the Anchor version, so an off-chain indexer + that already decodes Anchor `Lease` accounts would also decode the + Quasar ones after adjusting for the one-byte discriminator. + +- **One lease per lessor at a time.** The Anchor version keys its + `Lease` PDA on `[LEASE_SEED, lessor, lease_id]` so one lessor can + run many leases in parallel. Quasar's `seeds = [...]` macro embeds + raw references into generated code and does not (yet) have a + borrow-safe way to splice instruction args like + `lease_id.to_le_bytes()` into the seed list, so the Quasar port + keys its PDA on `[LEASE_SEED, lessor]` alone — one active lease per + lessor. The `lease_id` is still stored on the `Lease` account for + book-keeping and is a caller-supplied u64 in `create_lease`; the + off-chain client just has to ensure the previous lease from the same + lessor is `Closed` or `Liquidated` (i.e. its PDA account is gone) + before creating a new one. Swapping in a multi-lease seed is a + mechanical change once Quasar grows support for dynamic-byte seeds. + +The code layout mirrors this directory: `src/lib.rs` registers the +entrypoint and re-exports handlers, `src/state.rs` defines `Lease` and +`LeaseStatus`, and `src/instructions/*.rs` contains one file per +handler. Tests are in `src/tests.rs`. + +--- + ## 8. Extending the program A few directions that are genuinely educational rather than cargo-cult @@ -1162,10 +1123,10 @@ extensions: second program. - **Token-2022 support.** The program already uses the `TokenInterface` - trait so it accepts both SPL Token and Token-2022 mints. A real - extension would test against Token-2022 mint extensions - (transfer-fee, interest-bearing) and document which are compatible - with the rent / collateral flows. + trait so it accepts mints owned by either the classic Token program + or the Token-2022 program. A real extension would test against + Token-2022 mint extensions (transfer-fee, interest-bearing) and + document which are compatible with the rent / collateral flows. --- diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs index 20a3c30f4..406c15cac 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs @@ -2,11 +2,11 @@ /// u64 `lease_id` so one lessor can run many leases in parallel. pub const LEASE_SEED: &[u8] = b"lease"; -/// PDA seed for the SPL vault that holds the leased tokens while the lease is -/// `Listed` and that accepts returned tokens on settlement. +/// PDA seed for the token vault that holds the leased tokens while the lease +/// is `Listed` and that accepts returned tokens on settlement. pub const LEASED_VAULT_SEED: &[u8] = b"leased_vault"; -/// PDA seed for the SPL vault that escrows the lessee's collateral for the +/// PDA seed for the token vault that escrows the lessee's collateral for the /// life of the lease. pub const COLLATERAL_VAULT_SEED: &[u8] = b"collateral_vault"; diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs index a5ddd52e1..577ab20da 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs @@ -81,7 +81,7 @@ pub fn handle_create_lease( liquidation_bounty_bps: u16, feed_id: [u8; 32], ) -> Result<()> { - // Reject leased_mint == collateral_mint. Allowing both to be the same SPL + // Reject leased_mint == collateral_mint. Allowing both to be the same // mint would collapse the two vaults' seed derivations into one shared // token-balance pool, making rent-vs-collateral accounting ambiguous and // enabling griefing paths where the lessee's "collateral" is the same diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs index 443e9ce11..e8c3893eb 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs @@ -4,7 +4,7 @@ use anchor_spl::token_interface::{ TransferChecked, }; -/// Transfer SPL tokens from a user-controlled account to a program-controlled +/// Transfer tokens from a user-controlled account to a program-controlled /// vault (or any other account the signer owns). Authority is a plain signer. pub fn transfer_tokens_from_user<'info>( from: &InterfaceAccount<'info, TokenAccount>, @@ -27,8 +27,8 @@ pub fn transfer_tokens_from_user<'info>( ) } -/// Transfer SPL tokens out of a PDA-owned vault using the supplied signer -/// seeds. Used by the program when moving tokens held under its authority. +/// Transfer tokens out of a PDA-owned vault using the supplied signer seeds. +/// Used by the program when moving tokens held under its authority. pub fn transfer_tokens_from_vault<'info>( from: &InterfaceAccount<'info, TokenAccount>, to: &InterfaceAccount<'info, TokenAccount>, @@ -51,7 +51,7 @@ pub fn transfer_tokens_from_vault<'info>( ) } -/// Close a PDA-owned SPL token vault and forward its rent-exempt lamports to +/// Close a PDA-owned token vault and forward its rent-exempt lamports to /// `destination`. The vault is its own token-account authority, so the caller /// just passes the same vault `AccountInfo` as both the account and the /// authority, with the vault's signer seeds for the CPI. diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs b/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs index bcfe9abee..afc27a511 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs @@ -965,7 +965,7 @@ fn close_expired_cancels_listed_lease() { #[test] fn create_lease_rejects_same_mint_for_leased_and_collateral() { - // Collapsing leased_mint and collateral_mint into a single SPL mint would + // Collapsing leased_mint and collateral_mint into a single mint would // also collapse the two vaults into one token-balance pool (same mint, // same authority seed pattern) and make rent-vs-collateral accounting // ambiguous. The program rejects this up-front with diff --git a/defi/asset-leasing/quasar/Cargo.toml b/defi/asset-leasing/quasar/Cargo.toml new file mode 100644 index 000000000..79ad3d9e4 --- /dev/null +++ b/defi/asset-leasing/quasar/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "quasar-asset-leasing" +version = "0.1.0" +edition = "2021" + +# Standalone workspace — not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree from the Anchor +# projects, so it must declare its own [workspace] root. +[workspace] + +[lints.rust.unexpected_cfgs] +level = "warn" +check-cfg = [ + 'cfg(target_os, values("solana"))', +] + +[lib] +# `cdylib` for the on-chain .so; `lib` so `cargo test` can link the Rust +# code as a regular library and exercise handlers against QuasarSvm. +crate-type = ["cdylib", "lib"] + +[features] +alloc = [] +client = [] +debug = [] + +[dependencies] +quasar-lang = "0.0" +quasar-spl = "0.0" +solana-address = { version = "2.2.0" } +solana-instruction = { version = "3.2.0" } + +[dev-dependencies] +quasar-svm = { version = "0.1" } +spl-token-interface = { version = "2.0.0" } +solana-program-pack = { version = "3.1.0" } diff --git a/defi/asset-leasing/quasar/Quasar.toml b/defi/asset-leasing/quasar/Quasar.toml new file mode 100644 index 000000000..41588a4f2 --- /dev/null +++ b/defi/asset-leasing/quasar/Quasar.toml @@ -0,0 +1,22 @@ +[project] +name = "quasar_asset_leasing" + +[toolchain] +type = "solana" + +[testing] +language = "rust" + +[testing.rust] +framework = "quasar-svm" + +[testing.rust.test] +program = "cargo" +args = [ + "test", + "tests::", +] + +[clients] +path = "target/client" +languages = ["rust"] diff --git a/defi/asset-leasing/quasar/src/constants.rs b/defi/asset-leasing/quasar/src/constants.rs new file mode 100644 index 000000000..3810ee493 --- /dev/null +++ b/defi/asset-leasing/quasar/src/constants.rs @@ -0,0 +1,28 @@ +/// PDA seed for the `Lease` account. Combined with the lessor pubkey and a +/// u64 `lease_id` so one lessor can run many leases in parallel. +pub const LEASE_SEED: &[u8] = b"lease"; + +/// PDA seed for the token vault that holds the leased tokens while the lease +/// is `Listed` and that accepts returned tokens on settlement. +pub const LEASED_VAULT_SEED: &[u8] = b"leased_vault"; + +/// PDA seed for the token vault that escrows the lessee's collateral for the +/// life of the lease. +pub const COLLATERAL_VAULT_SEED: &[u8] = b"collateral_vault"; + +/// Denominator for basis-point (bps) ratios used for the maintenance margin +/// and the liquidation bounty. 10_000 bps = 100%. +pub const BPS_DENOMINATOR: u64 = 10_000; + +/// Maximum allowed maintenance margin: 50_000 bps = 500%. Prevents the lessor +/// setting an impossible margin that would let them liquidate on day one. +pub const MAX_MAINTENANCE_MARGIN_BPS: u16 = 50_000; + +/// Maximum liquidation bounty the keeper can claim: 2_000 bps = 20%. Keeps +/// most of the collateral flowing to the lessor on default. +pub const MAX_LIQUIDATION_BOUNTY_BPS: u16 = 2_000; + +/// A Pyth price update is considered stale if its `publish_time` is older +/// than this many seconds versus the current on-chain clock. 60 s matches +/// the default staleness window used in the Pyth SDK docs. +pub const PYTH_MAX_AGE_SECONDS: u64 = 60; diff --git a/defi/asset-leasing/quasar/src/errors.rs b/defi/asset-leasing/quasar/src/errors.rs new file mode 100644 index 000000000..50b85be09 --- /dev/null +++ b/defi/asset-leasing/quasar/src/errors.rs @@ -0,0 +1,26 @@ +use quasar_lang::prelude::*; + +/// Program-specific errors. Codes start at 6000 (Quasar's default +/// `#[error_code]` offset, matching Anchor), so they never collide with +/// Solana's built-in `ProgramError` codes or the framework's +/// `QuasarError` codes. +#[error_code] +pub enum AssetLeasingError { + InvalidLeaseStatus, + InvalidDuration, + InvalidLeasedAmount, + InvalidCollateralAmount, + InvalidRentPerSecond, + InvalidMaintenanceMargin, + InvalidLiquidationBounty, + LeaseExpired, + LeaseNotExpired, + PositionHealthy, + StalePrice, + NonPositivePrice, + MathOverflow, + Unauthorised, + LeasedMintEqualsCollateralMint, + PriceFeedMismatch, + InvalidStatusByte, +} diff --git a/defi/asset-leasing/quasar/src/instructions/close_expired.rs b/defi/asset-leasing/quasar/src/instructions/close_expired.rs new file mode 100644 index 000000000..3f991e31d --- /dev/null +++ b/defi/asset-leasing/quasar/src/instructions/close_expired.rs @@ -0,0 +1,156 @@ +use { + crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, + errors::AssetLeasingError, + instructions::pay_rent::update_last_paid_ts, + state::{Lease, LeaseStatus}, + }, + quasar_lang::prelude::*, + quasar_spl::{Mint, Token, TokenCpi}, +}; + +/// Lessor-only recovery path. Two situations collapse into this handler: +/// +/// - The lease sat in `Listed` and the lessor wants to cancel it, +/// recovering the leased tokens they pre-funded. Allowed any time. +/// - The lease was `Active` but the lessee ghosted past `end_ts`. The +/// lessor takes the collateral as compensation and closes the books. +#[derive(Accounts)] +pub struct CloseExpired<'info> { + #[account(mut)] + pub lessor: &'info Signer, + + #[account( + mut, + seeds = [LEASE_SEED, lessor], + bump = lease.bump, + has_one = lessor, + has_one = leased_mint, + has_one = collateral_mint, + constraint = { + let s = LeaseStatus::from_u8(lease.status); + s == Some(LeaseStatus::Listed) || s == Some(LeaseStatus::Active) + } @ AssetLeasingError::InvalidLeaseStatus, + close = lessor, + )] + pub lease: &'info mut Account, + + pub leased_mint: &'info Account, + pub collateral_mint: &'info Account, + + #[account( + mut, + seeds = [LEASED_VAULT_SEED, lease], + bump = lease.leased_vault_bump, + )] + pub leased_vault: &'info mut Account, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease], + bump = lease.collateral_vault_bump, + )] + pub collateral_vault: &'info mut Account, + + #[account(mut)] + pub lessor_leased_account: &'info mut Account, + + #[account(mut)] + pub lessor_collateral_account: &'info mut Account, + + pub token_program: &'info Program, +} + +#[inline(always)] +pub fn handle_close_expired(accounts: &mut CloseExpired) -> Result<(), ProgramError> { + let now = ::get()?.unix_timestamp.get(); + let lease_address = *accounts.lease.address(); + let status = LeaseStatus::from_u8(accounts.lease.status) + .ok_or(AssetLeasingError::InvalidStatusByte)?; + + // Active leases can only be closed after they expire. Listed leases + // have no start/end so the check is skipped. + if status == LeaseStatus::Active { + let end_ts = accounts.lease.end_ts.get(); + if now < end_ts { + return Err(AssetLeasingError::LeaseNotExpired.into()); + } + } + + let leased_vault_bump = [accounts.lease.leased_vault_bump]; + let leased_vault_seeds: &[Seed] = &[ + Seed::from(LEASED_VAULT_SEED), + Seed::from(lease_address.as_ref()), + Seed::from(&leased_vault_bump as &[u8]), + ]; + let collateral_vault_bump = [accounts.lease.collateral_vault_bump]; + let collateral_vault_seeds: &[Seed] = &[ + Seed::from(COLLATERAL_VAULT_SEED), + Seed::from(lease_address.as_ref()), + Seed::from(&collateral_vault_bump as &[u8]), + ]; + + // Drain whatever is in the leased vault back to the lessor. For a + // Listed lease this is the full leased_amount; for a defaulted + // Active lease the vault is empty (the lessee never returned) so + // this is a no-op. + let leased_vault_balance = accounts.leased_vault.amount(); + if leased_vault_balance > 0 { + accounts + .token_program + .transfer( + accounts.leased_vault, + accounts.lessor_leased_account, + accounts.leased_vault, + leased_vault_balance, + ) + .invoke_signed(leased_vault_seeds)?; + } + + // Drain the collateral vault to the lessor. For a Listed lease this + // is 0. For a defaulted Active lease this is the lessee's forfeited + // collateral. + let collateral_vault_balance = accounts.collateral_vault.amount(); + if collateral_vault_balance > 0 { + accounts + .token_program + .transfer( + accounts.collateral_vault, + accounts.lessor_collateral_account, + accounts.collateral_vault, + collateral_vault_balance, + ) + .invoke_signed(collateral_vault_seeds)?; + } + + accounts + .token_program + .close_account( + accounts.leased_vault, + accounts.lessor, + accounts.leased_vault, + ) + .invoke_signed(leased_vault_seeds)?; + accounts + .token_program + .close_account( + accounts.collateral_vault, + accounts.lessor, + accounts.collateral_vault, + ) + .invoke_signed(collateral_vault_seeds)?; + + // Keep the rent-settlement invariant intact even on default: the + // lessor takes the whole collateral vault as compensation here, but + // any future version of the program that wants to split the + // collateral differently (pro-rata rent, partial refund on default) + // can read `last_rent_paid_ts` and trust that everything up to + // `now` is already settled. + if status == LeaseStatus::Active { + update_last_paid_ts(accounts.lease, now); + } + accounts.lease.collateral_amount = 0u64.into(); + accounts.lease.status = LeaseStatus::Closed as u8; + + Ok(()) +} diff --git a/defi/asset-leasing/quasar/src/instructions/create_lease.rs b/defi/asset-leasing/quasar/src/instructions/create_lease.rs new file mode 100644 index 000000000..cac299041 --- /dev/null +++ b/defi/asset-leasing/quasar/src/instructions/create_lease.rs @@ -0,0 +1,155 @@ +use { + crate::{ + constants::{ + COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED, MAX_LIQUIDATION_BOUNTY_BPS, + MAX_MAINTENANCE_MARGIN_BPS, + }, + errors::AssetLeasingError, + state::{Lease, LeaseStatus}, + }, + quasar_lang::prelude::*, + quasar_spl::{Mint, Token, TokenCpi}, +}; + +/// Accounts needed to create a new `Listed` lease. The lessor funds the +/// lease state account and both PDA-owned token vaults up front, then +/// transfers the leased tokens into the leased vault in the same +/// transaction so a lessee can never accept a lease the lessor has not +/// pre-funded. +#[derive(Accounts)] +pub struct CreateLease<'info> { + #[account(mut)] + pub lessor: &'info Signer, + + pub leased_mint: &'info Account, + pub collateral_mint: &'info Account, + + /// Lessor's existing token account for the leased mint. Pre-created by + /// the caller — the Quasar port does not do `init_if_needed` ATAs + /// (the Anchor version does, via CPI to the Associated Token Account + /// program; see the Quasar section of the README for the rationale). + #[account(mut)] + pub lessor_leased_account: &'info mut Account, + + #[account( + mut, + init, + payer = lessor, + seeds = [LEASE_SEED, lessor], + bump, + )] + pub lease: &'info mut Account, + + /// Leased-token vault. Authority is the vault PDA itself — signing + /// with the vault seeds is the only way to move tokens out. + #[account( + mut, + init, + payer = lessor, + seeds = [LEASED_VAULT_SEED, lease], + bump, + token::mint = leased_mint, + token::authority = leased_vault, + )] + pub leased_vault: &'info mut Account, + + /// Collateral vault. Empty while `Listed`; filled on `take_lease`. + #[account( + mut, + init, + payer = lessor, + seeds = [COLLATERAL_VAULT_SEED, lease], + bump, + token::mint = collateral_mint, + token::authority = collateral_vault, + )] + pub collateral_vault: &'info mut Account, + + pub rent: &'info Sysvar, + pub token_program: &'info Program, + pub system_program: &'info Program, +} + +#[allow(clippy::too_many_arguments)] +#[inline(always)] +pub fn handle_create_lease( + accounts: &mut CreateLease, + lease_id: u64, + leased_amount: u64, + required_collateral_amount: u64, + rent_per_second: u64, + duration_seconds: i64, + maintenance_margin_bps: u16, + liquidation_bounty_bps: u16, + feed_id: [u8; 32], + bumps: &CreateLeaseBumps, +) -> Result<(), ProgramError> { + // Two vaults keyed on the same mint would collide on the shared + // token-balance pool and make rent-vs-collateral accounting + // ambiguous. Reject up-front. + require!( + accounts.leased_mint.address() != accounts.collateral_mint.address(), + AssetLeasingError::LeasedMintEqualsCollateralMint + ); + + require!(leased_amount > 0, AssetLeasingError::InvalidLeasedAmount); + require!( + required_collateral_amount > 0, + AssetLeasingError::InvalidCollateralAmount + ); + require!( + rent_per_second > 0, + AssetLeasingError::InvalidRentPerSecond + ); + require!(duration_seconds > 0, AssetLeasingError::InvalidDuration); + require!( + maintenance_margin_bps > 0 && maintenance_margin_bps <= MAX_MAINTENANCE_MARGIN_BPS, + AssetLeasingError::InvalidMaintenanceMargin + ); + require!( + liquidation_bounty_bps <= MAX_LIQUIDATION_BOUNTY_BPS, + AssetLeasingError::InvalidLiquidationBounty + ); + + // Lock the leased tokens into the vault up-front. Doing this here — + // rather than on `take_lease` — guarantees that by the time a lessee + // sees a `Listed` lease the lessor cannot have moved the funds + // elsewhere. + accounts + .token_program + .transfer( + accounts.lessor_leased_account, + accounts.leased_vault, + accounts.lessor, + leased_amount, + ) + .invoke()?; + + accounts.lease.set_inner( + lease_id, + *accounts.lessor.address(), + // No lessee yet — populated by `take_lease`. + Address::new_from_array([0u8; 32]), + *accounts.leased_mint.address(), + leased_amount, + *accounts.collateral_mint.address(), + // No collateral yet — posted on `take_lease`. + 0, + required_collateral_amount, + rent_per_second, + duration_seconds, + // start_ts / end_ts / last_rent_paid_ts set on `take_lease`. + 0, + 0, + 0, + maintenance_margin_bps, + liquidation_bounty_bps, + feed_id, + LeaseStatus::Listed as u8, + bumps.lease, + bumps.leased_vault, + bumps.collateral_vault, + ); + + Ok(()) +} diff --git a/defi/asset-leasing/quasar/src/instructions/liquidate.rs b/defi/asset-leasing/quasar/src/instructions/liquidate.rs new file mode 100644 index 000000000..9e2ae85c0 --- /dev/null +++ b/defi/asset-leasing/quasar/src/instructions/liquidate.rs @@ -0,0 +1,325 @@ +use { + crate::{ + constants::{ + BPS_DENOMINATOR, COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED, + PYTH_MAX_AGE_SECONDS, + }, + errors::AssetLeasingError, + instructions::pay_rent::compute_rent_due, + state::{Lease, LeaseStatus}, + }, + quasar_lang::prelude::*, + quasar_spl::{Mint, Token, TokenCpi}, +}; + +/// Pyth Solana Receiver program id on mainnet/devnet. Liquidation +/// rejects any `price_update` account not owned by this program. +// Base58: rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ +pub const PYTH_RECEIVER_PROGRAM_ID: Address = Address::new_from_array([ + 12, 183, 250, 187, 82, 247, 166, 72, 187, 91, 49, 125, 154, 1, 139, 144, 87, 203, 2, 71, 116, + 250, 254, 1, 230, 196, 223, 152, 204, 56, 88, 129, +]); + +/// 8-byte Anchor discriminator for `PriceUpdateV2`. Equal to the first +/// 8 bytes of `sha256("account:PriceUpdateV2")`. Hard-coded because the +/// Pyth SDK pulls in a large dependency tree we don't need for the two +/// numeric fields we actually read. +pub const PRICE_UPDATE_V2_DISCRIMINATOR: [u8; 8] = [34, 241, 35, 99, 157, 126, 244, 205]; + +/// Accounts for the keeper-driven liquidation of an underwater lease. +#[derive(Accounts)] +pub struct Liquidate<'info> { + #[account(mut)] + pub keeper: &'info Signer, + + /// Receives rent + the post-bounty remainder. Also the destination + /// for the closed-vault rent-exempt lamports. + #[account(mut)] + pub lessor: &'info UncheckedAccount, + + #[account( + mut, + seeds = [LEASE_SEED, lessor], + bump = lease.bump, + has_one = lessor, + has_one = leased_mint, + has_one = collateral_mint, + constraint = LeaseStatus::from_u8(lease.status) == Some(LeaseStatus::Active) + @ AssetLeasingError::InvalidLeaseStatus, + close = lessor, + )] + pub lease: &'info mut Account, + + pub leased_mint: &'info Account, + pub collateral_mint: &'info Account, + + #[account( + mut, + seeds = [LEASED_VAULT_SEED, lease], + bump = lease.leased_vault_bump, + )] + pub leased_vault: &'info mut Account, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease], + bump = lease.collateral_vault_bump, + )] + pub collateral_vault: &'info mut Account, + + /// Lessor's collateral-mint token account. Pre-created by the caller. + #[account(mut)] + pub lessor_collateral_account: &'info mut Account, + + /// Keeper's collateral-mint token account — bounty destination. + /// Pre-created by the caller. + #[account(mut)] + pub keeper_collateral_account: &'info mut Account, + + /// Pyth `PriceUpdateV2` account. Must be owned by the Pyth receiver + /// program and carry the expected discriminator; the `feed_id` + /// inside must match the one pinned on the `Lease` at creation so a + /// keeper cannot swap in an unrelated feed. + pub price_update: &'info UncheckedAccount, + + pub token_program: &'info Program, +} + +/// Minimal projection of `PriceUpdateV2` — only the fields we read. +/// Layout: `[discriminator(8) | write_authority(32) | verification_level(1) +/// | feed_id(32) | price(i64) | conf(u64) | exponent(i32) | +/// publish_time(i64) | ...]`. +pub struct DecodedPriceUpdate { + pub feed_id: [u8; 32], + pub price: i64, + pub exponent: i32, + pub publish_time: i64, +} + +pub fn decode_price_update(data: &[u8]) -> Result { + // Discriminator (8) + write_authority (32) + verification_level (1) = 41. + const FEED_ID_OFFSET: usize = 41; + const PRICE_OFFSET: usize = FEED_ID_OFFSET + 32; + const EXPONENT_OFFSET: usize = PRICE_OFFSET + 8 /* price */ + 8 /* conf */; + const PUBLISH_TIME_OFFSET: usize = EXPONENT_OFFSET + 4 /* exponent */; + const MIN_LEN: usize = PUBLISH_TIME_OFFSET + 8; + + if data.len() < MIN_LEN { + return Err(AssetLeasingError::StalePrice.into()); + } + if data[..8] != PRICE_UPDATE_V2_DISCRIMINATOR { + return Err(AssetLeasingError::StalePrice.into()); + } + + let mut feed_id = [0u8; 32]; + feed_id.copy_from_slice(&data[FEED_ID_OFFSET..FEED_ID_OFFSET + 32]); + + let price = i64::from_le_bytes(data[PRICE_OFFSET..PRICE_OFFSET + 8].try_into().unwrap()); + let exponent = i32::from_le_bytes( + data[EXPONENT_OFFSET..EXPONENT_OFFSET + 4] + .try_into() + .unwrap(), + ); + let publish_time = i64::from_le_bytes( + data[PUBLISH_TIME_OFFSET..PUBLISH_TIME_OFFSET + 8] + .try_into() + .unwrap(), + ); + + Ok(DecodedPriceUpdate { + feed_id, + price, + exponent, + publish_time, + }) +} + +#[inline(always)] +pub fn handle_liquidate(accounts: &mut Liquidate) -> Result<(), ProgramError> { + // Owner check: the price update must come from the Pyth receiver + // program. Without this a keeper could forge an arbitrary account. + let price_view = accounts.price_update.to_account_view(); + if price_view.owner() != &PYTH_RECEIVER_PROGRAM_ID { + return Err(ProgramError::IllegalOwner); + } + + let now = ::get()?.unix_timestamp.get(); + let decoded = { + let price_data = unsafe { price_view.borrow_unchecked() }; + decode_price_update(price_data)? + }; + + // Feed pinning: reject any `PriceUpdateV2` whose feed_id does not + // match the one the lessor committed to at `create_lease`. Without + // this guard, a keeper could pass in any feed the Pyth receiver + // owns — e.g. an unrelated volatile pair that happens to dip — + // and trigger a spurious liquidation. + if decoded.feed_id != accounts.lease.feed_id { + return Err(AssetLeasingError::PriceFeedMismatch.into()); + } + + if !is_underwater(accounts.lease, &decoded, now)? { + return Err(AssetLeasingError::PositionHealthy.into()); + } + + // Settle accrued rent first (up to end_ts) so the lessor is paid for + // the time the lessee actually used. Only then slice off bounty + + // remainder. + let rent_due = compute_rent_due(accounts.lease, now)?; + let collateral_amount = accounts.lease.collateral_amount.get(); + let rent_payable = rent_due.min(collateral_amount); + + let lease_address = *accounts.lease.address(); + let collateral_vault_bump = [accounts.lease.collateral_vault_bump]; + let collateral_vault_seeds: &[Seed] = &[ + Seed::from(COLLATERAL_VAULT_SEED), + Seed::from(lease_address.as_ref()), + Seed::from(&collateral_vault_bump as &[u8]), + ]; + let leased_vault_bump = [accounts.lease.leased_vault_bump]; + let leased_vault_seeds: &[Seed] = &[ + Seed::from(LEASED_VAULT_SEED), + Seed::from(lease_address.as_ref()), + Seed::from(&leased_vault_bump as &[u8]), + ]; + + if rent_payable > 0 { + accounts + .token_program + .transfer( + accounts.collateral_vault, + accounts.lessor_collateral_account, + accounts.collateral_vault, + rent_payable, + ) + .invoke_signed(collateral_vault_seeds)?; + } + + let remaining = collateral_amount + .checked_sub(rent_payable) + .ok_or(AssetLeasingError::MathOverflow)?; + + // Bounty is a percentage of the collateral *after* rent — guarantees + // we never try to pay out more than what actually sits in the vault. + let bounty = (remaining as u128) + .checked_mul(accounts.lease.liquidation_bounty_bps.get() as u128) + .ok_or(AssetLeasingError::MathOverflow)? + .checked_div(BPS_DENOMINATOR as u128) + .ok_or(AssetLeasingError::MathOverflow)? as u64; + + if bounty > 0 { + accounts + .token_program + .transfer( + accounts.collateral_vault, + accounts.keeper_collateral_account, + accounts.collateral_vault, + bounty, + ) + .invoke_signed(collateral_vault_seeds)?; + } + + let lessor_share = remaining + .checked_sub(bounty) + .ok_or(AssetLeasingError::MathOverflow)?; + if lessor_share > 0 { + accounts + .token_program + .transfer( + accounts.collateral_vault, + accounts.lessor_collateral_account, + accounts.collateral_vault, + lessor_share, + ) + .invoke_signed(collateral_vault_seeds)?; + } + + // Close both vaults. The leased vault is empty on the default path + // (lessee kept the tokens) but was rent-exempt funded at creation, + // so closing it still returns lamports to the lessor. + accounts + .token_program + .close_account( + accounts.leased_vault, + accounts.lessor, + accounts.leased_vault, + ) + .invoke_signed(leased_vault_seeds)?; + accounts + .token_program + .close_account( + accounts.collateral_vault, + accounts.lessor, + accounts.collateral_vault, + ) + .invoke_signed(collateral_vault_seeds)?; + + accounts.lease.collateral_amount = 0u64.into(); + let end_ts = accounts.lease.end_ts.get(); + accounts.lease.last_rent_paid_ts = now.min(end_ts).into(); + accounts.lease.status = LeaseStatus::Liquidated as u8; + + Ok(()) +} + +/// Liquidatable when collateral value < debt value * maintenance margin. +/// All math stays in integers by folding the Pyth exponent into whichever +/// side of the inequality does not already have a power of ten applied. +pub fn is_underwater( + lease: &Lease, + price: &DecodedPriceUpdate, + now: i64, +) -> Result { + // Staleness guard. `publish_time` coming from the future is treated + // as stale — the keeper must not front-run the clock. + if price.publish_time > now { + return Err(AssetLeasingError::StalePrice.into()); + } + let age = (now - price.publish_time) as u64; + if age > PYTH_MAX_AGE_SECONDS { + return Err(AssetLeasingError::StalePrice.into()); + } + + if price.price <= 0 { + return Err(AssetLeasingError::NonPositivePrice.into()); + } + let price_raw = price.price as u128; + + let leased_amount = lease.leased_amount.get() as u128; + let collateral_amount = lease.collateral_amount.get() as u128; + let margin_bps = lease.maintenance_margin_bps.get() as u128; + let denom = BPS_DENOMINATOR as u128; + + let (collateral_scaled, debt_scaled) = if price.exponent >= 0 { + let scale = ten_pow(price.exponent as u32)?; + let debt = leased_amount + .checked_mul(price_raw) + .and_then(|product| product.checked_mul(scale)) + .ok_or(AssetLeasingError::MathOverflow)?; + (collateral_amount, debt) + } else { + let scale = ten_pow((-price.exponent) as u32)?; + let collateral = collateral_amount + .checked_mul(scale) + .ok_or(AssetLeasingError::MathOverflow)?; + let debt = leased_amount + .checked_mul(price_raw) + .ok_or(AssetLeasingError::MathOverflow)?; + (collateral, debt) + }; + + let lhs = collateral_scaled + .checked_mul(denom) + .ok_or(AssetLeasingError::MathOverflow)?; + let rhs = debt_scaled + .checked_mul(margin_bps) + .ok_or(AssetLeasingError::MathOverflow)?; + + Ok(lhs < rhs) +} + +fn ten_pow(exponent: u32) -> Result { + 10u128 + .checked_pow(exponent) + .ok_or_else(|| AssetLeasingError::MathOverflow.into()) +} diff --git a/defi/asset-leasing/quasar/src/instructions/mod.rs b/defi/asset-leasing/quasar/src/instructions/mod.rs new file mode 100644 index 000000000..d7a421c0e --- /dev/null +++ b/defi/asset-leasing/quasar/src/instructions/mod.rs @@ -0,0 +1,20 @@ +pub mod create_lease; +pub use create_lease::*; + +pub mod take_lease; +pub use take_lease::*; + +pub mod pay_rent; +pub use pay_rent::*; + +pub mod top_up_collateral; +pub use top_up_collateral::*; + +pub mod return_lease; +pub use return_lease::*; + +pub mod liquidate; +pub use liquidate::*; + +pub mod close_expired; +pub use close_expired::*; diff --git a/defi/asset-leasing/quasar/src/instructions/pay_rent.rs b/defi/asset-leasing/quasar/src/instructions/pay_rent.rs new file mode 100644 index 000000000..19921c2c5 --- /dev/null +++ b/defi/asset-leasing/quasar/src/instructions/pay_rent.rs @@ -0,0 +1,115 @@ +use { + crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASE_SEED}, + errors::AssetLeasingError, + state::{Lease, LeaseStatus}, + }, + quasar_lang::prelude::*, + quasar_spl::{Mint, Token, TokenCpi}, +}; + +/// Accounts for settling rent on an `Active` lease. Permissionless: the +/// lessee has every incentive to keep the lease current, but a keeper bot +/// could also push a payment before a liquidation check. +#[derive(Accounts)] +pub struct PayRent<'info> { + #[account(mut)] + pub payer: &'info Signer, + + /// PDA seed + `has_one` target. Not read directly. + pub lessor: &'info UncheckedAccount, + + #[account( + mut, + seeds = [LEASE_SEED, lessor], + bump = lease.bump, + has_one = lessor, + has_one = collateral_mint, + constraint = LeaseStatus::from_u8(lease.status) == Some(LeaseStatus::Active) + @ AssetLeasingError::InvalidLeaseStatus, + )] + pub lease: &'info mut Account, + + pub collateral_mint: &'info Account, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease], + bump = lease.collateral_vault_bump, + )] + pub collateral_vault: &'info mut Account, + + /// Lessor's collateral token account. Pre-created by the caller. + #[account(mut)] + pub lessor_collateral_account: &'info mut Account, + + pub token_program: &'info Program, +} + +#[inline(always)] +pub fn handle_pay_rent(accounts: &mut PayRent) -> Result<(), ProgramError> { + let now = ::get()?.unix_timestamp.get(); + + let rent_amount = compute_rent_due(accounts.lease, now)?; + + if rent_amount == 0 { + update_last_paid_ts(accounts.lease, now); + return Ok(()); + } + + // Cap rent at whatever collateral actually sits in the vault. If the + // lessee under-collateralised we cannot magically create funds; the + // remainder is their debt and can trigger liquidation. + let collateral_amount = accounts.lease.collateral_amount.get(); + let payable = rent_amount.min(collateral_amount); + + if payable > 0 { + let lease_address = *accounts.lease.address(); + let collateral_vault_bump = [accounts.lease.collateral_vault_bump]; + let vault_seeds: &[Seed] = &[ + Seed::from(COLLATERAL_VAULT_SEED), + Seed::from(lease_address.as_ref()), + Seed::from(&collateral_vault_bump as &[u8]), + ]; + accounts + .token_program + .transfer( + accounts.collateral_vault, + accounts.lessor_collateral_account, + accounts.collateral_vault, + payable, + ) + .invoke_signed(vault_seeds)?; + + let new_collateral = collateral_amount + .checked_sub(payable) + .ok_or(AssetLeasingError::MathOverflow)?; + accounts.lease.collateral_amount = new_collateral.into(); + } + + update_last_paid_ts(accounts.lease, now); + Ok(()) +} + +/// Rent accrues linearly: `(min(now, end_ts) - last_rent_paid_ts) * rate`. +/// Shared with `return_lease` and `liquidate` for final settlement. +pub fn compute_rent_due(lease: &Lease, now: i64) -> Result { + let end_ts = lease.end_ts.get(); + let last_paid = lease.last_rent_paid_ts.get(); + let cutoff = now.min(end_ts); + if cutoff <= last_paid { + return Ok(0); + } + let elapsed = (cutoff - last_paid) as u64; + elapsed + .checked_mul(lease.rent_per_second.get()) + .ok_or_else(|| AssetLeasingError::MathOverflow.into()) +} + +/// Advance `last_rent_paid_ts`, but never past `end_ts` — once the lease +/// is over, extra rent does not accrue. +pub fn update_last_paid_ts(lease: &mut Lease, now: i64) { + let end_ts = lease.end_ts.get(); + let capped = now.min(end_ts); + lease.last_rent_paid_ts = capped.into(); +} diff --git a/defi/asset-leasing/quasar/src/instructions/return_lease.rs b/defi/asset-leasing/quasar/src/instructions/return_lease.rs new file mode 100644 index 000000000..cf7eb3286 --- /dev/null +++ b/defi/asset-leasing/quasar/src/instructions/return_lease.rs @@ -0,0 +1,177 @@ +use { + crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, + errors::AssetLeasingError, + instructions::pay_rent::{compute_rent_due, update_last_paid_ts}, + state::{Lease, LeaseStatus}, + }, + quasar_lang::prelude::*, + quasar_spl::{Mint, Token, TokenCpi}, +}; + +/// Accounts for the happy-path return. Lessee hands the leased tokens +/// back, pays accrued rent out of their collateral, and receives whatever +/// collateral is left. Both vaults are closed so the lessor recoups the +/// rent-exempt lamports. +#[derive(Accounts)] +pub struct ReturnLease<'info> { + #[account(mut)] + pub lessee: &'info Signer, + + /// Receives the leased tokens + any accrued rent + the vaults' + /// rent-exempt lamports. + #[account(mut)] + pub lessor: &'info UncheckedAccount, + + #[account( + mut, + seeds = [LEASE_SEED, lessor], + bump = lease.bump, + has_one = lessor, + has_one = leased_mint, + has_one = collateral_mint, + constraint = lease.lessee == *lessee.address() @ AssetLeasingError::Unauthorised, + constraint = LeaseStatus::from_u8(lease.status) == Some(LeaseStatus::Active) + @ AssetLeasingError::InvalidLeaseStatus, + close = lessor, + )] + pub lease: &'info mut Account, + + pub leased_mint: &'info Account, + pub collateral_mint: &'info Account, + + #[account( + mut, + seeds = [LEASED_VAULT_SEED, lease], + bump = lease.leased_vault_bump, + )] + pub leased_vault: &'info mut Account, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease], + bump = lease.collateral_vault_bump, + )] + pub collateral_vault: &'info mut Account, + + #[account(mut)] + pub lessee_leased_account: &'info mut Account, + + #[account(mut)] + pub lessee_collateral_account: &'info mut Account, + + /// Lessor's leased-mint token account. Pre-created by the caller. + #[account(mut)] + pub lessor_leased_account: &'info mut Account, + + /// Lessor's collateral-mint token account. Pre-created by the caller. + #[account(mut)] + pub lessor_collateral_account: &'info mut Account, + + pub token_program: &'info Program, +} + +#[inline(always)] +pub fn handle_return_lease(accounts: &mut ReturnLease) -> Result<(), ProgramError> { + let now = ::get()?.unix_timestamp.get(); + let lease_address = *accounts.lease.address(); + let leased_amount = accounts.lease.leased_amount.get(); + + // 1. Lessee returns leased tokens to the leased vault (full amount). + accounts + .token_program + .transfer( + accounts.lessee_leased_account, + accounts.leased_vault, + accounts.lessee, + leased_amount, + ) + .invoke()?; + + // 2. Forward leased tokens from the vault to the lessor. + let leased_vault_bump = [accounts.lease.leased_vault_bump]; + let leased_vault_seeds: &[Seed] = &[ + Seed::from(LEASED_VAULT_SEED), + Seed::from(lease_address.as_ref()), + Seed::from(&leased_vault_bump as &[u8]), + ]; + accounts + .token_program + .transfer( + accounts.leased_vault, + accounts.lessor_leased_account, + accounts.leased_vault, + leased_amount, + ) + .invoke_signed(leased_vault_seeds)?; + + // 3. Settle accrued rent: collateral vault -> lessor. + let rent_due = compute_rent_due(accounts.lease, now)?; + let collateral_amount = accounts.lease.collateral_amount.get(); + let rent_payable = rent_due.min(collateral_amount); + + let collateral_vault_bump = [accounts.lease.collateral_vault_bump]; + let collateral_vault_seeds: &[Seed] = &[ + Seed::from(COLLATERAL_VAULT_SEED), + Seed::from(lease_address.as_ref()), + Seed::from(&collateral_vault_bump as &[u8]), + ]; + + if rent_payable > 0 { + accounts + .token_program + .transfer( + accounts.collateral_vault, + accounts.lessor_collateral_account, + accounts.collateral_vault, + rent_payable, + ) + .invoke_signed(collateral_vault_seeds)?; + } + + // 4. Refund remaining collateral to the lessee. Returning early does + // not entitle the lessee to a future-rent refund — rent only accrues + // for time actually used, so `compute_rent_due` already excludes the + // unused tail. + let collateral_after_rent = collateral_amount + .checked_sub(rent_payable) + .ok_or(AssetLeasingError::MathOverflow)?; + + if collateral_after_rent > 0 { + accounts + .token_program + .transfer( + accounts.collateral_vault, + accounts.lessee_collateral_account, + accounts.collateral_vault, + collateral_after_rent, + ) + .invoke_signed(collateral_vault_seeds)?; + } + + // 5. Close both vaults so the rent-exempt lamports flow to the lessor + // — the lessee only pays for the temporary state they held while the + // lease was active. + accounts + .token_program + .close_account( + accounts.leased_vault, + accounts.lessor, + accounts.leased_vault, + ) + .invoke_signed(leased_vault_seeds)?; + accounts + .token_program + .close_account( + accounts.collateral_vault, + accounts.lessor, + accounts.collateral_vault, + ) + .invoke_signed(collateral_vault_seeds)?; + + update_last_paid_ts(accounts.lease, now); + accounts.lease.collateral_amount = 0u64.into(); + accounts.lease.status = LeaseStatus::Closed as u8; + + Ok(()) +} diff --git a/defi/asset-leasing/quasar/src/instructions/take_lease.rs b/defi/asset-leasing/quasar/src/instructions/take_lease.rs new file mode 100644 index 000000000..be789bcf3 --- /dev/null +++ b/defi/asset-leasing/quasar/src/instructions/take_lease.rs @@ -0,0 +1,117 @@ +use { + crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, + errors::AssetLeasingError, + state::{Lease, LeaseStatus}, + }, + quasar_lang::prelude::*, + quasar_spl::{Mint, Token, TokenCpi}, +}; + +/// Accounts for accepting a `Listed` lease. The lessee posts their +/// collateral, receives the leased tokens, and the lease transitions to +/// `Active` — all atomically. +#[derive(Accounts)] +pub struct TakeLease<'info> { + #[account(mut)] + pub lessee: &'info Signer, + + /// Pubkey of the lessor who created the lease. Referenced only for + /// `Lease` PDA derivation and the `has_one` check below. + pub lessor: &'info UncheckedAccount, + + #[account( + mut, + seeds = [LEASE_SEED, lessor], + bump = lease.bump, + has_one = lessor, + has_one = leased_mint, + has_one = collateral_mint, + constraint = LeaseStatus::from_u8(lease.status) == Some(LeaseStatus::Listed) + @ AssetLeasingError::InvalidLeaseStatus, + )] + pub lease: &'info mut Account, + + pub leased_mint: &'info Account, + pub collateral_mint: &'info Account, + + #[account( + mut, + seeds = [LEASED_VAULT_SEED, lease], + bump = lease.leased_vault_bump, + )] + pub leased_vault: &'info mut Account, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease], + bump = lease.collateral_vault_bump, + )] + pub collateral_vault: &'info mut Account, + + /// Lessee's existing collateral-mint token account — must hold at least + /// `required_collateral_amount` before calling. + #[account(mut)] + pub lessee_collateral_account: &'info mut Account, + + /// Lessee's leased-mint token account. Must be pre-created by the + /// caller (see the Quasar section of the README for the rationale). + #[account(mut)] + pub lessee_leased_account: &'info mut Account, + + pub token_program: &'info Program, +} + +#[inline(always)] +pub fn handle_take_lease(accounts: &mut TakeLease) -> Result<(), ProgramError> { + let now = ::get()?.unix_timestamp.get(); + + let required_collateral_amount = accounts.lease.required_collateral_amount.get(); + let leased_amount = accounts.lease.leased_amount.get(); + let duration_seconds = accounts.lease.duration_seconds.get(); + + // Lessee deposits collateral first so a failed leased-token transfer + // (e.g. vault under-funded) rolls back their deposit atomically. + accounts + .token_program + .transfer( + accounts.lessee_collateral_account, + accounts.collateral_vault, + accounts.lessee, + required_collateral_amount, + ) + .invoke()?; + + // Pay out leased tokens from the vault PDA. Signer seeds reproduce the + // vault's derivation: [LEASED_VAULT_SEED, lease, bump]. + let leased_vault_bump = [accounts.lease.leased_vault_bump]; + let lease_address = *accounts.lease.address(); + let vault_seeds: &[Seed] = &[ + Seed::from(LEASED_VAULT_SEED), + Seed::from(lease_address.as_ref()), + Seed::from(&leased_vault_bump as &[u8]), + ]; + accounts + .token_program + .transfer( + accounts.leased_vault, + accounts.lessee_leased_account, + accounts.leased_vault, + leased_amount, + ) + .invoke_signed(vault_seeds)?; + + let end_ts = now + .checked_add(duration_seconds) + .ok_or(AssetLeasingError::MathOverflow)?; + + let lease = &mut accounts.lease; + lease.lessee = *accounts.lessee.address(); + lease.collateral_amount = required_collateral_amount.into(); + lease.start_ts = now.into(); + lease.end_ts = end_ts.into(); + lease.last_rent_paid_ts = now.into(); + lease.status = LeaseStatus::Active as u8; + + Ok(()) +} diff --git a/defi/asset-leasing/quasar/src/instructions/top_up_collateral.rs b/defi/asset-leasing/quasar/src/instructions/top_up_collateral.rs new file mode 100644 index 000000000..679c94c91 --- /dev/null +++ b/defi/asset-leasing/quasar/src/instructions/top_up_collateral.rs @@ -0,0 +1,75 @@ +use { + crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASE_SEED}, + errors::AssetLeasingError, + state::{Lease, LeaseStatus}, + }, + quasar_lang::prelude::*, + quasar_spl::{Mint, Token, TokenCpi}, +}; + +/// Accounts for increasing collateral on an `Active` lease. Only the +/// registered lessee may call — anyone else hitting the program returns +/// `Unauthorised`. +#[derive(Accounts)] +pub struct TopUpCollateral<'info> { + #[account(mut)] + pub lessee: &'info Signer, + + /// PDA seed only — not read directly. + pub lessor: &'info UncheckedAccount, + + #[account( + mut, + seeds = [LEASE_SEED, lessor], + bump = lease.bump, + has_one = lessor, + has_one = collateral_mint, + constraint = lease.lessee == *lessee.address() @ AssetLeasingError::Unauthorised, + constraint = LeaseStatus::from_u8(lease.status) == Some(LeaseStatus::Active) + @ AssetLeasingError::InvalidLeaseStatus, + )] + pub lease: &'info mut Account, + + pub collateral_mint: &'info Account, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease], + bump = lease.collateral_vault_bump, + )] + pub collateral_vault: &'info mut Account, + + #[account(mut)] + pub lessee_collateral_account: &'info mut Account, + + pub token_program: &'info Program, +} + +#[inline(always)] +pub fn handle_top_up_collateral( + accounts: &mut TopUpCollateral, + amount: u64, +) -> Result<(), ProgramError> { + require!(amount > 0, AssetLeasingError::InvalidCollateralAmount); + + accounts + .token_program + .transfer( + accounts.lessee_collateral_account, + accounts.collateral_vault, + accounts.lessee, + amount, + ) + .invoke()?; + + let new_collateral = accounts + .lease + .collateral_amount + .get() + .checked_add(amount) + .ok_or(AssetLeasingError::MathOverflow)?; + accounts.lease.collateral_amount = new_collateral.into(); + + Ok(()) +} diff --git a/defi/asset-leasing/quasar/src/lib.rs b/defi/asset-leasing/quasar/src/lib.rs new file mode 100644 index 000000000..4b237bb2a --- /dev/null +++ b/defi/asset-leasing/quasar/src/lib.rs @@ -0,0 +1,89 @@ +#![cfg_attr(not(test), no_std)] + +use quasar_lang::prelude::*; + +mod constants; +mod errors; +mod instructions; +mod state; + +use instructions::*; +#[cfg(test)] +mod tests; + +// Same program id as the Anchor version so off-chain tooling that derives +// PDAs or looks up the program on-chain works against both binaries +// interchangeably. +declare_id!("Lease11111111111111111111111111111111111111"); + +/// Asset-leasing program: fixed-term token leases with a streaming rent +/// payment, collateral escrow, and Pyth-oracle-triggered liquidation. +/// +/// See the top-level `defi/asset-leasing/anchor/README.md` for the full +/// mechanics — the Quasar and Anchor versions are functionally identical. +#[program] +mod quasar_asset_leasing { + use super::*; + + /// Discriminators are packed densely starting from 0 so the wire format + /// stays a single byte. The order matches the natural user-facing + /// lifecycle (create → take → pay/top-up → return/liquidate/close). + #[instruction(discriminator = 0)] + pub fn create_lease( + ctx: Ctx, + lease_id: u64, + leased_amount: u64, + required_collateral_amount: u64, + rent_per_second: u64, + duration_seconds: i64, + maintenance_margin_bps: u16, + liquidation_bounty_bps: u16, + feed_id: [u8; 32], + ) -> Result<(), ProgramError> { + instructions::handle_create_lease( + &mut ctx.accounts, + lease_id, + leased_amount, + required_collateral_amount, + rent_per_second, + duration_seconds, + maintenance_margin_bps, + liquidation_bounty_bps, + feed_id, + &ctx.bumps, + ) + } + + #[instruction(discriminator = 1)] + pub fn take_lease(ctx: Ctx) -> Result<(), ProgramError> { + instructions::handle_take_lease(&mut ctx.accounts) + } + + #[instruction(discriminator = 2)] + pub fn pay_rent(ctx: Ctx) -> Result<(), ProgramError> { + instructions::handle_pay_rent(&mut ctx.accounts) + } + + #[instruction(discriminator = 3)] + pub fn top_up_collateral( + ctx: Ctx, + amount: u64, + ) -> Result<(), ProgramError> { + instructions::handle_top_up_collateral(&mut ctx.accounts, amount) + } + + #[instruction(discriminator = 4)] + pub fn return_lease(ctx: Ctx) -> Result<(), ProgramError> { + instructions::handle_return_lease(&mut ctx.accounts) + } + + #[instruction(discriminator = 5)] + pub fn liquidate(ctx: Ctx) -> Result<(), ProgramError> { + instructions::handle_liquidate(&mut ctx.accounts) + } + + #[instruction(discriminator = 6)] + pub fn close_expired(ctx: Ctx) -> Result<(), ProgramError> { + instructions::handle_close_expired(&mut ctx.accounts) + } +} diff --git a/defi/asset-leasing/quasar/src/state.rs b/defi/asset-leasing/quasar/src/state.rs new file mode 100644 index 000000000..ac911a59c --- /dev/null +++ b/defi/asset-leasing/quasar/src/state.rs @@ -0,0 +1,88 @@ +use quasar_lang::prelude::*; + +/// Lifecycle of a lease. Stored as a single byte on `Lease` and driven by +/// the program — a user cannot write to it directly. +/// +/// The final `Closed` / `Liquidated` states are set *before* the account is +/// closed by its handler, so the transaction log records the terminal state +/// even though the account itself disappears at the end of the transaction. +#[repr(u8)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum LeaseStatus { + Listed = 0, + Active = 1, + Liquidated = 2, + Closed = 3, +} + +impl LeaseStatus { + pub fn from_u8(byte: u8) -> Option { + match byte { + 0 => Some(Self::Listed), + 1 => Some(Self::Active), + 2 => Some(Self::Liquidated), + 3 => Some(Self::Closed), + _ => None, + } + } +} + +/// Persistent per-lease state. Created on `create_lease`, closed on +/// `return_lease` / `liquidate` / `close_expired`. +/// +/// Field order mirrors the Anchor version; integers are promoted to their +/// `PodXX` counterparts by the `#[account]` macro so the struct stays +/// alignment-1 and the on-chain bytes match Anchor's little-endian layout +/// (after the one-byte Quasar discriminator replaces Anchor's 8-byte +/// sha256 prefix). +#[account(discriminator = 1)] +pub struct Lease { + /// Caller-supplied id so one lessor can run many leases in parallel. + pub lease_id: u64, + + /// Signer of `create_lease`; paid rent and any final recovery. + pub lessor: Address, + + /// Signer of `take_lease`. `Address::default()` while still `Listed`. + pub lessee: Address, + + pub leased_mint: Address, + /// Locked at creation, unchanging for the life of the lease. + pub leased_amount: u64, + + pub collateral_mint: Address, + /// Decreases as rent streams out; increases on `top_up_collateral`. + pub collateral_amount: u64, + /// What the lessee must post on `take_lease`. + pub required_collateral_amount: u64, + + /// Denominated in collateral-mint base units per second. + pub rent_per_second: u64, + pub duration_seconds: i64, + /// `0` while `Listed`; `unix_timestamp` of `take_lease` while `Active`. + pub start_ts: i64, + /// `0` while `Listed`; `start_ts + duration_seconds` once `Active`. + pub end_ts: i64, + /// Rent accrues from here to `min(now, end_ts)`. + pub last_rent_paid_ts: i64, + + /// Collateral-over-debt ratio in basis points. + /// `12_000` bps = 120%. Capped at `MAX_MAINTENANCE_MARGIN_BPS`. + pub maintenance_margin_bps: u16, + /// Keeper's cut of the post-rent collateral on liquidation, in basis + /// points. Capped at `MAX_LIQUIDATION_BOUNTY_BPS` to stop a malicious + /// lessor from draining the recovery pool via the bounty. + pub liquidation_bounty_bps: u16, + + /// Pyth feed id this lease is pinned to at creation. Enforced on every + /// `liquidate` so a keeper cannot swap in an unrelated feed to force an + /// underwater verdict. + pub feed_id: [u8; 32], + + /// Current lifecycle state. See [`LeaseStatus`]. + pub status: u8, + + pub bump: u8, + pub leased_vault_bump: u8, + pub collateral_vault_bump: u8, +} diff --git a/defi/asset-leasing/quasar/src/tests.rs b/defi/asset-leasing/quasar/src/tests.rs new file mode 100644 index 000000000..8edbe828d --- /dev/null +++ b/defi/asset-leasing/quasar/src/tests.rs @@ -0,0 +1,936 @@ +//! Quasar-SVM tests for the asset-leasing program. +//! +//! Covers the full lifecycle: listing, taking, rent streaming, top-ups, +//! early return, keeper liquidation via a mocked Pyth `PriceUpdateV2` +//! account, and lessor-initiated default recovery after expiry. +//! +//! Each test constructs a fresh `QuasarSvm`, synthesises the minimal set +//! of accounts that handler needs (mints, token accounts, the existing +//! lease state where relevant), and submits a manually-assembled +//! instruction. State updates are read straight back out of the SVM. + +extern crate std; + +use { + alloc::{vec, vec::Vec}, + quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, + solana_instruction::AccountMeta, + spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, + std::println, +}; + +use crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, + state::LeaseStatus, +}; + +// --------------------------------------------------------------------------- +// Shared test constants +// --------------------------------------------------------------------------- + +/// USDC-style decimals keep the arithmetic readable in asserts. +const DECIMALS: u8 = 6; + +/// 100 leased tokens at 6 decimals. +const LEASED_AMOUNT: u64 = 100_000_000; +/// 200 collateral tokens at 6 decimals. +const REQUIRED_COLLATERAL: u64 = 200_000_000; +const RENT_PER_SECOND: u64 = 10; +/// 24 hours. +const DURATION_SECONDS: i64 = 60 * 60 * 24; +/// 120% maintenance margin, in basis points. +const MAINTENANCE_MARGIN_BPS: u16 = 12_000; +/// 5% keeper bounty, in basis points. +const LIQUIDATION_BOUNTY_BPS: u16 = 500; +/// Arbitrary 32-byte Pyth feed id the tests pin their leases to. +const FEED_ID: [u8; 32] = [0xAB; 32]; + +/// LiteSVM's default clock starts at epoch 0; anchoring at a recent-ish +/// real timestamp keeps rent math free of sign-weirdness without any +/// tests having to special-case `start_ts = 0`. +const DEFAULT_TIMESTAMP: i64 = 1_700_000_000; + +/// Starting wallet balance for lessor and lessee token accounts. +const STARTING_BALANCE: u64 = 1_000_000_000; + +/// Pyth receiver program id on mainnet/devnet. Matches +/// [`crate::instructions::liquidate::PYTH_RECEIVER_PROGRAM_ID`]. +fn pyth_receiver_pubkey() -> Pubkey { + Pubkey::from([ + 12, 183, 250, 187, 82, 247, 166, 72, 187, 91, 49, 125, 154, 1, 139, 144, 87, 203, 2, 71, + 116, 250, 254, 1, 230, 196, 223, 152, 204, 56, 88, 129, + ]) +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +fn setup() -> QuasarSvm { + let elf = std::fs::read("target/deploy/quasar_asset_leasing.so") + .expect("build the program with `quasar build` before running tests"); + let mut svm = QuasarSvm::new() + .with_program(&crate::ID, &elf) + .with_token_program(); + svm.warp_to_timestamp(DEFAULT_TIMESTAMP); + svm +} + +fn signer(address: Pubkey) -> Account { + quasar_svm::token::create_keyed_system_account(&address, 1_000_000_000) +} + +fn empty(address: Pubkey) -> Account { + Account { + address, + lamports: 0, + data: vec![], + owner: quasar_svm::system_program::ID, + executable: false, + } +} + +fn mint(address: Pubkey, authority: Pubkey) -> Account { + quasar_svm::token::create_keyed_mint_account( + &address, + &Mint { + mint_authority: Some(authority).into(), + supply: STARTING_BALANCE * 4, + decimals: DECIMALS, + is_initialized: true, + freeze_authority: None.into(), + }, + ) +} + +fn token(address: Pubkey, mint: Pubkey, owner: Pubkey, amount: u64) -> Account { + quasar_svm::token::create_keyed_token_account( + &address, + &TokenAccount { + mint, + owner, + amount, + state: AccountState::Initialized, + ..TokenAccount::default() + }, + ) +} + +/// Byte offsets for reading fields out of a serialised `Lease` account. +/// Layout (after the `#[account(discriminator = 1)]` macro lowers fields +/// to pod types): 1 disc + 8 lease_id + 32 lessor + 32 lessee + 32 +/// leased_mint + 8 leased_amount + 32 collateral_mint + 8 collateral_amount +/// + 8 required_collateral + 8 rent_per_second + 8 duration + 8 start_ts + +/// 8 end_ts + 8 last_rent_paid_ts + 2 margin_bps + 2 bounty_bps + 32 +/// feed_id + 4 status/bumps = 249 bytes. +mod lease_offsets { + pub const COLLATERAL_AMOUNT: usize = 1 + 8 + 32 + 32 + 32 + 8 + 32; + pub const LAST_RENT_PAID_TS: usize = COLLATERAL_AMOUNT + 8 + 8 + 8 + 8 + 8 + 8; + pub const STATUS: usize = LAST_RENT_PAID_TS + 8 + 2 + 2 + 32; +} + +fn read_collateral_amount(data: &[u8]) -> u64 { + u64::from_le_bytes( + data[lease_offsets::COLLATERAL_AMOUNT..lease_offsets::COLLATERAL_AMOUNT + 8] + .try_into() + .unwrap(), + ) +} + +fn read_status(data: &[u8]) -> u8 { + data[lease_offsets::STATUS] +} + +fn read_token_amount(account: &Account) -> u64 { + u64::from_le_bytes(account.data[64..72].try_into().unwrap()) +} + +// --------------------------------------------------------------------------- +// PDA derivations (mirror the program's `#[account(seeds = ...)]`) +// --------------------------------------------------------------------------- + +fn lease_pda(lessor: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[LEASE_SEED, lessor.as_ref()], &crate::ID) +} + +fn leased_vault_pda(lease: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[LEASED_VAULT_SEED, lease.as_ref()], &crate::ID) +} + +fn collateral_vault_pda(lease: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[COLLATERAL_VAULT_SEED, lease.as_ref()], &crate::ID) +} + +// --------------------------------------------------------------------------- +// Instruction builders +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn build_create_lease_data( + lease_id: u64, + leased_amount: u64, + required_collateral_amount: u64, + rent_per_second: u64, + duration_seconds: i64, + maintenance_margin_bps: u16, + liquidation_bounty_bps: u16, + feed_id: [u8; 32], +) -> Vec { + let mut data = vec![0u8]; // discriminator for create_lease + data.extend_from_slice(&lease_id.to_le_bytes()); + data.extend_from_slice(&leased_amount.to_le_bytes()); + data.extend_from_slice(&required_collateral_amount.to_le_bytes()); + data.extend_from_slice(&rent_per_second.to_le_bytes()); + data.extend_from_slice(&duration_seconds.to_le_bytes()); + data.extend_from_slice(&maintenance_margin_bps.to_le_bytes()); + data.extend_from_slice(&liquidation_bounty_bps.to_le_bytes()); + data.extend_from_slice(&feed_id); + data +} + +// --------------------------------------------------------------------------- +// Scenario — a fresh SVM + the set of pubkeys every test needs +// --------------------------------------------------------------------------- + +struct Scenario { + lessor: Pubkey, + lessee: Pubkey, + keeper: Pubkey, + leased_mint: Pubkey, + collateral_mint: Pubkey, + /// Pre-created lessor token account for the leased mint, starts at + /// `STARTING_BALANCE`. + lessor_leased_ta: Pubkey, + /// Lessor's collateral ATA, starts empty. + lessor_collateral_ta: Pubkey, + /// Lessee's collateral ATA, starts at `STARTING_BALANCE`. + lessee_collateral_ta: Pubkey, + /// Lessee's leased ATA, starts empty. + lessee_leased_ta: Pubkey, + /// Keeper's collateral ATA, starts empty — bounty destination. + keeper_collateral_ta: Pubkey, + lease: Pubkey, + leased_vault: Pubkey, + collateral_vault: Pubkey, +} + +fn make_scenario() -> (QuasarSvm, Scenario) { + let svm = setup(); + let lessor = Pubkey::new_unique(); + let lessee = Pubkey::new_unique(); + let keeper = Pubkey::new_unique(); + let leased_mint = Pubkey::new_unique(); + let collateral_mint = Pubkey::new_unique(); + let lessor_leased_ta = Pubkey::new_unique(); + let lessor_collateral_ta = Pubkey::new_unique(); + let lessee_collateral_ta = Pubkey::new_unique(); + let lessee_leased_ta = Pubkey::new_unique(); + let keeper_collateral_ta = Pubkey::new_unique(); + let (lease, _lease_bump) = lease_pda(&lessor); + let (leased_vault, _leased_vault_bump) = leased_vault_pda(&lease); + let (collateral_vault, _collateral_vault_bump) = collateral_vault_pda(&lease); + let scenario = Scenario { + lessor, + lessee, + keeper, + leased_mint, + collateral_mint, + lessor_leased_ta, + lessor_collateral_ta, + lessee_collateral_ta, + lessee_leased_ta, + keeper_collateral_ta, + lease, + leased_vault, + collateral_vault, + }; + (svm, scenario) +} + +// --------------------------------------------------------------------------- +// Instruction assemblers — one per handler, returning `(Instruction, +// Vec)` pairs ready to hand to `process_instruction`. +// +// The `accounts` vector order matches the order of fields in the matching +// `#[derive(Accounts)]` struct, which is also the order the handler reads +// them from. Off-by-one errors show up as ownership / signer failures, +// never as silent misbehaviour. +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn create_lease_call(sc: &Scenario, lease_id: u64) -> (Instruction, Vec) { + // `init + seeds` fields self-sign via `invoke_signed` inside the + // program, so only the lessor (index 0) is a true signer here. + let ix = Instruction { + program_id: crate::ID, + accounts: vec![ + AccountMeta::new(sc.lessor, true), + AccountMeta::new_readonly(sc.leased_mint, false), + AccountMeta::new_readonly(sc.collateral_mint, false), + AccountMeta::new(sc.lessor_leased_ta, false), + AccountMeta::new(sc.lease, false), + AccountMeta::new(sc.leased_vault, false), + AccountMeta::new(sc.collateral_vault, false), + AccountMeta::new_readonly(quasar_svm::solana_sdk_ids::sysvar::rent::ID, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(quasar_svm::system_program::ID, false), + ], + data: build_create_lease_data( + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + RENT_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BPS, + LIQUIDATION_BOUNTY_BPS, + FEED_ID, + ), + }; + + let accounts = vec![ + signer(sc.lessor), + mint(sc.leased_mint, sc.lessor), + mint(sc.collateral_mint, sc.lessor), + token(sc.lessor_leased_ta, sc.leased_mint, sc.lessor, STARTING_BALANCE), + empty(sc.lease), + empty(sc.leased_vault), + empty(sc.collateral_vault), + ]; + + (ix, accounts) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[test] +fn create_lease_locks_tokens_and_lists() { + let (mut svm, sc) = make_scenario(); + let (ix, accounts) = create_lease_call(&sc, 1); + let result = svm.process_instruction(&ix, &accounts); + assert!(result.is_ok(), "create_lease failed: {:?}", result.raw_result); + + // Lease created, vaults initialised. + let lease_account = result.account(&sc.lease).expect("lease PDA missing"); + assert_eq!(lease_account.owner, crate::ID); + assert_eq!(read_status(&lease_account.data), LeaseStatus::Listed as u8); + + // Leased tokens escrowed; lessor balance dropped. + let leased_vault = result.account(&sc.leased_vault).unwrap(); + assert_eq!(read_token_amount(leased_vault), LEASED_AMOUNT); + let lessor_ta = result.account(&sc.lessor_leased_ta).unwrap(); + assert_eq!(read_token_amount(lessor_ta), STARTING_BALANCE - LEASED_AMOUNT); + + // Collateral vault exists, empty. + let collateral_vault = result.account(&sc.collateral_vault).unwrap(); + assert_eq!(read_token_amount(collateral_vault), 0); + + println!(" CREATE CU: {}", result.compute_units_consumed); +} + +/// Second form of `create_lease` that lets a test swap the mint addresses +/// — used to exercise the same-mint rejection path. +#[allow(clippy::too_many_arguments)] +fn create_lease_call_with_mints( + sc: &Scenario, + lease_id: u64, + leased_mint: Pubkey, + collateral_mint: Pubkey, +) -> (Instruction, Vec) { + let ix = Instruction { + program_id: crate::ID, + accounts: vec![ + AccountMeta::new(sc.lessor, true), + AccountMeta::new_readonly(leased_mint, false), + AccountMeta::new_readonly(collateral_mint, false), + AccountMeta::new(sc.lessor_leased_ta, false), + AccountMeta::new(sc.lease, false), + AccountMeta::new(sc.leased_vault, false), + AccountMeta::new(sc.collateral_vault, false), + AccountMeta::new_readonly(quasar_svm::solana_sdk_ids::sysvar::rent::ID, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(quasar_svm::system_program::ID, false), + ], + data: build_create_lease_data( + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + RENT_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BPS, + LIQUIDATION_BOUNTY_BPS, + FEED_ID, + ), + }; + let accounts = vec![ + signer(sc.lessor), + mint(leased_mint, sc.lessor), + mint(collateral_mint, sc.lessor), + token(sc.lessor_leased_ta, leased_mint, sc.lessor, STARTING_BALANCE), + empty(sc.lease), + empty(sc.leased_vault), + empty(sc.collateral_vault), + ]; + (ix, accounts) +} + +/// Pyth `PriceUpdateV2` body with only the fields liquidate actually reads +/// populated; everything else is zeroed. +fn build_price_update_data( + feed_id: [u8; 32], + price: i64, + exponent: i32, + publish_time: i64, +) -> Vec { + // 8 disc + 32 write_authority + 1 verification_level + 32 feed_id + + // 8 price + 8 conf + 4 exponent + 8 publish_time + 8 prev_publish_time + + // 8 ema_price + 8 ema_conf + 8 posted_slot = 141 bytes. + const TOTAL_LEN: usize = 141; + const PRICE_UPDATE_V2_DISCRIMINATOR: [u8; 8] = [34, 241, 35, 99, 157, 126, 244, 205]; + let mut data = Vec::with_capacity(TOTAL_LEN); + data.extend_from_slice(&PRICE_UPDATE_V2_DISCRIMINATOR); + data.extend_from_slice(&[0u8; 32]); // write_authority + data.push(1); // verification_level = Full + data.extend_from_slice(&feed_id); + data.extend_from_slice(&price.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); // conf + data.extend_from_slice(&exponent.to_le_bytes()); + data.extend_from_slice(&publish_time.to_le_bytes()); + data.extend_from_slice(&publish_time.to_le_bytes()); // prev_publish_time + data.extend_from_slice(&0i64.to_le_bytes()); // ema_price + data.extend_from_slice(&0u64.to_le_bytes()); // ema_conf + data.extend_from_slice(&0u64.to_le_bytes()); // posted_slot + data +} + +fn install_price_update( + svm: &mut QuasarSvm, + address: Pubkey, + feed_id: [u8; 32], + price: i64, + exponent: i32, + publish_time: i64, +) { + let data = build_price_update_data(feed_id, price, exponent, publish_time); + svm.set_account(Account { + address, + lamports: 10_000_000, + data, + owner: pyth_receiver_pubkey(), + executable: false, + }); +} + +fn take_lease_call(sc: &Scenario) -> (Instruction, Vec) { + let ix = Instruction { + program_id: crate::ID, + accounts: vec![ + AccountMeta::new(sc.lessee, true), + AccountMeta::new_readonly(sc.lessor, false), + AccountMeta::new(sc.lease, false), + AccountMeta::new_readonly(sc.leased_mint, false), + AccountMeta::new_readonly(sc.collateral_mint, false), + AccountMeta::new(sc.leased_vault, false), + AccountMeta::new(sc.collateral_vault, false), + AccountMeta::new(sc.lessee_collateral_ta, false), + AccountMeta::new(sc.lessee_leased_ta, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + ], + data: vec![1u8], // discriminator = take_lease + }; + let accounts = vec![ + signer(sc.lessee), + empty(sc.lessor), + // `lease` is sourced from the SVM database, already pre-installed. + mint(sc.leased_mint, sc.lessor), + mint(sc.collateral_mint, sc.lessor), + // `leased_vault` and `collateral_vault` similarly pre-installed. + token( + sc.lessee_collateral_ta, + sc.collateral_mint, + sc.lessee, + STARTING_BALANCE, + ), + token(sc.lessee_leased_ta, sc.leased_mint, sc.lessee, 0), + ]; + (ix, accounts) +} + +fn pay_rent_call(sc: &Scenario) -> (Instruction, Vec) { + let ix = Instruction { + program_id: crate::ID, + accounts: vec![ + AccountMeta::new(sc.lessee, true), + AccountMeta::new_readonly(sc.lessor, false), + AccountMeta::new(sc.lease, false), + AccountMeta::new_readonly(sc.collateral_mint, false), + AccountMeta::new(sc.collateral_vault, false), + AccountMeta::new(sc.lessor_collateral_ta, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + ], + data: vec![2u8], + }; + let accounts = vec![ + signer(sc.lessee), + empty(sc.lessor), + mint(sc.collateral_mint, sc.lessor), + token(sc.lessor_collateral_ta, sc.collateral_mint, sc.lessor, 0), + ]; + (ix, accounts) +} + +fn top_up_call(sc: &Scenario, amount: u64) -> (Instruction, Vec) { + let mut data = vec![3u8]; + data.extend_from_slice(&amount.to_le_bytes()); + let ix = Instruction { + program_id: crate::ID, + accounts: vec![ + AccountMeta::new(sc.lessee, true), + AccountMeta::new_readonly(sc.lessor, false), + AccountMeta::new(sc.lease, false), + AccountMeta::new_readonly(sc.collateral_mint, false), + AccountMeta::new(sc.collateral_vault, false), + AccountMeta::new(sc.lessee_collateral_ta, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + ], + data, + }; + let accounts = vec![ + signer(sc.lessee), + empty(sc.lessor), + mint(sc.collateral_mint, sc.lessor), + ]; + (ix, accounts) +} + +fn return_lease_call(sc: &Scenario) -> (Instruction, Vec) { + let ix = Instruction { + program_id: crate::ID, + accounts: vec![ + AccountMeta::new(sc.lessee, true), + AccountMeta::new(sc.lessor, false), + AccountMeta::new(sc.lease, false), + AccountMeta::new_readonly(sc.leased_mint, false), + AccountMeta::new_readonly(sc.collateral_mint, false), + AccountMeta::new(sc.leased_vault, false), + AccountMeta::new(sc.collateral_vault, false), + AccountMeta::new(sc.lessee_leased_ta, false), + AccountMeta::new(sc.lessee_collateral_ta, false), + AccountMeta::new(sc.lessor_leased_ta, false), + AccountMeta::new(sc.lessor_collateral_ta, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + ], + data: vec![4u8], + }; + let accounts = vec![ + signer(sc.lessee), + empty(sc.lessor), + mint(sc.leased_mint, sc.lessor), + mint(sc.collateral_mint, sc.lessor), + token(sc.lessor_collateral_ta, sc.collateral_mint, sc.lessor, 0), + ]; + (ix, accounts) +} + +fn liquidate_call(sc: &Scenario, price_update: Pubkey) -> (Instruction, Vec) { + let ix = Instruction { + program_id: crate::ID, + accounts: vec![ + AccountMeta::new(sc.keeper, true), + AccountMeta::new(sc.lessor, false), + AccountMeta::new(sc.lease, false), + AccountMeta::new_readonly(sc.leased_mint, false), + AccountMeta::new_readonly(sc.collateral_mint, false), + AccountMeta::new(sc.leased_vault, false), + AccountMeta::new(sc.collateral_vault, false), + AccountMeta::new(sc.lessor_collateral_ta, false), + AccountMeta::new(sc.keeper_collateral_ta, false), + AccountMeta::new_readonly(price_update, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + ], + data: vec![5u8], + }; + let accounts = vec![ + signer(sc.keeper), + empty(sc.lessor), + mint(sc.leased_mint, sc.lessor), + mint(sc.collateral_mint, sc.lessor), + token(sc.lessor_collateral_ta, sc.collateral_mint, sc.lessor, 0), + token(sc.keeper_collateral_ta, sc.collateral_mint, sc.keeper, 0), + ]; + (ix, accounts) +} + +fn close_expired_call(sc: &Scenario) -> (Instruction, Vec) { + let ix = Instruction { + program_id: crate::ID, + accounts: vec![ + AccountMeta::new(sc.lessor, true), + AccountMeta::new(sc.lease, false), + AccountMeta::new_readonly(sc.leased_mint, false), + AccountMeta::new_readonly(sc.collateral_mint, false), + AccountMeta::new(sc.leased_vault, false), + AccountMeta::new(sc.collateral_vault, false), + AccountMeta::new(sc.lessor_leased_ta, false), + AccountMeta::new(sc.lessor_collateral_ta, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + ], + data: vec![6u8], + }; + let accounts = vec![ + signer(sc.lessor), + mint(sc.leased_mint, sc.lessor), + mint(sc.collateral_mint, sc.lessor), + token( + sc.lessor_leased_ta, + sc.leased_mint, + sc.lessor, + STARTING_BALANCE - LEASED_AMOUNT, + ), + token(sc.lessor_collateral_ta, sc.collateral_mint, sc.lessor, 0), + ]; + (ix, accounts) +} + +/// After a successful `create_lease`, install the resulting vault + lease +/// state in the SVM database so the next handler call has something to +/// read from. Copies the authentic on-chain bytes (discriminator, token +/// amounts, lease fields) straight out of the previous execution result. +fn commit_state<'a>( + svm: &mut QuasarSvm, + result: &'a quasar_svm::ExecutionResult, + addresses: &[Pubkey], +) { + for address in addresses { + if let Some(account) = result.account(address) { + svm.set_account(Account { + address: *address, + lamports: account.lamports, + data: account.data.clone(), + owner: account.owner, + executable: account.executable, + }); + } + } +} + +#[test] +fn take_lease_posts_collateral_and_delivers_tokens() { + let (mut svm, sc) = make_scenario(); + + // Run create_lease and commit its output (lease + both vaults). + let (create_ix, create_accounts) = create_lease_call(&sc, 2); + let create_result = svm.process_instruction(&create_ix, &create_accounts); + assert!(create_result.is_ok(), "create_lease failed: {:?}", create_result.raw_result); + commit_state( + &mut svm, + &create_result, + &[sc.lease, sc.leased_vault, sc.collateral_vault, sc.lessor_leased_ta], + ); + + // Now the lessee takes it. + let (take_ix, take_accounts) = take_lease_call(&sc); + let take_result = svm.process_instruction(&take_ix, &take_accounts); + assert!(take_result.is_ok(), "take_lease failed: {:?}", take_result.raw_result); + + // Leased vault drained into the lessee. + assert_eq!( + read_token_amount(take_result.account(&sc.leased_vault).unwrap()), + 0 + ); + assert_eq!( + read_token_amount(take_result.account(&sc.lessee_leased_ta).unwrap()), + LEASED_AMOUNT + ); + // Collateral moved from the lessee into the collateral vault. + assert_eq!( + read_token_amount(take_result.account(&sc.collateral_vault).unwrap()), + REQUIRED_COLLATERAL + ); + assert_eq!( + read_token_amount(take_result.account(&sc.lessee_collateral_ta).unwrap()), + STARTING_BALANCE - REQUIRED_COLLATERAL + ); + // Lease transitioned Listed -> Active. + assert_eq!( + read_status(&take_result.account(&sc.lease).unwrap().data), + LeaseStatus::Active as u8 + ); +} + +/// Helper: run create + take atomically and commit all resulting state so +/// the next call starts from an `Active` lease. +fn make_and_take(svm: &mut QuasarSvm, sc: &Scenario) { + let (create_ix, create_accounts) = create_lease_call(sc, 1); + let create_result = svm.process_instruction(&create_ix, &create_accounts); + assert!(create_result.is_ok(), "create_lease failed: {:?}", create_result.raw_result); + commit_state( + svm, + &create_result, + &[sc.lease, sc.leased_vault, sc.collateral_vault, sc.lessor_leased_ta], + ); + + let (take_ix, take_accounts) = take_lease_call(sc); + let take_result = svm.process_instruction(&take_ix, &take_accounts); + assert!(take_result.is_ok(), "take_lease failed: {:?}", take_result.raw_result); + commit_state( + svm, + &take_result, + &[ + sc.lease, + sc.leased_vault, + sc.collateral_vault, + sc.lessee_collateral_ta, + sc.lessee_leased_ta, + ], + ); +} + +#[test] +fn pay_rent_streams_collateral_by_elapsed_time() { + let (mut svm, sc) = make_scenario(); + make_and_take(&mut svm, &sc); + + // Advance clock by 2 minutes and pay rent. + let elapsed: i64 = 120; + svm.warp_to_timestamp(DEFAULT_TIMESTAMP + elapsed); + let (pay_ix, pay_accounts) = pay_rent_call(&sc); + let result = svm.process_instruction(&pay_ix, &pay_accounts); + assert!(result.is_ok(), "pay_rent failed: {:?}", result.raw_result); + + let expected_rent = (elapsed as u64) * RENT_PER_SECOND; + assert_eq!( + read_token_amount(result.account(&sc.lessor_collateral_ta).unwrap()), + expected_rent + ); + assert_eq!( + read_token_amount(result.account(&sc.collateral_vault).unwrap()), + REQUIRED_COLLATERAL - expected_rent + ); +} + +#[test] +fn top_up_collateral_increases_vault_balance() { + let (mut svm, sc) = make_scenario(); + make_and_take(&mut svm, &sc); + + let top_up_amount: u64 = 50_000_000; + let (ix, accounts) = top_up_call(&sc, top_up_amount); + let result = svm.process_instruction(&ix, &accounts); + assert!(result.is_ok(), "top_up failed: {:?}", result.raw_result); + + assert_eq!( + read_token_amount(result.account(&sc.collateral_vault).unwrap()), + REQUIRED_COLLATERAL + top_up_amount + ); + // Collateral amount on the lease bumps too. + assert_eq!( + read_collateral_amount(&result.account(&sc.lease).unwrap().data), + REQUIRED_COLLATERAL + top_up_amount + ); +} + +#[test] +fn return_lease_refunds_unused_collateral() { + let (mut svm, sc) = make_scenario(); + make_and_take(&mut svm, &sc); + + // Lessee returns 10 minutes in, for a 24h lease. + let elapsed: i64 = 600; + svm.warp_to_timestamp(DEFAULT_TIMESTAMP + elapsed); + + let (ix, accounts) = return_lease_call(&sc); + let result = svm.process_instruction(&ix, &accounts); + assert!(result.is_ok(), "return_lease failed: {:?}", result.raw_result); + + let rent_paid = (elapsed as u64) * RENT_PER_SECOND; + let refund_expected = REQUIRED_COLLATERAL - rent_paid; + + // Lessor got the full leased amount back. + assert_eq!( + read_token_amount(result.account(&sc.lessor_leased_ta).unwrap()), + STARTING_BALANCE + ); + // Lessor received the accrued rent. + assert_eq!( + read_token_amount(result.account(&sc.lessor_collateral_ta).unwrap()), + rent_paid + ); + // Lessee got unused-time collateral back. + assert_eq!( + read_token_amount(result.account(&sc.lessee_collateral_ta).unwrap()), + STARTING_BALANCE - REQUIRED_COLLATERAL + refund_expected + ); + + // Both vaults closed — the SVM keeps the account record but with + // lamports=0 / data empty. We check lamports drained rather than + // .is_none(), which is stricter than needed. + assert_eq!( + result.account(&sc.leased_vault).map(|a| a.lamports).unwrap_or(0), + 0 + ); + assert_eq!( + result.account(&sc.collateral_vault).map(|a| a.lamports).unwrap_or(0), + 0 + ); +} + +#[test] +fn liquidate_seizes_collateral_on_price_drop() { + let (mut svm, sc) = make_scenario(); + make_and_take(&mut svm, &sc); + + // Let 300 s of rent accrue so the handler settles rent *and* bounty + // on the same vault balance. + let elapsed: i64 = 300; + let now_ts = DEFAULT_TIMESTAMP + elapsed; + svm.warp_to_timestamp(now_ts); + + // Price 4.0 with exponent 0 — debt = 400 collateral vs. 200 held, + // ratio 50% ≪ 120% margin. + let price_update = Pubkey::new_unique(); + install_price_update(&mut svm, price_update, FEED_ID, 4, 0, now_ts); + + let (ix, accounts) = liquidate_call(&sc, price_update); + let result = svm.process_instruction(&ix, &accounts); + assert!(result.is_ok(), "liquidate failed: {:?}", result.raw_result); + + let rent_paid = (elapsed as u64) * RENT_PER_SECOND; + let remaining_after_rent = REQUIRED_COLLATERAL - rent_paid; + let bounty = remaining_after_rent * (LIQUIDATION_BOUNTY_BPS as u64) / 10_000; + let lessor_share = remaining_after_rent - bounty; + + assert_eq!( + read_token_amount(result.account(&sc.lessor_collateral_ta).unwrap()), + rent_paid + lessor_share + ); + assert_eq!( + read_token_amount(result.account(&sc.keeper_collateral_ta).unwrap()), + bounty + ); + assert_eq!( + result.account(&sc.leased_vault).map(|a| a.lamports).unwrap_or(0), + 0 + ); + assert_eq!( + result.account(&sc.collateral_vault).map(|a| a.lamports).unwrap_or(0), + 0 + ); +} + +#[test] +fn liquidate_rejects_healthy_position() { + let (mut svm, sc) = make_scenario(); + make_and_take(&mut svm, &sc); + + // Price 1.0 → debt = 100 vs. 200 collateral → ratio 200% ≥ 120%. + let price_update = Pubkey::new_unique(); + install_price_update(&mut svm, price_update, FEED_ID, 1, 0, DEFAULT_TIMESTAMP); + + let (ix, accounts) = liquidate_call(&sc, price_update); + let result = svm.process_instruction(&ix, &accounts); + assert!( + result.is_err(), + "healthy liquidation must fail: {:?}", + result.raw_result + ); +} + +#[test] +fn liquidate_rejects_mismatched_price_feed() { + let (mut svm, sc) = make_scenario(); + make_and_take(&mut svm, &sc); + + // Price that *would* trigger liquidation but with a foreign feed id — + // the feed-pinning check must reject before the undercollateralisation + // math runs. + let wrong_feed_id = [0xCD; 32]; + let price_update = Pubkey::new_unique(); + install_price_update(&mut svm, price_update, wrong_feed_id, 4, 0, DEFAULT_TIMESTAMP); + + let (ix, accounts) = liquidate_call(&sc, price_update); + let result = svm.process_instruction(&ix, &accounts); + assert!( + result.is_err(), + "liquidate must reject foreign price feeds: {:?}", + result.raw_result + ); +} + +#[test] +fn close_expired_reclaims_collateral_after_end_ts() { + let (mut svm, sc) = make_scenario(); + make_and_take(&mut svm, &sc); + + // Jump past end_ts. + svm.warp_to_timestamp(DEFAULT_TIMESTAMP + DURATION_SECONDS + 1); + + let (ix, accounts) = close_expired_call(&sc); + let result = svm.process_instruction(&ix, &accounts); + assert!(result.is_ok(), "close_expired failed: {:?}", result.raw_result); + + // Full collateral forfeited to the lessor. + assert_eq!( + read_token_amount(result.account(&sc.lessor_collateral_ta).unwrap()), + REQUIRED_COLLATERAL + ); + // Lessor's leased balance is only what remained after the initial + // escrow (the lessee kept the tokens on default). + assert_eq!( + read_token_amount(result.account(&sc.lessor_leased_ta).unwrap()), + STARTING_BALANCE - LEASED_AMOUNT + ); + assert_eq!( + result.account(&sc.leased_vault).map(|a| a.lamports).unwrap_or(0), + 0 + ); + assert_eq!( + result.account(&sc.collateral_vault).map(|a| a.lamports).unwrap_or(0), + 0 + ); +} + +#[test] +fn close_expired_cancels_listed_lease() { + let (mut svm, sc) = make_scenario(); + let (create_ix, create_accounts) = create_lease_call(&sc, 1); + let create_result = svm.process_instruction(&create_ix, &create_accounts); + assert!(create_result.is_ok(), "create_lease failed: {:?}", create_result.raw_result); + commit_state( + &mut svm, + &create_result, + &[sc.lease, sc.leased_vault, sc.collateral_vault, sc.lessor_leased_ta], + ); + + // Lessor bails while the lease is still `Listed` — allowed immediately. + let (ix, accounts) = close_expired_call(&sc); + let result = svm.process_instruction(&ix, &accounts); + assert!(result.is_ok(), "close_expired on Listed failed: {:?}", result.raw_result); + + // Lessor recovered the full leased amount. No collateral was posted. + assert_eq!( + read_token_amount(result.account(&sc.lessor_leased_ta).unwrap()), + STARTING_BALANCE + ); + assert_eq!( + result.account(&sc.leased_vault).map(|a| a.lamports).unwrap_or(0), + 0 + ); + assert_eq!( + result.account(&sc.collateral_vault).map(|a| a.lamports).unwrap_or(0), + 0 + ); +} + +#[test] +fn create_lease_rejects_same_mint_for_leased_and_collateral() { + let (mut svm, sc) = make_scenario(); + let (ix, accounts) = create_lease_call_with_mints(&sc, 42, sc.leased_mint, sc.leased_mint); + let result = svm.process_instruction(&ix, &accounts); + assert!( + result.is_err(), + "create_lease must reject identical mints: {:?}", + result.raw_result + ); +} From 33f5ef746e54620091d58af820308218e48d0e83 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Wed, 22 Apr 2026 21:21:27 +0000 Subject: [PATCH 09/41] docs(asset-leasing): drop 'SPL Token' qualifier, just say 'token' Tokens are the default on Solana; no need to qualify with 'SPL'. Reserve 'SPL Token' for the rare case of contrasting with the native token (SOL). - README account table: 'SPL Token' -> 'token account' (matches what the column actually describes: an ATA, not a token type) - README prose: '6-decimal SPL tokens' -> '6-decimal tokens', 'same SPL mint' -> 'same mint' - Cargo.toml description: 'Fixed-term SPL token leasing' -> 'Fixed-term token leasing' Left SPL_TOKEN_PROGRAM_ID identifiers alone -- those are program IDs, not prose. --- defi/asset-leasing/anchor/README.md | 14 +++++++------- .../anchor/programs/asset-leasing/Cargo.toml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 8d8f349dd..5489246fa 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -147,11 +147,11 @@ three PDAs are created on `create_lease` and destroyed on `return_lease` | `lessee` wallet | user | `take_lease` / `top_up_collateral` / `return_lease` signer | | `keeper` wallet | user | `liquidate` signer, receives the bounty | | `payer` wallet | user | `pay_rent` signer (can be anyone, not just the lessee) | -| `lessor_leased_account` | SPL Token | lessor's ATA for the leased mint; source on `create_lease`, destination on `return_lease` / `close_expired` | -| `lessor_collateral_account` | SPL Token | lessor's ATA for the collateral mint; destination for rent and liquidation proceeds | -| `lessee_leased_account` | SPL Token | lessee's ATA for the leased mint; destination on `take_lease`, source on `return_lease` | -| `lessee_collateral_account` | SPL Token | lessee's ATA for the collateral mint; source on `take_lease` / `top_up_collateral`, destination for collateral refund on `return_lease` | -| `keeper_collateral_account` | SPL Token | keeper's ATA for the collateral mint; receives the liquidation bounty | +| `lessor_leased_account` | token account | lessor's ATA for the leased mint; source on `create_lease`, destination on `return_lease` / `close_expired` | +| `lessor_collateral_account` | token account | lessor's ATA for the collateral mint; destination for rent and liquidation proceeds | +| `lessee_leased_account` | token account | lessee's ATA for the leased mint; destination on `take_lease`, source on `return_lease` | +| `lessee_collateral_account` | token account | lessee's ATA for the collateral mint; source on `take_lease` / `top_up_collateral`, destination for collateral refund on `return_lease` | +| `keeper_collateral_account` | token account | keeper's ATA for the collateral mint; receives the liquidation bounty | | `price_update` | Pyth Receiver program | `PriceUpdateV2` account for the feed the lease is pinned to | ### Fields on `Lease` @@ -628,7 +628,7 @@ closed; all three rent-exempt lamport refunds go to the lessor. ## 4. Full-lifecycle worked examples All three use the same starting numbers so the arithmetic is easy to -follow. Both mints are 6-decimal SPL tokens. "LEASED" means one base +follow. Both mints are 6-decimal tokens. "LEASED" means one base unit of the leased mint; "COLLA" means one base unit of the collateral mint. @@ -824,7 +824,7 @@ handler: cannot fail because the lessor spent the funds elsewhere in the meantime. -- **Leased mint ≠ collateral mint.** If both sides used the same SPL +- **Leased mint ≠ collateral mint.** If both sides used the same mint, the two vaults would hold the same asset and the "what-do-I-owe-vs-what-do-I-hold" accounting would collapse. The guard is cheap and the error message is explicit. diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml b/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml index dfd7ece5b..99faa0ce1 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml +++ b/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "asset-leasing" version = "0.1.0" -description = "Fixed-term SPL token leasing with collateral and Pyth-priced liquidation" +description = "Fixed-term token leasing with collateral and Pyth-priced liquidation" edition = "2021" [lib] From 001ca85c39245c24c4f5d3acf66ca0bcc1684b72 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Wed, 22 Apr 2026 21:26:40 +0000 Subject: [PATCH 10/41] refactor(asset-leasing): alias SPL_TOKEN_PROGRAM_ID to TOKEN_PROGRAM_ID in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Solana, 'token' is the default — the 'SPL' prefix is only meaningful when contrasting with the native token (SOL). The upstream quasar-svm crate exports the constant as SPL_TOKEN_PROGRAM_ID; since we can't rename that without touching the dependency, we alias it on import and use the clean TOKEN_PROGRAM_ID name throughout the test module. 12 tests still pass. --- defi/asset-leasing/quasar/src/tests.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/defi/asset-leasing/quasar/src/tests.rs b/defi/asset-leasing/quasar/src/tests.rs index 8edbe828d..2a3673cdd 100644 --- a/defi/asset-leasing/quasar/src/tests.rs +++ b/defi/asset-leasing/quasar/src/tests.rs @@ -13,6 +13,9 @@ extern crate std; use { alloc::{vec, vec::Vec}, + // Alias the SPL-prefixed constant away: on Solana, "token" is the default; + // the "SPL" qualifier is only useful when contrasting with the native token (SOL). + quasar_svm::SPL_TOKEN_PROGRAM_ID as TOKEN_PROGRAM_ID, quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, solana_instruction::AccountMeta, spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, @@ -272,7 +275,7 @@ fn create_lease_call(sc: &Scenario, lease_id: u64) -> (Instruction, Vec AccountMeta::new(sc.leased_vault, false), AccountMeta::new(sc.collateral_vault, false), AccountMeta::new_readonly(quasar_svm::solana_sdk_ids::sysvar::rent::ID, false), - AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), AccountMeta::new_readonly(quasar_svm::system_program::ID, false), ], data: build_create_lease_data( @@ -349,7 +352,7 @@ fn create_lease_call_with_mints( AccountMeta::new(sc.leased_vault, false), AccountMeta::new(sc.collateral_vault, false), AccountMeta::new_readonly(quasar_svm::solana_sdk_ids::sysvar::rent::ID, false), - AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), AccountMeta::new_readonly(quasar_svm::system_program::ID, false), ], data: build_create_lease_data( @@ -435,7 +438,7 @@ fn take_lease_call(sc: &Scenario) -> (Instruction, Vec) { AccountMeta::new(sc.collateral_vault, false), AccountMeta::new(sc.lessee_collateral_ta, false), AccountMeta::new(sc.lessee_leased_ta, false), - AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), ], data: vec![1u8], // discriminator = take_lease }; @@ -467,7 +470,7 @@ fn pay_rent_call(sc: &Scenario) -> (Instruction, Vec) { AccountMeta::new_readonly(sc.collateral_mint, false), AccountMeta::new(sc.collateral_vault, false), AccountMeta::new(sc.lessor_collateral_ta, false), - AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), ], data: vec![2u8], }; @@ -492,7 +495,7 @@ fn top_up_call(sc: &Scenario, amount: u64) -> (Instruction, Vec) { AccountMeta::new_readonly(sc.collateral_mint, false), AccountMeta::new(sc.collateral_vault, false), AccountMeta::new(sc.lessee_collateral_ta, false), - AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), ], data, }; @@ -519,7 +522,7 @@ fn return_lease_call(sc: &Scenario) -> (Instruction, Vec) { AccountMeta::new(sc.lessee_collateral_ta, false), AccountMeta::new(sc.lessor_leased_ta, false), AccountMeta::new(sc.lessor_collateral_ta, false), - AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), ], data: vec![4u8], }; @@ -547,7 +550,7 @@ fn liquidate_call(sc: &Scenario, price_update: Pubkey) -> (Instruction, Vec (Instruction, Vec) { AccountMeta::new(sc.collateral_vault, false), AccountMeta::new(sc.lessor_leased_ta, false), AccountMeta::new(sc.lessor_collateral_ta, false), - AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), ], data: vec![6u8], }; From 709542ecff9db5c6c8a4ab1ac1f34ae493dcdd44 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Wed, 22 Apr 2026 21:27:33 +0000 Subject: [PATCH 11/41] Revert "refactor(asset-leasing): alias SPL_TOKEN_PROGRAM_ID to TOKEN_PROGRAM_ID in tests" This reverts commit 001ca85c39245c24c4f5d3acf66ca0bcc1684b72. --- defi/asset-leasing/quasar/src/tests.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/defi/asset-leasing/quasar/src/tests.rs b/defi/asset-leasing/quasar/src/tests.rs index 2a3673cdd..8edbe828d 100644 --- a/defi/asset-leasing/quasar/src/tests.rs +++ b/defi/asset-leasing/quasar/src/tests.rs @@ -13,9 +13,6 @@ extern crate std; use { alloc::{vec, vec::Vec}, - // Alias the SPL-prefixed constant away: on Solana, "token" is the default; - // the "SPL" qualifier is only useful when contrasting with the native token (SOL). - quasar_svm::SPL_TOKEN_PROGRAM_ID as TOKEN_PROGRAM_ID, quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, solana_instruction::AccountMeta, spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, @@ -275,7 +272,7 @@ fn create_lease_call(sc: &Scenario, lease_id: u64) -> (Instruction, Vec AccountMeta::new(sc.leased_vault, false), AccountMeta::new(sc.collateral_vault, false), AccountMeta::new_readonly(quasar_svm::solana_sdk_ids::sysvar::rent::ID, false), - AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), AccountMeta::new_readonly(quasar_svm::system_program::ID, false), ], data: build_create_lease_data( @@ -352,7 +349,7 @@ fn create_lease_call_with_mints( AccountMeta::new(sc.leased_vault, false), AccountMeta::new(sc.collateral_vault, false), AccountMeta::new_readonly(quasar_svm::solana_sdk_ids::sysvar::rent::ID, false), - AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), AccountMeta::new_readonly(quasar_svm::system_program::ID, false), ], data: build_create_lease_data( @@ -438,7 +435,7 @@ fn take_lease_call(sc: &Scenario) -> (Instruction, Vec) { AccountMeta::new(sc.collateral_vault, false), AccountMeta::new(sc.lessee_collateral_ta, false), AccountMeta::new(sc.lessee_leased_ta, false), - AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), ], data: vec![1u8], // discriminator = take_lease }; @@ -470,7 +467,7 @@ fn pay_rent_call(sc: &Scenario) -> (Instruction, Vec) { AccountMeta::new_readonly(sc.collateral_mint, false), AccountMeta::new(sc.collateral_vault, false), AccountMeta::new(sc.lessor_collateral_ta, false), - AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), ], data: vec![2u8], }; @@ -495,7 +492,7 @@ fn top_up_call(sc: &Scenario, amount: u64) -> (Instruction, Vec) { AccountMeta::new_readonly(sc.collateral_mint, false), AccountMeta::new(sc.collateral_vault, false), AccountMeta::new(sc.lessee_collateral_ta, false), - AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), ], data, }; @@ -522,7 +519,7 @@ fn return_lease_call(sc: &Scenario) -> (Instruction, Vec) { AccountMeta::new(sc.lessee_collateral_ta, false), AccountMeta::new(sc.lessor_leased_ta, false), AccountMeta::new(sc.lessor_collateral_ta, false), - AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), ], data: vec![4u8], }; @@ -550,7 +547,7 @@ fn liquidate_call(sc: &Scenario, price_update: Pubkey) -> (Instruction, Vec (Instruction, Vec) { AccountMeta::new(sc.collateral_vault, false), AccountMeta::new(sc.lessor_leased_ta, false), AccountMeta::new(sc.lessor_collateral_ta, false), - AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), ], data: vec![6u8], }; From 5f35c768eed836f3012323b6903c1a76b20133b0 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Mon, 27 Apr 2026 23:00:24 +0000 Subject: [PATCH 12/41] docs+code: rename "rent" to "lease fee" throughout asset-leasing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Rent" collides with Solana's account-rent concept. Switching to "lease fee" makes the per-second payment from lessee to lessor unambiguous. Also tightens the README: - Drops the "this README is a teaching document" / "It is not a deployed, audited production program" / "treat it as a learning example" framing — true of every program in program-examples by definition. - Drops the disambiguation disclaimer about Solana account rent vs lease rent (no longer needed once the per-second payment is named the lease fee). - Drops fungibility caveats around what the lessee returns — just says they return leased_amount of leased_mint. - Switches "neutral escrow" to "non-custodial escrow". - Adds a concrete xNVDA / USDC worked example with both rallying- and falling-NVIDIA scenarios so the asymmetric-payoff (short-like) shape of the lessee position is explicit. - Adds §4.3 (falling-price path) covering the case where the leased asset depreciates and the lessee benefits. Code rename details: - Field: Lease.rent_per_second -> Lease.lease_fee_per_second - Field: Lease.last_rent_paid_ts -> Lease.last_paid_ts - Function: compute_rent_due -> compute_lease_fee_due - Instruction: pay_rent -> pay_lease_fee (module / handler / Accounts struct / source filename) - Error: InvalidRentPerSecond -> InvalidLeaseFeePerSecond - Local variables and code comments updated to match. - Solana terminology kept verbatim where it really means account rent (rent-exempt lamports, Sysvar, sysvar::rent::ID, rent_epoch, etc.). The Quasar port is renamed in lockstep so the two implementations stay byte-for-byte identical at the IDL level (same discriminators, same Lease layout). Quasar's tests are renamed to match. All 11 LiteSVM tests pass after the rename. Quasar `quasar build` also succeeds. --- defi/asset-leasing/anchor/README.md | 329 ++++++++++++------ .../programs/asset-leasing/src/errors.rs | 4 +- .../src/instructions/close_expired.rs | 16 +- .../src/instructions/create_lease.rs | 12 +- .../src/instructions/liquidate.rs | 20 +- .../asset-leasing/src/instructions/mod.rs | 4 +- .../{pay_rent.rs => pay_lease_fee.rs} | 36 +- .../src/instructions/return_lease.rs | 28 +- .../src/instructions/take_lease.rs | 2 +- .../anchor/programs/asset-leasing/src/lib.rs | 12 +- .../programs/asset-leasing/src/state/lease.rs | 14 +- .../asset-leasing/tests/test_asset_leasing.rs | 72 ++-- defi/asset-leasing/quasar/src/errors.rs | 2 +- .../quasar/src/instructions/close_expired.rs | 8 +- .../quasar/src/instructions/create_lease.rs | 12 +- .../quasar/src/instructions/liquidate.rs | 20 +- .../quasar/src/instructions/mod.rs | 4 +- .../{pay_rent.rs => pay_lease_fee.rs} | 30 +- .../quasar/src/instructions/return_lease.rs | 28 +- .../quasar/src/instructions/take_lease.rs | 2 +- defi/asset-leasing/quasar/src/lib.rs | 10 +- defi/asset-leasing/quasar/src/state.rs | 12 +- defi/asset-leasing/quasar/src/tests.rs | 58 +-- 23 files changed, 421 insertions(+), 314 deletions(-) rename defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/{pay_rent.rs => pay_lease_fee.rs} (77%) rename defi/asset-leasing/quasar/src/instructions/{pay_rent.rs => pay_lease_fee.rs} (76%) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 5489246fa..80627df76 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -1,18 +1,14 @@ # Asset Leasing -A fixed-term token lease on Solana, with a second-by-second rent stream, -a separate collateral deposit, and a Pyth-oracle-triggered seizure path -when the collateral is no longer worth enough. +A fixed-term token lease on Solana, with a second-by-second lease fee +stream, a separate collateral deposit, and a Pyth-oracle-triggered +seizure path when the collateral is no longer worth enough. -This README is a teaching document. If you have never written a Solana -program before and have no background in finance, you are the target -reader — every instruction handler is walked through step by step with -the exact token movements it causes. - -If you already know what collateral, a maintenance margin and an oracle -are, you can skip straight to the [Accounts and PDAs](#2-accounts-and-pdas) -or [Instruction handler lifecycle walkthrough](#3-instruction-handler-lifecycle-walkthrough) -sections. +Every instruction handler is walked through with the exact token +movements it causes. If you already know what collateral, a +maintenance margin and an oracle are, you can skip straight to +[Accounts and PDAs](#2-accounts-and-pdas) or +[Instruction handler lifecycle walkthrough](#3-instruction-handler-lifecycle-walkthrough). Solana terminology is defined at https://solana.com/docs/terminology. Terms specific to this program are explained inline when they first @@ -44,18 +40,14 @@ temporarily: - The lessee has tokens of a different mint **B** (the "collateral mint") they can lock up as a security deposit. -The program acts as a neutral escrow. It: +The program acts as a non-custodial escrow. It: 1. Takes the lessor's A tokens and locks them in a program-owned vault until a lessee shows up. 2. When a lessee calls `take_lease`, the program locks the lessee's B tokens as collateral and hands the A tokens to the lessee. -3. While the lease is live, a second-by-second **rent stream** pays the - lessor out of the collateral vault. "Rent" here is the per-second - payment the lessee owes the lessor for use of the leased tokens; it - is unrelated to Solana account rent (the lamports deposit that keeps - an account alive). Same word, different meaning — context usually - makes the intent obvious, and where it doesn't the text says so. +3. While the lease is live, a second-by-second **lease fee stream** + pays the lessor out of the collateral vault. 4. If the price of A (measured in B) moves against the lessee far enough that the locked collateral is no longer enough to cover the cost of re-acquiring the leased tokens, anyone can call `liquidate` — the @@ -65,7 +57,7 @@ The program acts as a neutral escrow. It: watches the chain for positions that have gone underwater and earns the bounty by cleaning them up. 5. If the lessee returns the full A amount before the deadline, they get - back whatever collateral is left after rent. + back whatever collateral is left after lease fees. 6. If the lessee ghosts past the deadline without returning anything, the lessor calls `close_expired` and sweeps the collateral as compensation. @@ -76,16 +68,13 @@ collateral value to debt value. `maintenance_margin_bps = 12_000` is 120%, meaning the collateral must stay worth at least 1.2× the leased tokens. Drop below and the position becomes liquidatable. -Nothing mysterious: the program is a pair of vaults, a small piece of -state that tracks how much rent has been paid, and an oracle check. It -is written in Anchor. +The program is a pair of vaults, a small piece of state that tracks +how much has been paid, and an oracle check. It is written in Anchor. ### The tradfi picture, briefly -For readers who have never encountered a real-world margin or -securities-lending arrangement — two quick analogies from finance. -They are strictly optional; the program is fully described above in -Solana terms. +Two analogies from finance for the uninitiated; the on-chain mechanics +above are the canonical description. - **Leasing gold bars from a bullion dealer.** The dealer hands over a fixed amount of physical gold for a fixed period; the counterparty @@ -106,17 +95,81 @@ Solana terms. buy-back. Neither analogy is exact — real bullion leases and real securities -lending add features this example doesn't model (recall rights, rebate -rates, haircuts). The on-chain mechanics are what matters below. +lending add features the program doesn't model (recall rights, rebate +rates, haircuts). + +### Worked example: leasing xNVDA against USDC + +Concrete numbers using assets that already trade on Solana — +[xNVDA](https://www.backed.fi/) (a Backed Finance / xStocks tokenised +NVIDIA share) and USDC. xNVDA has its own Pyth feed; the program +takes the feed id verbatim at `create_lease`. -### What this example is not +Alice holds 100 xNVDA at ~$180 / share, ~$18 000 notional. She wants +yield without selling the underlying. -- **It is not a deployed, audited production program.** Treat it as a - learning example. It makes simplifying choices (see §5) that a - production lease protocol would need to revisit. -- **It does not pretend to match mainnet Pyth behaviour exactly.** The - LiteSVM tests install a hand-rolled `PriceUpdateV2` account; on - mainnet you would use the real Pyth Receiver crate. +Bob wants short exposure to NVIDIA without using a perp. + +Alice lists the lease (assume USDC is 6-decimal, xNVDA is also +6-decimal for round numbers): + +| Parameter | Value | Notes | +|---|---|---| +| `leased_amount` | `100_000_000` (100 xNVDA) | | +| `required_collateral_amount` | `22_000_000_000` (22 000 USDC) | ~122% LTV at the spot price | +| `lease_fee_per_second` | `456` (USDC base units / s) | ≈ 8% APR on 18 000 USDC notional | +| `duration_seconds` | `2_592_000` | 30 days | +| `maintenance_margin_bps` | `11_000` | 110% | +| `liquidation_bounty_bps` | `100` | 1% of post-fee collateral | +| `feed_id` | Pyth xNVDA/USD feed id | ([Pyth feed registry](https://www.pyth.network/price-feeds)) | + +Bob calls `take_lease`, posts 22 000 USDC, receives 100 xNVDA, and +sells them on Jupiter for ~18 000 USDC at the spot price. + +#### If NVIDIA rallies to $200 + +- Bob's debt to repurchase the 100 xNVDA is now `100 × $200 = $20 000`. +- Collateral ratio: `22 000 / 20 000 = 110%` — exactly at the + maintenance margin. +- One more upward tick and a keeper can call `liquidate` with a fresh + Pyth update. Of the 22 000 USDC vault: a small portion has + already streamed out as lease fees (Bob's incentive to keep paying + was to keep the position alive); of what's left, 1% goes to the + keeper as the bounty (~220 USDC), the rest to Alice. +- Bob can avoid this by: + - Calling `top_up_collateral` to push the ratio back above 110%, or + - Buying 100 xNVDA on the open market and calling `return_lease` to + close out cleanly. + +#### If NVIDIA falls to $160 + +- Bob's debt drops to `100 × $160 = $16 000`. +- Collateral ratio: `22 000 / 16 000 = 137.5%` — well above the 110% + maintenance margin. No liquidation pressure. +- Bob buys back 100 xNVDA on Jupiter for ~16 000 USDC and calls + `return_lease`. Alice receives the 100 xNVDA back plus the + accrued lease fee. The remaining ~22 000 USDC (minus fees paid) + refunds to Bob. +- Bob's profit ≈ `$18 000 − $16 000 − fees − trading costs ≈ $2 000` + minus carry — the same payoff shape as a 30-day short on NVIDIA. + +The asymmetry: liquidation only ever fires when the *leased* asset +rallies against the collateral. A drop in the leased asset price is +purely beneficial to the lessee. The streaming lease fee is the +position's only ongoing cost in either direction. + +§4 walks the on-chain token flows for each path with abstract numbers +that match the LiteSVM tests; the example above is the same machinery +applied to a real asset pair. + +### Production deviations to know + +- **Pyth integration is hand-rolled, not via the SDK.** The LiteSVM + tests install a `PriceUpdateV2` account whose layout is decoded + inline in `liquidate.rs`. Production code would depend on the + `pyth-solana-receiver-sdk` crate so layout changes are caught at + compile time. +- See §5 for the rest of the deliberate simplifications. --- @@ -137,18 +190,18 @@ three PDAs are created on `create_lease` and destroyed on `return_lease` | Account | PDA? | Seeds | Kind | Authority | Holds | |---|---|---|---|---|---| | `leased_vault` | yes | `["leased_vault", lease]` | token account | itself (PDA-signed) | `leased_amount` while `Listed`; 0 while `Active` (lessee has the tokens); full amount again briefly inside `return_lease` | -| `collateral_vault` | yes | `["collateral_vault", lease]` | token account | itself (PDA-signed) | 0 while `Listed`; `collateral_amount` while `Active`, decreasing as rent streams out and increasing on `top_up_collateral` | +| `collateral_vault` | yes | `["collateral_vault", lease]` | token account | itself (PDA-signed) | 0 while `Listed`; `collateral_amount` while `Active`, decreasing as lease fee streams out and increasing on `top_up_collateral` | ### User accounts passed in | Account | Owner | Purpose | |---|---|---| -| `lessor` wallet | user | `create_lease` signer, receives rent and final recovery | +| `lessor` wallet | user | `create_lease` signer, receives the lease fee and final recovery | | `lessee` wallet | user | `take_lease` / `top_up_collateral` / `return_lease` signer | | `keeper` wallet | user | `liquidate` signer, receives the bounty | -| `payer` wallet | user | `pay_rent` signer (can be anyone, not just the lessee) | +| `payer` wallet | user | `pay_lease_fee` signer (can be anyone, not just the lessee) | | `lessor_leased_account` | token account | lessor's ATA for the leased mint; source on `create_lease`, destination on `return_lease` / `close_expired` | -| `lessor_collateral_account` | token account | lessor's ATA for the collateral mint; destination for rent and liquidation proceeds | +| `lessor_collateral_account` | token account | lessor's ATA for the collateral mint; destination for the lease fee and liquidation proceeds | | `lessee_leased_account` | token account | lessee's ATA for the leased mint; destination on `take_lease`, source on `return_lease` | | `lessee_collateral_account` | token account | lessee's ATA for the collateral mint; source on `take_lease` / `top_up_collateral`, destination for collateral refund on `return_lease` | | `keeper_collateral_account` | token account | keeper's ATA for the collateral mint; receives the liquidation bounty | @@ -161,21 +214,21 @@ From [`state/lease.rs`](programs/asset-leasing/src/state/lease.rs): ```rust pub struct Lease { pub lease_id: u64, // caller-supplied id so one lessor can run many leases - pub lessor: Pubkey, // who listed it, gets paid rent + pub lessor: Pubkey, // who listed it, gets paid the lease fee pub lessee: Pubkey, // who took it; Pubkey::default() while Listed pub leased_mint: Pubkey, pub leased_amount: u64, // locked at creation, unchanging pub collateral_mint: Pubkey, - pub collateral_amount: u64, // increases on top_up, decreases as rent pays out + pub collateral_amount: u64, // increases on top_up, decreases as lease fees pay out pub required_collateral_amount: u64, // what the lessee must post on take_lease - pub rent_per_second: u64, // denominated in collateral units + pub lease_fee_per_second: u64, // denominated in collateral units pub duration_seconds: i64, pub start_ts: i64, // 0 while Listed pub end_ts: i64, // 0 while Listed; start_ts + duration once Active - pub last_rent_paid_ts: i64, // rent accrues from here to min(now, end_ts) + pub last_paid_ts: i64, // Lease fee accrues from here to min(now, end_ts) pub maintenance_margin_bps: u16, // e.g. 12_000 = 120% pub liquidation_bounty_bps: u16, // e.g. 500 = 5% @@ -231,7 +284,7 @@ them — the order below — is: 1. `create_lease` (lessor) 2. `take_lease` (lessee) -3. `pay_rent` (anyone) +3. `pay_lease_fee` (anyone) 4. `top_up_collateral` (lessee) 5. `return_lease` (lessee) — **happy path** 6. `liquidate` (keeper) — **adversarial path** @@ -262,7 +315,7 @@ pub fn create_lease( lease_id: u64, leased_amount: u64, required_collateral_amount: u64, - rent_per_second: u64, + lease_fee_per_second: u64, duration_seconds: i64, maintenance_margin_bps: u16, liquidation_bounty_bps: u16, @@ -291,7 +344,7 @@ pub fn create_lease( - `leased_mint != collateral_mint` → `LeasedMintEqualsCollateralMint` - `leased_amount > 0` → `InvalidLeasedAmount` - `required_collateral_amount > 0` → `InvalidCollateralAmount` -- `rent_per_second > 0` → `InvalidRentPerSecond` +- `lease_fee_per_second > 0` → `InvalidLeaseFeePerSecond` - `duration_seconds > 0` → `InvalidDuration` - `0 < maintenance_margin_bps <= 50_000` → `InvalidMaintenanceMargin` - `liquidation_bounty_bps <= 2_000` → `InvalidLiquidationBounty` @@ -306,7 +359,7 @@ pub fn create_lease( - New `Lease` account written with `status = Listed`, `lessee = Pubkey::default()`, `collateral_amount = 0`, `start_ts = 0`, - `end_ts = 0`, `last_rent_paid_ts = 0`, and the given parameters + `end_ts = 0`, `last_paid_ts = 0`, and the given parameters including `feed_id`. All three bumps stored. **Why lock the leased tokens up-front rather than on `take_lease`?** So a @@ -359,13 +412,13 @@ collateral back. - `lease.collateral_amount = required_collateral_amount` - `lease.start_ts = now` - `lease.end_ts = now + duration_seconds` (checked add, errors on overflow) -- `lease.last_rent_paid_ts = now` (nothing has accrued yet) +- `lease.last_paid_ts = now` (nothing has accrued yet) - `lease.status = Active` -### 3.3 `pay_rent` +### 3.3 `pay_lease_fee` **Who calls it:** anyone. The lessee's incentive is obvious (keep the -lease from going underwater); a keeper bot may also push rent before a +lease from going underwater); a keeper bot may also push a lease fee payment before a liquidation check so healthy leases stay healthy. **Signers:** `payer` (any signer). @@ -379,39 +432,39 @@ liquidation check so healthy leases stay healthy. - `lessor_collateral_account` (mut, **init_if_needed**) - `token_program`, `associated_token_program`, `system_program` -**Rent math:** +**Lease fee math:** ```rust -pub fn compute_rent_due(lease: &Lease, now: i64) -> Result { +pub fn compute_lease_fee_due(lease: &Lease, now: i64) -> Result { let cutoff = now.min(lease.end_ts); - if cutoff <= lease.last_rent_paid_ts { + if cutoff <= lease.last_paid_ts { return Ok(0); } - let elapsed = (cutoff - lease.last_rent_paid_ts) as u64; - elapsed.checked_mul(lease.rent_per_second) + let elapsed = (cutoff - lease.last_paid_ts) as u64; + elapsed.checked_mul(lease.lease_fee_per_second) .ok_or(AssetLeasingError::MathOverflow.into()) } ``` -Rent does not accrue past `end_ts`. Past the deadline the lessee is +Lease fees do not accrue past `end_ts`. Past the deadline the lessee is either returning the tokens (via `return_lease`), being liquidated, or -defaulting — no more rent is owed. +defaulting — no more lease fees are owed. **Token movements:** ``` - collateral_vault PDA --[min(rent_due, collateral_amount) of collateral_mint]--> lessor_collateral_account + collateral_vault PDA --[min(lease_fee_due, collateral_amount) of collateral_mint]--> lessor_collateral_account ``` If the vault does not have enough collateral to cover the full -`rent_due`, the handler pays out whatever is there and leaves the +`lease_fee_due`, the handler pays out whatever is there and leaves the residual as a debt the next liquidation (or `close_expired`) will clean up. **State changes:** - `lease.collateral_amount -= payable` -- `lease.last_rent_paid_ts = now.min(end_ts)` +- `lease.last_paid_ts = now.min(end_ts)` ### 3.4 `top_up_collateral` @@ -452,7 +505,7 @@ by adding more of the collateral mint to the vault. **Who calls it:** the lessee, while the lease is still `Active` and before or after `end_ts` (the only timing rule is that `status == -Active`; rent only accrues up to `end_ts` so returning after the +Active`; Lease fees only accrue up to `end_ts` so returning after the deadline does not pile on extra charges). **Signers:** `lessee`. @@ -481,8 +534,8 @@ deadline does not pile on extra charges). ``` lessee_leased_account --[leased_amount of leased_mint]----------> leased_vault PDA leased_vault PDA --[leased_amount of leased_mint]----------> lessor_leased_account - collateral_vault PDA --[rent_payable of collateral_mint]-------> lessor_collateral_account - collateral_vault PDA --[collateral_after_rent of collateral_mint]--> lessee_collateral_account + collateral_vault PDA --[lease_fee_payable of collateral_mint]-------> lessor_collateral_account + collateral_vault PDA --[collateral_after_lease_fees of collateral_mint]--> lessee_collateral_account ``` The leased tokens hop through the vault rather than going direct @@ -499,7 +552,7 @@ After the transfers: **State changes before close:** -- `lease.last_rent_paid_ts = now.min(end_ts)` +- `lease.last_paid_ts = now.min(end_ts)` - `lease.collateral_amount = 0` - `lease.status = Closed` @@ -513,7 +566,7 @@ underwater. **Accounts in:** - `keeper` (signer, mut — pays `init_if_needed` cost for both ATAs) -- `lessor` (UncheckedAccount, mut — receives rent + lessor_share + the +- `lessor` (UncheckedAccount, mut — receives the lease fee + lessor_share + the `Lease` and vault rent-exempt lamports) - `lease` (mut, `close = lessor`, must be `Active`) - `leased_mint`, `collateral_mint` @@ -551,13 +604,13 @@ exponent folded into whichever side keeps the math non-negative, see **Token movements:** ``` - collateral_vault PDA --[rent_payable of collateral_mint]---------------------> lessor_collateral_account + collateral_vault PDA --[lease_fee_payable of collateral_mint]---------------------> lessor_collateral_account collateral_vault PDA --[bounty = remaining * bounty_bps / 10_000]-----------> keeper_collateral_account collateral_vault PDA --[remaining - bounty of collateral_mint]--------------> lessor_collateral_account leased_vault PDA --[0 of leased_mint] (empty — lessee kept the tokens) close only ``` -After the three outbound collateral transfers (rent, bounty, lessor +After the three outbound collateral transfers (lease fee, bounty, lessor share) the collateral_vault is empty. Both vaults are then closed — their rent-exempt lamports go to the lessor. The `Lease` account is closed the same way (Anchor `close = lessor`). @@ -565,7 +618,7 @@ closed the same way (Anchor `close = lessor`). **State changes before close:** - `lease.collateral_amount = 0` -- `lease.last_rent_paid_ts = now.min(end_ts)` +- `lease.last_paid_ts = now.min(end_ts)` - `lease.status = Liquidated` ### 3.7 `close_expired` @@ -616,7 +669,7 @@ closed; all three rent-exempt lamport refunds go to the lessor. **State changes before close:** -- If `Active`: `lease.last_rent_paid_ts = now.min(end_ts)` +- If `Active`: `lease.last_paid_ts = now.min(end_ts)` (settles the accounting so any future program version that wants to split the default pot differently has a correct timestamp to start from) @@ -634,10 +687,10 @@ mint. - `leased_amount = 100_000_000` LEASED (100 tokens). - `required_collateral_amount = 200_000_000` COLLA (200 tokens). -- `rent_per_second = 10` COLLA. +- `lease_fee_per_second = 10` COLLA. - `duration_seconds = 86_400` (24 hours). - `maintenance_margin_bps = 12_000` (120%). -- `liquidation_bounty_bps = 500` (5% of post-rent collateral). +- `liquidation_bounty_bps = 500` (5% of post-lease-fee collateral). - `feed_id = [0xAB; 32]` (arbitrary, consistent across all calls). Lessor starts with 1 000 000 000 LEASED in their ATA. Lessee starts @@ -662,7 +715,7 @@ Calls, in order: ``` `lease.status = Active`, `start_ts = T`, `end_ts = T + 86_400`. -3. **`pay_rent`** called at `T + 120` seconds. Rent due = 120 × 10 = +3. **`pay_lease_fee`** called at `T + 120` seconds. Lease fee due = 120 × 10 = 1 200 COLLA. ``` collateral_vault PDA --[1_200 COLLA]--> lessor_collateral_account @@ -676,9 +729,9 @@ Calls, in order: ``` `collateral_amount = 199_998_800 + 50_000_000 = 249_998_800`. -5. **`return_lease`** called at `T + 3_600` (one hour in). Total rent +5. **`return_lease`** called at `T + 3_600` (one hour in). Total lease fees from `start_ts` to `now` is 3 600 × 10 = 36 000 COLLA; 1 200 of that - was paid in step 3. Residual rent = 36 000 − 1 200 = 34 800 COLLA. + was paid in step 3. Residual lease fees = 36 000 − 1 200 = 34 800 COLLA. ``` lessee_leased_account --[100_000_000 LEASED]--> leased_vault PDA leased_vault PDA --[100_000_000 LEASED]--> lessor_leased_account @@ -692,12 +745,12 @@ Calls, in order: **Final balances:** -- Lessor: 1 000 000 000 LEASED (full return), 36 000 COLLA (total rent +- Lessor: 1 000 000 000 LEASED (full return), 36 000 COLLA (total lease fees received in steps 3 + 5), plus the lamports from three account closes. - Lessee: 100 000 000 LEASED → 0 (all returned), COLLA: started with 1 000 000 000, spent 200 000 000 on initial deposit + 50 000 000 on top-up, got back 249 964 000, so holds 999 964 000 COLLA (net cost - of 36 000 — exactly the total rent paid). + of 36 000 — exactly the total lease fees paid). ### 4.2 Liquidation path @@ -707,15 +760,15 @@ Same setup. Steps 1 and 2 run identically. the leased-in-collateral price has spiked to 4.0 (exponent 0, price = 4). At that price, the debt value is `100_000_000 × 4 = 400_000_000` COLLA. The collateral is still ~`200_000_000` COLLA - (minus some streamed rent). Maintenance ratio = `200/400 = 50%`, + (minus some streamed lease fees). Maintenance ratio = `200/400 = 50%`, well below the required 120%. - The keeper calls `pay_rent` first is *not* required — `liquidate` - settles accrued rent itself. It goes straight to `liquidate`. + The keeper calls `pay_lease_fee` first is *not* required — `liquidate` + settles accrued lease fees itself. It goes straight to `liquidate`. 4. **`liquidate`** at `T + 300`: - - Rent due = 300 × 10 = 3 000 COLLA; collateral_amount = 200 000 000 - so `rent_payable = 3 000`. + - Lease fee due = 300 × 10 = 3 000 COLLA; collateral_amount = 200 000 000 + so `lease_fee_payable = 3 000`. ``` collateral_vault PDA --[3_000 COLLA]--> lessor_collateral_account ``` @@ -744,12 +797,66 @@ Same setup. Steps 1 and 2 run identically. tokens. The collateral pays the lessor for the lost asset. The lessee has effectively bought the leased tokens at the forfeit price.) -### 4.3 Default / expiry path — `close_expired` on an `Active` lease +### 4.3 Falling-price path — lessee benefits + +Liquidation is a one-sided risk: it only ever fires when the leased +asset *appreciates* against the collateral. If the leased asset +depreciates, the collateral ratio rises and the lessee's position +gets safer. Mechanically the position behaves like a short on the +leased asset — gains accrue to the lessee, the only ongoing cost is +the streaming lease fee. + +Same setup. Steps 1 and 2 run identically. + +3. Time jumps to `T + 300`. The leased-in-collateral price has + *fallen* to 0.5 (exponent 0, price = 0). To make the math + non-trivial, take exponent = −1, price = 5: the debt value is + `100_000_000 × 5 / 10 = 50_000_000` COLLA. The collateral is + ~`200_000_000` COLLA (minus a tiny bit of streamed lease fees). + Maintenance ratio = `200_000_000 / 50_000_000 = 400%`, far above + the required 120%. + + A keeper calling `liquidate` here would fail with + `PositionHealthy` — the program refuses to seize a healthy + position. The lessee is in the clear. + +4. **`return_lease`** called at `T + 600` (10 minutes in). The + lessee buys 100 LEASED on the open market at the new price (about + 50 COLLA total — far less than the 200 COLLA they posted), then + returns those tokens to close out the lease. + + Lease fees accrued: 600 × 10 = 6 000 COLLA. + + ``` + lessee_leased_account --[100_000_000 LEASED]--> leased_vault PDA + leased_vault PDA --[100_000_000 LEASED]--> lessor_leased_account + collateral_vault PDA --[6_000 COLLA]---------> lessor_collateral_account + collateral_vault PDA --[199_994_000 COLLA]---> lessee_collateral_account + ``` + +**Final balances:** + +- Lessor: 1 000 000 000 LEASED (full return), 6 000 COLLA in lease + fees. +- Lessee: 100 000 000 LEASED received → bought 100 LEASED back at + the lower price → returned them. Their net cost is the lease fee + (6 000 COLLA) plus whatever they paid on the open market for the + replacement leased tokens; their gain is the difference between + what they originally received the leased tokens at versus what + they paid to re-acquire them. + +This is the same payoff shape as a short on the leased asset: the +lessee profits from price drops and pays a small carry (the lease +fee) for the duration. Only adverse moves trigger liquidation, and +the lessee can defend a borderline position with `top_up_collateral` +or close it early via `return_lease`. + +### 4.4 Default / expiry path — `close_expired` on an `Active` lease Same setup. Steps 1 and 2 run as usual. The lessee takes the tokens, posts collateral, then disappears. -3. `pay_rent` is never called. Clock advances all the way past +3. `pay_lease_fee` is never called. Clock advances all the way past `end_ts = T + 86_400`. 4. **`close_expired`** called by the lessor at `T + 100_000`: @@ -761,7 +868,7 @@ posts collateral, then disappears. collateral_vault PDA --[200_000_000 COLLA]--> lessor_collateral_account ``` - Both vaults close; Lease closes. - - `last_rent_paid_ts = min(now, end_ts) = end_ts` (step added in + - `last_paid_ts = min(now, end_ts) = end_ts` (step added in Fix 5). **Final balances:** @@ -771,7 +878,7 @@ posts collateral, then disappears. - Lessee: 100 000 000 LEASED, −200 000 000 COLLA. They paid the whole collateral and kept the leased tokens. -### 4.4 Default / expiry path — `close_expired` on a `Listed` lease +### 4.5 Default / expiry path — `close_expired` on a `Listed` lease This is the cheap cancel path. No lessee ever showed up. @@ -804,10 +911,10 @@ handler: | `InvalidDuration` | `duration_seconds <= 0` on `create_lease` | | `InvalidLeasedAmount` | `leased_amount == 0` on `create_lease` | | `InvalidCollateralAmount` | `required_collateral_amount == 0` on `create_lease`; `amount == 0` on `top_up_collateral` | -| `InvalidRentPerSecond` | `rent_per_second == 0` on `create_lease` | +| `InvalidLeaseFeePerSecond` | `lease_fee_per_second == 0` on `create_lease` | | `InvalidMaintenanceMargin` | `maintenance_margin_bps == 0` or `> 50_000` on `create_lease` | | `InvalidLiquidationBounty` | `liquidation_bounty_bps > 2_000` on `create_lease` | -| `LeaseExpired` | Reserved; not currently used (rent accrual naturally caps at `end_ts`) | +| `LeaseExpired` | Reserved; not currently used (Lease fee accrual naturally caps at `end_ts`) | | `LeaseNotExpired` | `close_expired` called on an `Active` lease before `end_ts` | | `PositionHealthy` | `liquidate` called on a lease that passes the maintenance-margin check | | `StalePrice` | Pyth price update older than 60 s, or has a future `publish_time`, or fails discriminator / length check | @@ -862,7 +969,7 @@ handler: ### 5.3 Things the program does *not* guard against -A production lease protocol would want more, but this is an example: +A production protocol would want more: - **Price feed correctness.** The program verifies the owner (`PYTH_RECEIVER_PROGRAM_ID`), the discriminator, the layout and the @@ -871,26 +978,27 @@ A production lease protocol would want more, but this is an example: lessor's problem — it won't cause a liquidation to succeed against a truly healthy position (the feed id check would fail), but it will mean *no* liquidation can succeed, so a lessee could drain the - collateral via rent and walk away. A production version would cross- + collateral via lease fees and walk away. A production version would cross- check the price feed's `feed_id` against a protocol registry. -- **Rent dust accumulation.** Rent is paid in whole base units per - second of `rent_per_second`. Choose a small `rent_per_second` and - short-lived leases can settle 0 rent if no-one calls `pay_rent` for - a very short period. Not a security issue — the accrual ts only - moves forward when rent is actually settled — but worth knowing. +- **Lease-fee dust accumulation.** Lease fees are paid in whole base + units per second of `lease_fee_per_second`. Choose a small + `lease_fee_per_second` and short-lived leases can settle 0 lease + fees if no-one calls `pay_lease_fee` for a very short period. Not a + security issue — the accrual ts only moves forward when the lease + fee is actually settled — but worth knowing. -- **Griefing on `init_if_needed`.** `take_lease`, `pay_rent`, +- **Griefing on `init_if_needed`.** `take_lease`, `pay_lease_fee`, `liquidate`, `return_lease` and `close_expired` all do `init_if_needed` on one or more ATAs. If the caller does not fund the rent-exempt reserve for those accounts, the transaction fails. This is the intended behaviour (the caller pays for the state they require) but can surprise a lessee on a tight SOL budget. -- **No partial rent refund on default.** When `close_expired` runs on +- **No partial lease-fee refund on default.** When `close_expired` runs on an `Active` lease, the lessor takes the entire collateral regardless - of how much rent had actually accrued by then. This is a deliberate - simplification — the `last_rent_paid_ts` bookkeeping in Fix 5 is in + of how many lease fees had actually accrued by then. This is a deliberate + simplification — the `last_paid_ts` bookkeeping in Fix 5 is in place precisely so a future version can split the pot correctly. - **No pause / upgrade authority.** The program has no admin and no @@ -939,7 +1047,7 @@ test create_lease_rejects_same_mint_for_leased_and_collateral ... ok test liquidate_rejects_healthy_position ... ok test liquidate_rejects_mismatched_price_feed ... ok test liquidate_seizes_collateral_on_price_drop ... ok -test pay_rent_streams_collateral_by_elapsed_time ... ok +test pay_lease_fee_streams_collateral_by_elapsed_time ... ok test return_lease_refunds_unused_collateral ... ok test take_lease_posts_collateral_and_delivers_tokens ... ok test top_up_collateral_increases_vault_balance ... ok @@ -952,10 +1060,10 @@ test top_up_collateral_increases_vault_balance ... ok | `create_lease_locks_tokens_and_lists` | Lessor funds vault, `Lease` created, collateral vault empty | | `create_lease_rejects_same_mint_for_leased_and_collateral` | Guard against `leased_mint == collateral_mint` | | `take_lease_posts_collateral_and_delivers_tokens` | Collateral deposit + leased-token payout in one ix | -| `pay_rent_streams_collateral_by_elapsed_time` | Rent math: `elapsed * rent_per_second`, rent transferred to lessor | +| `pay_lease_fee_streams_collateral_by_elapsed_time` | Lease fee math: `elapsed * lease_fee_per_second`, lease fee transferred to lessor | | `top_up_collateral_increases_vault_balance` | Collateral balance after `top_up` equals deposit + top-up | | `return_lease_refunds_unused_collateral` | Happy path round-trip — leased tokens returned, residual collateral refunded, accounts closed | -| `liquidate_seizes_collateral_on_price_drop` | Price-induced underwater position → rent + bounty + lessor share paid, accounts closed | +| `liquidate_seizes_collateral_on_price_drop` | Price-induced underwater position → lease fee + bounty + lessor share paid, accounts closed | | `liquidate_rejects_healthy_position` | Program refuses to liquidate a position that passes the margin check | | `liquidate_rejects_mismatched_price_feed` | Program refuses a `PriceUpdateV2` whose `feed_id` ≠ `lease.feed_id` | | `close_expired_reclaims_collateral_after_end_ts` | Default path — lessor seizes the collateral | @@ -1072,8 +1180,7 @@ handler. Tests are in `src/tests.rs`. ## 8. Extending the program -A few directions that are genuinely educational rather than cargo-cult -extensions: +Directions a real protocol would consider, grouped by effort: ### Easy @@ -1082,18 +1189,18 @@ extensions: is_underwater }` given the same inputs `is_underwater` uses. Useful for UIs that want to show "you are 15% away from liquidation". -- **Cap rent at collateral.** Currently `pay_rent` pays `min(rent_due, +- **Cap lease fees at collateral.** Currently `pay_lease_fee` pays `min(lease_fee_due, collateral_amount)` and silently leaves a debt. Add an explicit - `RentDebtOutstanding` error so the caller is warned when the stream - has stalled, rather than inferring it from a non-zero `rent_due` + `LeaseFeeDebtOutstanding` error so the caller is warned when the stream + has stalled, rather than inferring it from a non-zero `lease_fee_due` after settlement. ### Moderate - **Partial-refund default.** In `close_expired` on `Active`, instead of giving the lessor the entire collateral, split it: - `rent_due` to the lessor, the rest stays with the lessee up to some - `default_haircut_bps`. `last_rent_paid_ts` is already bumped by + `lease_fee_due` to the lessor, the rest stays with the lessee up to some + `default_haircut_bps`. `last_paid_ts` is already bumped by Fix 5, so the timestamp invariants are ready. - **Multiple outstanding leases per `(lessor, lessee)` pair with the @@ -1126,7 +1233,7 @@ extensions: trait so it accepts mints owned by either the classic Token program or the Token-2022 program. A real extension would test against Token-2022 mint extensions (transfer-fee, interest-bearing) and - document which are compatible with the rent / collateral flows. + document which are compatible with the lease-fee / collateral flows. --- @@ -1148,7 +1255,7 @@ defi/asset-leasing/anchor/ │ │ ├── shared.rs transfer / close helpers │ │ ├── create_lease.rs │ │ ├── take_lease.rs - │ │ ├── pay_rent.rs + │ │ ├── pay_lease_fee.rs │ │ ├── top_up_collateral.rs │ │ ├── return_lease.rs │ │ ├── liquidate.rs diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs index 2b227f3a9..149b6acef 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs @@ -10,8 +10,8 @@ pub enum AssetLeasingError { InvalidLeasedAmount, #[msg("Required collateral amount must be greater than zero")] InvalidCollateralAmount, - #[msg("Rent per second must be greater than zero")] - InvalidRentPerSecond, + #[msg("Lease fee per second must be greater than zero")] + InvalidLeaseFeePerSecond, #[msg("Maintenance margin is outside the allowed range")] InvalidMaintenanceMargin, #[msg("Liquidation bounty is outside the allowed range")] diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs index f936a456d..f01dc6fec 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs @@ -8,7 +8,7 @@ use crate::{ constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, errors::AssetLeasingError, instructions::{ - pay_rent::update_last_paid_ts, + pay_lease_fee::update_last_paid_ts, shared::{close_vault, transfer_tokens_from_vault}, }, state::{Lease, LeaseStatus}, @@ -155,20 +155,20 @@ pub fn handle_close_expired(context: Context) -> Result<()> { &[collateral_vault_seeds], )?; - // Settle rent accounting on the default path. + // Settle lease-fee accounting on the default path. // - // We are not forwarding any accrued rent to the lessor here — on default + // We are not forwarding any accrued lease fees to the lessor here — on default // the lessor takes the whole collateral vault as compensation — but we - // still bump \`last_rent_paid_ts\` so the invariant - // \`last_rent_paid_ts <= now.min(end_ts)\` stays intact. That matters for + // still bump \`last_paid_ts\` so the invariant + // \`last_paid_ts <= now.min(end_ts)\` stays intact. That matters for // any future version of the program that wants to split the collateral - // differently (pro-rata rent, partial refund on default, haircut to the + // differently (pro-rata lease fees, partial refund on default, haircut to the // lessee for unused time): such a version can read - // \`last_rent_paid_ts\` and trust that everything up to \`now\` is already + // \`last_paid_ts\` and trust that everything up to \`now\` is already // settled, rather than having to reason about whether this branch ever // bumped the timestamp. // - // No-op on the \`Listed\` branch because rent never started accruing. + // No-op on the \`Listed\` branch because Lease fees never started accruing. if status == LeaseStatus::Active { update_last_paid_ts(&mut context.accounts.lease, now); } diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs index 577ab20da..a58f24f32 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs @@ -75,7 +75,7 @@ pub fn handle_create_lease( lease_id: u64, leased_amount: u64, required_collateral_amount: u64, - rent_per_second: u64, + lease_fee_per_second: u64, duration_seconds: i64, maintenance_margin_bps: u16, liquidation_bounty_bps: u16, @@ -83,7 +83,7 @@ pub fn handle_create_lease( ) -> Result<()> { // Reject leased_mint == collateral_mint. Allowing both to be the same // mint would collapse the two vaults' seed derivations into one shared - // token-balance pool, making rent-vs-collateral accounting ambiguous and + // token-balance pool, making lease-fee-vs-collateral accounting ambiguous and // enabling griefing paths where the lessee's "collateral" is the same // asset they already hold as the lease principal. require!( @@ -96,7 +96,7 @@ pub fn handle_create_lease( required_collateral_amount > 0, AssetLeasingError::InvalidCollateralAmount ); - require!(rent_per_second > 0, AssetLeasingError::InvalidRentPerSecond); + require!(lease_fee_per_second > 0, AssetLeasingError::InvalidLeaseFeePerSecond); require!(duration_seconds > 0, AssetLeasingError::InvalidDuration); require!( maintenance_margin_bps > 0 && maintenance_margin_bps <= MAX_MAINTENANCE_MARGIN_BPS, @@ -131,13 +131,13 @@ pub fn handle_create_lease( // No collateral yet — posted on take_lease. collateral_amount: 0, required_collateral_amount, - rent_per_second, + lease_fee_per_second, duration_seconds, - // start_ts / end_ts / last_rent_paid_ts are set when the lease + // start_ts / end_ts / last_paid_ts are set when the lease // activates in `take_lease`. start_ts: 0, end_ts: 0, - last_rent_paid_ts: 0, + last_paid_ts: 0, maintenance_margin_bps, liquidation_bounty_bps, feed_id, diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs index 96079660b..a57dd07a9 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs @@ -11,7 +11,7 @@ use crate::{ }, errors::AssetLeasingError, instructions::{ - pay_rent::compute_rent_due, + pay_lease_fee::compute_lease_fee_due, shared::{close_vault, transfer_tokens_from_vault}, }, state::{Lease, LeaseStatus}, @@ -34,7 +34,7 @@ pub struct Liquidate<'info> { #[account(mut)] pub keeper: Signer<'info>, - /// CHECK: PDA seed + rent/collateral destination. + /// CHECK: PDA seed + lease-fee / collateral destination. #[account(mut)] pub lessor: UncheckedAccount<'info>, @@ -174,10 +174,10 @@ pub fn handle_liquidate(context: Context) -> Result<()> { AssetLeasingError::PositionHealthy ); - // Settle accrued rent first (up to end_ts) so the lessor is paid for the + // Settle accrued lease fees first (up to end_ts) so the lessor is paid for the // time the lessee actually used. Only then slice off bounty + remainder. - let rent_due = compute_rent_due(&context.accounts.lease, now)?; - let rent_payable = rent_due.min(context.accounts.lease.collateral_amount); + let lease_fee_due = compute_lease_fee_due(&context.accounts.lease, now)?; + let lease_fee_payable = lease_fee_due.min(context.accounts.lease.collateral_amount); let lease_key = context.accounts.lease.key(); let collateral_vault_bump = context.accounts.lease.collateral_vault_bump; @@ -193,11 +193,11 @@ pub fn handle_liquidate(context: Context) -> Result<()> { core::slice::from_ref(&leased_vault_bump), ]; - if rent_payable > 0 { + if lease_fee_payable > 0 { transfer_tokens_from_vault( &context.accounts.collateral_vault, &context.accounts.lessor_collateral_account, - rent_payable, + lease_fee_payable, &context.accounts.collateral_mint, &context.accounts.collateral_vault.to_account_info(), &context.accounts.token_program, @@ -209,10 +209,10 @@ pub fn handle_liquidate(context: Context) -> Result<()> { .accounts .lease .collateral_amount - .checked_sub(rent_payable) + .checked_sub(lease_fee_payable) .ok_or(AssetLeasingError::MathOverflow)?; - // Bounty is a percentage of the collateral *after* rent — guarantees we + // Bounty is a percentage of the collateral *after* lease fees — guarantees we // never try to pay out more than what actually sits in the vault. let bounty = (remaining as u128) .checked_mul(context.accounts.lease.liquidation_bounty_bps as u128) @@ -264,7 +264,7 @@ pub fn handle_liquidate(context: Context) -> Result<()> { )?; context.accounts.lease.collateral_amount = 0; - context.accounts.lease.last_rent_paid_ts = now.min(context.accounts.lease.end_ts); + context.accounts.lease.last_paid_ts = now.min(context.accounts.lease.end_ts); context.accounts.lease.status = LeaseStatus::Liquidated; Ok(()) diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/mod.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/mod.rs index 18a4660b1..39033e435 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/mod.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/mod.rs @@ -1,7 +1,7 @@ pub mod close_expired; pub mod create_lease; pub mod liquidate; -pub mod pay_rent; +pub mod pay_lease_fee; pub mod return_lease; pub mod shared; pub mod take_lease; @@ -10,7 +10,7 @@ pub mod top_up_collateral; pub use close_expired::*; pub use create_lease::*; pub use liquidate::*; -pub use pay_rent::*; +pub use pay_lease_fee::*; pub use return_lease::*; pub use shared::*; pub use take_lease::*; diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_rent.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_lease_fee.rs similarity index 77% rename from defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_rent.rs rename to defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_lease_fee.rs index 71b833622..ef3fb8db6 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_rent.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_lease_fee.rs @@ -12,9 +12,9 @@ use crate::{ }; #[derive(Accounts)] -pub struct PayRent<'info> { - /// Anyone may settle rent — the lessee has every incentive to keep the - /// lease current, but a keeper bot could also push a payment before a +pub struct PayLeaseFee<'info> { + /// Anyone may settle the lease fee — the lessee has every incentive to keep the + /// lease current, but a keeper bot could also push a lease fee payment before a /// liquidation check so healthy leases stay healthy. #[account(mut)] pub payer: Signer<'info>, @@ -45,7 +45,7 @@ pub struct PayRent<'info> { pub collateral_vault: Box>, /// Lessor's collateral-mint ATA, created on demand so the lessor does not - /// need to pre-fund it with rent. + /// need to pre-fund it with the lease fee. #[account( init_if_needed, payer = payer, @@ -60,21 +60,21 @@ pub struct PayRent<'info> { pub system_program: Program<'info, System>, } -pub fn handle_pay_rent(context: Context) -> Result<()> { +pub fn handle_pay_lease_fee(context: Context) -> Result<()> { let now = Clock::get()?.unix_timestamp; - let rent_amount = compute_rent_due(&context.accounts.lease, now)?; + let lease_fee_amount = compute_lease_fee_due(&context.accounts.lease, now)?; // No time has passed (or already capped at end_ts). Nothing to do. - if rent_amount == 0 { + if lease_fee_amount == 0 { update_last_paid_ts(&mut context.accounts.lease, now); return Ok(()); } - // Cap rent at whatever collateral actually sits in the vault. If the + // Cap lease fees at whatever collateral actually sits in the vault. If the // lessee under-collateralised we cannot magically create funds; the // remainder is their debt and can trigger liquidation. - let payable = rent_amount.min(context.accounts.collateral_amount_available()); + let payable = lease_fee_amount.min(context.accounts.collateral_amount_available()); if payable > 0 { let lease_key = context.accounts.lease.key(); @@ -108,27 +108,27 @@ pub fn handle_pay_rent(context: Context) -> Result<()> { Ok(()) } -/// Rent accrues linearly: `(min(now, end_ts) - last_rent_paid_ts) * rate`. +/// Lease fee accrues linearly: `(min(now, end_ts) - last_paid_ts) * rate`. /// Extracted so it can be re-used by `return_lease` and `liquidate` for a /// final settlement before closing the lease. -pub fn compute_rent_due(lease: &Lease, now: i64) -> Result { +pub fn compute_lease_fee_due(lease: &Lease, now: i64) -> Result { let cutoff = now.min(lease.end_ts); - if cutoff <= lease.last_rent_paid_ts { + if cutoff <= lease.last_paid_ts { return Ok(0); } - let elapsed = (cutoff - lease.last_rent_paid_ts) as u64; + let elapsed = (cutoff - lease.last_paid_ts) as u64; elapsed - .checked_mul(lease.rent_per_second) + .checked_mul(lease.lease_fee_per_second) .ok_or(AssetLeasingError::MathOverflow.into()) } -/// Advance `last_rent_paid_ts` but never past the lease end — after end_ts -/// the lease is settled and extra rent does not accrue. +/// Advance `last_paid_ts` but never past the lease end — after end_ts +/// the lease is settled and extra Lease fees do not accrue. pub fn update_last_paid_ts(lease: &mut Lease, now: i64) { - lease.last_rent_paid_ts = now.min(lease.end_ts); + lease.last_paid_ts = now.min(lease.end_ts); } -impl<'info> PayRent<'info> { +impl<'info> PayLeaseFee<'info> { fn collateral_amount_available(&self) -> u64 { self.lease.collateral_amount } diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs index cb5bbb242..d0f794e8c 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs @@ -8,7 +8,7 @@ use crate::{ constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, errors::AssetLeasingError, instructions::{ - pay_rent::{compute_rent_due, update_last_paid_ts}, + pay_lease_fee::{compute_lease_fee_due, update_last_paid_ts}, shared::{close_vault, transfer_tokens_from_user, transfer_tokens_from_vault}, }, state::{Lease, LeaseStatus}, @@ -19,7 +19,7 @@ pub struct ReturnLease<'info> { #[account(mut)] pub lessee: Signer<'info>, - /// CHECK: Reference only — receives rent + closed-vault rent refund. + /// CHECK: Reference only — receives the lease fee + closed-vault rent-exempt-lamport refund. #[account(mut)] pub lessor: UncheckedAccount<'info>, @@ -40,7 +40,7 @@ pub struct ReturnLease<'info> { pub collateral_mint: Box>, /// Leased tokens flow back into this vault from the lessee, then out to - /// the lessor in the same instruction. Closed at the end to reclaim rent. + /// the lessor in the same instruction. Closed at the end to reclaim rent-exempt lamports. #[account( mut, seeds = [LEASED_VAULT_SEED, lease.key().as_ref()], @@ -134,9 +134,9 @@ pub fn handle_return_lease(context: Context) -> Result<()> { &[leased_vault_seeds], )?; - // 3. Settle accrued rent: collateral vault -> lessor. - let rent_due = compute_rent_due(&context.accounts.lease, now)?; - let rent_payable = rent_due.min(context.accounts.lease.collateral_amount); + // 3. Settle accrued lease fees: collateral vault -> lessor. + let lease_fee_due = compute_lease_fee_due(&context.accounts.lease, now)?; + let lease_fee_payable = lease_fee_due.min(context.accounts.lease.collateral_amount); let collateral_vault_bump = context.accounts.lease.collateral_vault_bump; let collateral_vault_seeds: &[&[u8]] = &[ @@ -145,11 +145,11 @@ pub fn handle_return_lease(context: Context) -> Result<()> { core::slice::from_ref(&collateral_vault_bump), ]; - if rent_payable > 0 { + if lease_fee_payable > 0 { transfer_tokens_from_vault( &context.accounts.collateral_vault, &context.accounts.lessor_collateral_account, - rent_payable, + lease_fee_payable, &context.accounts.collateral_mint, &context.accounts.collateral_vault.to_account_info(), &context.accounts.token_program, @@ -158,20 +158,20 @@ pub fn handle_return_lease(context: Context) -> Result<()> { } // 4. Refund remaining collateral to the lessee. Returning early does not - // entitle the lessee to a future-rent refund — rent only accrues for time - // actually used, so `compute_rent_due` already excludes the unused tail. - let collateral_after_rent = context + // entitle the lessee to a future-lease-fee refund — Lease fees only accrue for time + // actually used, so `compute_lease_fee_due` already excludes the unused tail. + let collateral_after_lease_fees = context .accounts .lease .collateral_amount - .checked_sub(rent_payable) + .checked_sub(lease_fee_payable) .ok_or(AssetLeasingError::MathOverflow)?; - if collateral_after_rent > 0 { + if collateral_after_lease_fees > 0 { transfer_tokens_from_vault( &context.accounts.collateral_vault, &context.accounts.lessee_collateral_account, - collateral_after_rent, + collateral_after_lease_fees, &context.accounts.collateral_mint, &context.accounts.collateral_vault.to_account_info(), &context.accounts.token_program, diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs index 470e4056b..387ad9283 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs @@ -127,7 +127,7 @@ pub fn handle_take_lease(context: Context) -> Result<()> { lease.collateral_amount = required_collateral_amount; lease.start_ts = now; lease.end_ts = end_ts; - lease.last_rent_paid_ts = now; + lease.last_paid_ts = now; lease.status = LeaseStatus::Active; Ok(()) diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs index 235617a0d..b58c29a83 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs @@ -23,7 +23,7 @@ pub mod asset_leasing { lease_id: u64, leased_amount: u64, required_collateral_amount: u64, - rent_per_second: u64, + lease_fee_per_second: u64, duration_seconds: i64, maintenance_margin_bps: u16, liquidation_bounty_bps: u16, @@ -34,7 +34,7 @@ pub mod asset_leasing { lease_id, leased_amount, required_collateral_amount, - rent_per_second, + lease_fee_per_second, duration_seconds, maintenance_margin_bps, liquidation_bounty_bps, @@ -48,10 +48,10 @@ pub mod asset_leasing { instructions::take_lease::handle_take_lease(context) } - /// Stream rent from the collateral vault to the lessor, up to `end_ts`. + /// Stream the lease fee from the collateral vault to the lessor, up to `end_ts`. /// Anyone may call this to keep the lease current. - pub fn pay_rent(context: Context) -> Result<()> { - instructions::pay_rent::handle_pay_rent(context) + pub fn pay_lease_fee(context: Context) -> Result<()> { + instructions::pay_lease_fee::handle_pay_lease_fee(context) } /// Lessee adds more collateral to stay above the maintenance margin. @@ -59,7 +59,7 @@ pub mod asset_leasing { instructions::top_up_collateral::handle_top_up_collateral(context, amount) } - /// Lessee returns the leased tokens (at or before `end_ts`). Accrued rent + /// Lessee returns the leased tokens (at or before `end_ts`). Accrued lease fees /// is settled and the remaining collateral is refunded. pub fn return_lease(context: Context) -> Result<()> { instructions::return_lease::handle_return_lease(context) diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs index f02ab7300..2a5406398 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs @@ -20,7 +20,7 @@ pub struct Lease { /// Caller-supplied id so one lessor can run many leases in parallel. The /// PDA is seeded by (LEASE_SEED, lessor, lease_id). pub lease_id: u64, - /// Account that listed the lease and receives rent. Always set. + /// Account that listed the lease and receives the lease fee. Always set. pub lessor: Pubkey, /// Account that took the lease. `Pubkey::default()` while `Listed`. pub lessee: Pubkey, @@ -32,15 +32,15 @@ pub struct Lease { /// Mint of the collateral posted by the lessee. pub collateral_mint: Pubkey, - /// Collateral the lessee posted (increases on top-up). Decreases as rent + /// Collateral the lessee posted (increases on top-up). Decreases as the lease fee /// is streamed out of the collateral vault. pub collateral_amount: u64, /// Collateral the lessee must deposit up-front when taking the lease. pub required_collateral_amount: u64, - /// Rent charged per second, denominated in collateral tokens and paid - /// from the collateral vault to the lessor on each `pay_rent`. - pub rent_per_second: u64, + /// Lease fee charged per second, denominated in collateral tokens and paid + /// from the collateral vault to the lessor on each `pay_lease_fee`. + pub lease_fee_per_second: u64, /// Length of the lease, in seconds. Set at creation, used to compute /// `end_ts` when the lease activates. pub duration_seconds: i64, @@ -48,8 +48,8 @@ pub struct Lease { pub start_ts: i64, /// Unix timestamp after which the lease expires. 0 while `Listed`. pub end_ts: i64, - /// Last time rent was settled. Rent accrues from here to `now.min(end_ts)`. - pub last_rent_paid_ts: i64, + /// Last time the lease fee was settled. Lease fee accrues from here to `now.min(end_ts)`. + pub last_paid_ts: i64, /// Required collateral value as a percentage of the leased value, /// expressed in basis points. 12_000 bps = 120%. diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs b/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs index afc27a511..a1bad0f52 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs @@ -1,6 +1,6 @@ //! LiteSVM tests for the asset-leasing program. //! -//! Covers the full lifecycle: listing, taking, rent streaming, top-ups, +//! Covers the full lifecycle: listing, taking, lease fee streaming, top-ups, //! early return, keeper liquidation via a mocked Pyth `PriceUpdateV2` //! account, and lessor-initiated default recovery after expiry. @@ -124,7 +124,7 @@ fn full_setup() -> Scenario { // Anchor macros init the Lease + vault accounts — LiteSVM's default clock // is epoch 0 which makes the first `take_lease` have start_ts=0 and look - // identical to a Listed lease. Advance once so rent math has signal. + // identical to a Listed lease. Advance once so lease fee math has signal. advance_clock_to(&mut svm, 1_700_000_000); Scenario { @@ -167,7 +167,7 @@ fn build_create_lease_ix( lease_id: u64, leased_amount: u64, required_collateral_amount: u64, - rent_per_second: u64, + lease_fee_per_second: u64, duration_seconds: i64, maintenance_margin_bps: u16, liquidation_bounty_bps: u16, @@ -181,7 +181,7 @@ fn build_create_lease_ix( lease_id, leased_amount, required_collateral_amount, - rent_per_second, + lease_fee_per_second, duration_seconds, maintenance_margin_bps, liquidation_bounty_bps, @@ -228,14 +228,14 @@ fn build_take_lease_ix(sc: &Scenario, lease_id: u64) -> Instruction { ) } -fn build_pay_rent_ix(sc: &Scenario, lease_id: u64) -> Instruction { +fn build_pay_lease_fee_ix(sc: &Scenario, lease_id: u64) -> Instruction { let (lease, _leased_vault, collateral_vault) = lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); Instruction::new_with_bytes( sc.program_id, - &asset_leasing::instruction::PayRent {}.data(), - asset_leasing::accounts::PayRent { + &asset_leasing::instruction::PayLeaseFee {}.data(), + asset_leasing::accounts::PayLeaseFee { payer: sc.lessee.pubkey(), lessor: sc.lessor.pubkey(), lease, @@ -412,7 +412,7 @@ fn mock_price_update( // Shared lease parameters so the sanity assertions line up across tests. const LEASED_AMOUNT: u64 = 100_000_000; // 100 "leased" tokens (6 dp) const REQUIRED_COLLATERAL: u64 = 200_000_000; // 200 collateral tokens -const RENT_PER_SECOND: u64 = 10; // 10 base-units / sec +const LEASE_FEE_PER_SECOND: u64 = 10; // 10 base-units / sec const DURATION_SECONDS: i64 = 60 * 60 * 24; // 24h const MAINTENANCE_MARGIN_BPS: u16 = 12_000; // 120% const LIQUIDATION_BOUNTY_BPS: u16 = 500; // 5% @@ -432,7 +432,7 @@ fn create_lease_locks_tokens_and_lists() { lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, - RENT_PER_SECOND, + LEASE_FEE_PER_SECOND, DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, @@ -476,7 +476,7 @@ fn take_lease_posts_collateral_and_delivers_tokens() { lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, - RENT_PER_SECOND, + LEASE_FEE_PER_SECOND, DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, @@ -521,7 +521,7 @@ fn take_lease_posts_collateral_and_delivers_tokens() { } #[test] -fn pay_rent_streams_collateral_by_elapsed_time() { +fn pay_lease_fee_streams_collateral_by_elapsed_time() { let mut sc = full_setup(); let lease_id = 3u64; @@ -530,7 +530,7 @@ fn pay_rent_streams_collateral_by_elapsed_time() { lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, - RENT_PER_SECOND, + LEASE_FEE_PER_SECOND, DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, @@ -548,7 +548,7 @@ fn pay_rent_streams_collateral_by_elapsed_time() { let elapsed: i64 = 120; // 2 minutes advance_clock_by(&mut sc.svm, elapsed); - let pay_ix = build_pay_rent_ix(&sc, lease_id); + let pay_ix = build_pay_lease_fee_ix(&sc, lease_id); send_transaction_from_instructions( &mut sc.svm, vec![pay_ix], @@ -557,16 +557,16 @@ fn pay_rent_streams_collateral_by_elapsed_time() { ) .unwrap(); - let expected_rent = (elapsed as u64) * RENT_PER_SECOND; + let expected_lease_fees = (elapsed as u64) * LEASE_FEE_PER_SECOND; let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); assert_eq!( get_token_account_balance(&sc.svm, &lessor_collateral_ata).unwrap(), - expected_rent + expected_lease_fees ); let (_, _, collateral_vault) = lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); assert_eq!( get_token_account_balance(&sc.svm, &collateral_vault).unwrap(), - REQUIRED_COLLATERAL - expected_rent + REQUIRED_COLLATERAL - expected_lease_fees ); } @@ -580,7 +580,7 @@ fn top_up_collateral_increases_vault_balance() { lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, - RENT_PER_SECOND, + LEASE_FEE_PER_SECOND, DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, @@ -622,7 +622,7 @@ fn return_lease_refunds_unused_collateral() { lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, - RENT_PER_SECOND, + LEASE_FEE_PER_SECOND, DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, @@ -650,19 +650,19 @@ fn return_lease_refunds_unused_collateral() { ) .unwrap(); - let rent_paid = (elapsed as u64) * RENT_PER_SECOND; - let refund_expected = REQUIRED_COLLATERAL - rent_paid; + let lease_fee_paid = (elapsed as u64) * LEASE_FEE_PER_SECOND; + let refund_expected = REQUIRED_COLLATERAL - lease_fee_paid; // Lessor got their leased tokens back. assert_eq!( get_token_account_balance(&sc.svm, &sc.lessor_leased_ata).unwrap(), 1_000_000_000 ); - // Lessor also received the accrued rent. + // Lessor also received the accrued lease fees. let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); assert_eq!( get_token_account_balance(&sc.svm, &lessor_collateral_ata).unwrap(), - rent_paid + lease_fee_paid ); // Lessee got the unused-time portion of their collateral back. assert_eq!( @@ -688,7 +688,7 @@ fn liquidate_seizes_collateral_on_price_drop() { lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, - RENT_PER_SECOND, + LEASE_FEE_PER_SECOND, DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, @@ -703,8 +703,8 @@ fn liquidate_seizes_collateral_on_price_drop() { ) .unwrap(); - // A bit of rent accrues before the liquidation call so the handler has to - // settle rent *and* bounty on the same vault balance. + // A bit of Lease fee accrues before the liquidation call so the handler has to + // settle the lease fee *and* bounty on the same vault balance. let elapsed: i64 = 300; advance_clock_by(&mut sc.svm, elapsed); @@ -731,17 +731,17 @@ fn liquidate_seizes_collateral_on_price_drop() { ) .unwrap(); - let rent_paid = (elapsed as u64) * RENT_PER_SECOND; - let remaining_after_rent = REQUIRED_COLLATERAL - rent_paid; - let bounty = remaining_after_rent * (LIQUIDATION_BOUNTY_BPS as u64) / 10_000; - let lessor_share = remaining_after_rent - bounty; + let lease_fee_paid = (elapsed as u64) * LEASE_FEE_PER_SECOND; + let remaining_after_lease_fees = REQUIRED_COLLATERAL - lease_fee_paid; + let bounty = remaining_after_lease_fees * (LIQUIDATION_BOUNTY_BPS as u64) / 10_000; + let lessor_share = remaining_after_lease_fees - bounty; let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); let keeper_collateral_ata = derive_ata(&sc.keeper.pubkey(), &sc.collateral_mint); assert_eq!( get_token_account_balance(&sc.svm, &lessor_collateral_ata).unwrap(), - rent_paid + lessor_share + lease_fee_paid + lessor_share ); assert_eq!( get_token_account_balance(&sc.svm, &keeper_collateral_ata).unwrap(), @@ -766,7 +766,7 @@ fn liquidate_rejects_healthy_position() { lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, - RENT_PER_SECOND, + LEASE_FEE_PER_SECOND, DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, @@ -812,7 +812,7 @@ fn liquidate_rejects_mismatched_price_feed() { lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, - RENT_PER_SECOND, + LEASE_FEE_PER_SECOND, DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, @@ -870,7 +870,7 @@ fn close_expired_reclaims_collateral_after_end_ts() { lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, - RENT_PER_SECOND, + LEASE_FEE_PER_SECOND, DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, @@ -927,7 +927,7 @@ fn close_expired_cancels_listed_lease() { lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, - RENT_PER_SECOND, + LEASE_FEE_PER_SECOND, DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, @@ -967,7 +967,7 @@ fn close_expired_cancels_listed_lease() { fn create_lease_rejects_same_mint_for_leased_and_collateral() { // Collapsing leased_mint and collateral_mint into a single mint would // also collapse the two vaults into one token-balance pool (same mint, - // same authority seed pattern) and make rent-vs-collateral accounting + // same authority seed pattern) and make lease-fee-vs-collateral accounting // ambiguous. The program rejects this up-front with // `LeasedMintEqualsCollateralMint`. let mut sc = full_setup(); @@ -984,7 +984,7 @@ fn create_lease_rejects_same_mint_for_leased_and_collateral() { lease_id, leased_amount: LEASED_AMOUNT, required_collateral_amount: REQUIRED_COLLATERAL, - rent_per_second: RENT_PER_SECOND, + lease_fee_per_second: LEASE_FEE_PER_SECOND, duration_seconds: DURATION_SECONDS, maintenance_margin_bps: MAINTENANCE_MARGIN_BPS, liquidation_bounty_bps: LIQUIDATION_BOUNTY_BPS, diff --git a/defi/asset-leasing/quasar/src/errors.rs b/defi/asset-leasing/quasar/src/errors.rs index 50b85be09..2326e1d2f 100644 --- a/defi/asset-leasing/quasar/src/errors.rs +++ b/defi/asset-leasing/quasar/src/errors.rs @@ -10,7 +10,7 @@ pub enum AssetLeasingError { InvalidDuration, InvalidLeasedAmount, InvalidCollateralAmount, - InvalidRentPerSecond, + InvalidLeaseFeePerSecond, InvalidMaintenanceMargin, InvalidLiquidationBounty, LeaseExpired, diff --git a/defi/asset-leasing/quasar/src/instructions/close_expired.rs b/defi/asset-leasing/quasar/src/instructions/close_expired.rs index 3f991e31d..26e1ae735 100644 --- a/defi/asset-leasing/quasar/src/instructions/close_expired.rs +++ b/defi/asset-leasing/quasar/src/instructions/close_expired.rs @@ -2,7 +2,7 @@ use { crate::{ constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, errors::AssetLeasingError, - instructions::pay_rent::update_last_paid_ts, + instructions::pay_lease_fee::update_last_paid_ts, state::{Lease, LeaseStatus}, }, quasar_lang::prelude::*, @@ -140,11 +140,11 @@ pub fn handle_close_expired(accounts: &mut CloseExpired) -> Result<(), ProgramEr ) .invoke_signed(collateral_vault_seeds)?; - // Keep the rent-settlement invariant intact even on default: the + // Keep the lease-fee-settlement invariant intact even on default: the // lessor takes the whole collateral vault as compensation here, but // any future version of the program that wants to split the - // collateral differently (pro-rata rent, partial refund on default) - // can read `last_rent_paid_ts` and trust that everything up to + // collateral differently (pro-rata lease fees, partial refund on default) + // can read `last_paid_ts` and trust that everything up to // `now` is already settled. if status == LeaseStatus::Active { update_last_paid_ts(accounts.lease, now); diff --git a/defi/asset-leasing/quasar/src/instructions/create_lease.rs b/defi/asset-leasing/quasar/src/instructions/create_lease.rs index cac299041..afbc64dae 100644 --- a/defi/asset-leasing/quasar/src/instructions/create_lease.rs +++ b/defi/asset-leasing/quasar/src/instructions/create_lease.rs @@ -77,7 +77,7 @@ pub fn handle_create_lease( lease_id: u64, leased_amount: u64, required_collateral_amount: u64, - rent_per_second: u64, + lease_fee_per_second: u64, duration_seconds: i64, maintenance_margin_bps: u16, liquidation_bounty_bps: u16, @@ -85,7 +85,7 @@ pub fn handle_create_lease( bumps: &CreateLeaseBumps, ) -> Result<(), ProgramError> { // Two vaults keyed on the same mint would collide on the shared - // token-balance pool and make rent-vs-collateral accounting + // token-balance pool and make lease-fee-vs-collateral accounting // ambiguous. Reject up-front. require!( accounts.leased_mint.address() != accounts.collateral_mint.address(), @@ -98,8 +98,8 @@ pub fn handle_create_lease( AssetLeasingError::InvalidCollateralAmount ); require!( - rent_per_second > 0, - AssetLeasingError::InvalidRentPerSecond + lease_fee_per_second > 0, + AssetLeasingError::InvalidLeaseFeePerSecond ); require!(duration_seconds > 0, AssetLeasingError::InvalidDuration); require!( @@ -136,9 +136,9 @@ pub fn handle_create_lease( // No collateral yet — posted on `take_lease`. 0, required_collateral_amount, - rent_per_second, + lease_fee_per_second, duration_seconds, - // start_ts / end_ts / last_rent_paid_ts set on `take_lease`. + // start_ts / end_ts / last_paid_ts set on `take_lease`. 0, 0, 0, diff --git a/defi/asset-leasing/quasar/src/instructions/liquidate.rs b/defi/asset-leasing/quasar/src/instructions/liquidate.rs index 9e2ae85c0..519fdf4d9 100644 --- a/defi/asset-leasing/quasar/src/instructions/liquidate.rs +++ b/defi/asset-leasing/quasar/src/instructions/liquidate.rs @@ -5,7 +5,7 @@ use { PYTH_MAX_AGE_SECONDS, }, errors::AssetLeasingError, - instructions::pay_rent::compute_rent_due, + instructions::pay_lease_fee::compute_lease_fee_due, state::{Lease, LeaseStatus}, }, quasar_lang::prelude::*, @@ -32,7 +32,7 @@ pub struct Liquidate<'info> { #[account(mut)] pub keeper: &'info Signer, - /// Receives rent + the post-bounty remainder. Also the destination + /// Receives the lease fee + the post-bounty remainder. Also the destination /// for the closed-vault rent-exempt lamports. #[account(mut)] pub lessor: &'info UncheckedAccount, @@ -162,12 +162,12 @@ pub fn handle_liquidate(accounts: &mut Liquidate) -> Result<(), ProgramError> { return Err(AssetLeasingError::PositionHealthy.into()); } - // Settle accrued rent first (up to end_ts) so the lessor is paid for + // Settle accrued lease fees first (up to end_ts) so the lessor is paid for // the time the lessee actually used. Only then slice off bounty + // remainder. - let rent_due = compute_rent_due(accounts.lease, now)?; + let lease_fee_due = compute_lease_fee_due(accounts.lease, now)?; let collateral_amount = accounts.lease.collateral_amount.get(); - let rent_payable = rent_due.min(collateral_amount); + let lease_fee_payable = lease_fee_due.min(collateral_amount); let lease_address = *accounts.lease.address(); let collateral_vault_bump = [accounts.lease.collateral_vault_bump]; @@ -183,23 +183,23 @@ pub fn handle_liquidate(accounts: &mut Liquidate) -> Result<(), ProgramError> { Seed::from(&leased_vault_bump as &[u8]), ]; - if rent_payable > 0 { + if lease_fee_payable > 0 { accounts .token_program .transfer( accounts.collateral_vault, accounts.lessor_collateral_account, accounts.collateral_vault, - rent_payable, + lease_fee_payable, ) .invoke_signed(collateral_vault_seeds)?; } let remaining = collateral_amount - .checked_sub(rent_payable) + .checked_sub(lease_fee_payable) .ok_or(AssetLeasingError::MathOverflow)?; - // Bounty is a percentage of the collateral *after* rent — guarantees + // Bounty is a percentage of the collateral *after* lease fees — guarantees // we never try to pay out more than what actually sits in the vault. let bounty = (remaining as u128) .checked_mul(accounts.lease.liquidation_bounty_bps.get() as u128) @@ -256,7 +256,7 @@ pub fn handle_liquidate(accounts: &mut Liquidate) -> Result<(), ProgramError> { accounts.lease.collateral_amount = 0u64.into(); let end_ts = accounts.lease.end_ts.get(); - accounts.lease.last_rent_paid_ts = now.min(end_ts).into(); + accounts.lease.last_paid_ts = now.min(end_ts).into(); accounts.lease.status = LeaseStatus::Liquidated as u8; Ok(()) diff --git a/defi/asset-leasing/quasar/src/instructions/mod.rs b/defi/asset-leasing/quasar/src/instructions/mod.rs index d7a421c0e..67f7715c4 100644 --- a/defi/asset-leasing/quasar/src/instructions/mod.rs +++ b/defi/asset-leasing/quasar/src/instructions/mod.rs @@ -4,8 +4,8 @@ pub use create_lease::*; pub mod take_lease; pub use take_lease::*; -pub mod pay_rent; -pub use pay_rent::*; +pub mod pay_lease_fee; +pub use pay_lease_fee::*; pub mod top_up_collateral; pub use top_up_collateral::*; diff --git a/defi/asset-leasing/quasar/src/instructions/pay_rent.rs b/defi/asset-leasing/quasar/src/instructions/pay_lease_fee.rs similarity index 76% rename from defi/asset-leasing/quasar/src/instructions/pay_rent.rs rename to defi/asset-leasing/quasar/src/instructions/pay_lease_fee.rs index 19921c2c5..e72ed929a 100644 --- a/defi/asset-leasing/quasar/src/instructions/pay_rent.rs +++ b/defi/asset-leasing/quasar/src/instructions/pay_lease_fee.rs @@ -8,11 +8,11 @@ use { quasar_spl::{Mint, Token, TokenCpi}, }; -/// Accounts for settling rent on an `Active` lease. Permissionless: the +/// Accounts for settling the lease fee on an `Active` lease. Permissionless: the /// lessee has every incentive to keep the lease current, but a keeper bot -/// could also push a payment before a liquidation check. +/// could also push a lease fee payment before a liquidation check. #[derive(Accounts)] -pub struct PayRent<'info> { +pub struct PayLeaseFee<'info> { #[account(mut)] pub payer: &'info Signer, @@ -47,21 +47,21 @@ pub struct PayRent<'info> { } #[inline(always)] -pub fn handle_pay_rent(accounts: &mut PayRent) -> Result<(), ProgramError> { +pub fn handle_pay_lease_fee(accounts: &mut PayLeaseFee) -> Result<(), ProgramError> { let now = ::get()?.unix_timestamp.get(); - let rent_amount = compute_rent_due(accounts.lease, now)?; + let lease_fee_amount = compute_lease_fee_due(accounts.lease, now)?; - if rent_amount == 0 { + if lease_fee_amount == 0 { update_last_paid_ts(accounts.lease, now); return Ok(()); } - // Cap rent at whatever collateral actually sits in the vault. If the + // Cap lease fees at whatever collateral actually sits in the vault. If the // lessee under-collateralised we cannot magically create funds; the // remainder is their debt and can trigger liquidation. let collateral_amount = accounts.lease.collateral_amount.get(); - let payable = rent_amount.min(collateral_amount); + let payable = lease_fee_amount.min(collateral_amount); if payable > 0 { let lease_address = *accounts.lease.address(); @@ -91,25 +91,25 @@ pub fn handle_pay_rent(accounts: &mut PayRent) -> Result<(), ProgramError> { Ok(()) } -/// Rent accrues linearly: `(min(now, end_ts) - last_rent_paid_ts) * rate`. +/// Lease fee accrues linearly: `(min(now, end_ts) - last_paid_ts) * rate`. /// Shared with `return_lease` and `liquidate` for final settlement. -pub fn compute_rent_due(lease: &Lease, now: i64) -> Result { +pub fn compute_lease_fee_due(lease: &Lease, now: i64) -> Result { let end_ts = lease.end_ts.get(); - let last_paid = lease.last_rent_paid_ts.get(); + let last_paid = lease.last_paid_ts.get(); let cutoff = now.min(end_ts); if cutoff <= last_paid { return Ok(0); } let elapsed = (cutoff - last_paid) as u64; elapsed - .checked_mul(lease.rent_per_second.get()) + .checked_mul(lease.lease_fee_per_second.get()) .ok_or_else(|| AssetLeasingError::MathOverflow.into()) } -/// Advance `last_rent_paid_ts`, but never past `end_ts` — once the lease -/// is over, extra rent does not accrue. +/// Advance `last_paid_ts`, but never past `end_ts` — once the lease +/// is over, extra Lease fees do not accrue. pub fn update_last_paid_ts(lease: &mut Lease, now: i64) { let end_ts = lease.end_ts.get(); let capped = now.min(end_ts); - lease.last_rent_paid_ts = capped.into(); + lease.last_paid_ts = capped.into(); } diff --git a/defi/asset-leasing/quasar/src/instructions/return_lease.rs b/defi/asset-leasing/quasar/src/instructions/return_lease.rs index cf7eb3286..e7ffd1c26 100644 --- a/defi/asset-leasing/quasar/src/instructions/return_lease.rs +++ b/defi/asset-leasing/quasar/src/instructions/return_lease.rs @@ -2,7 +2,7 @@ use { crate::{ constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, errors::AssetLeasingError, - instructions::pay_rent::{compute_rent_due, update_last_paid_ts}, + instructions::pay_lease_fee::{compute_lease_fee_due, update_last_paid_ts}, state::{Lease, LeaseStatus}, }, quasar_lang::prelude::*, @@ -10,7 +10,7 @@ use { }; /// Accounts for the happy-path return. Lessee hands the leased tokens -/// back, pays accrued rent out of their collateral, and receives whatever +/// back, pays accrued lease fees out of their collateral, and receives whatever /// collateral is left. Both vaults are closed so the lessor recoups the /// rent-exempt lamports. #[derive(Accounts)] @@ -18,7 +18,7 @@ pub struct ReturnLease<'info> { #[account(mut)] pub lessee: &'info Signer, - /// Receives the leased tokens + any accrued rent + the vaults' + /// Receives the leased tokens + any accrued lease fees + the vaults' /// rent-exempt lamports. #[account(mut)] pub lessor: &'info UncheckedAccount, @@ -105,10 +105,10 @@ pub fn handle_return_lease(accounts: &mut ReturnLease) -> Result<(), ProgramErro ) .invoke_signed(leased_vault_seeds)?; - // 3. Settle accrued rent: collateral vault -> lessor. - let rent_due = compute_rent_due(accounts.lease, now)?; + // 3. Settle accrued lease fees: collateral vault -> lessor. + let lease_fee_due = compute_lease_fee_due(accounts.lease, now)?; let collateral_amount = accounts.lease.collateral_amount.get(); - let rent_payable = rent_due.min(collateral_amount); + let lease_fee_payable = lease_fee_due.min(collateral_amount); let collateral_vault_bump = [accounts.lease.collateral_vault_bump]; let collateral_vault_seeds: &[Seed] = &[ @@ -117,34 +117,34 @@ pub fn handle_return_lease(accounts: &mut ReturnLease) -> Result<(), ProgramErro Seed::from(&collateral_vault_bump as &[u8]), ]; - if rent_payable > 0 { + if lease_fee_payable > 0 { accounts .token_program .transfer( accounts.collateral_vault, accounts.lessor_collateral_account, accounts.collateral_vault, - rent_payable, + lease_fee_payable, ) .invoke_signed(collateral_vault_seeds)?; } // 4. Refund remaining collateral to the lessee. Returning early does - // not entitle the lessee to a future-rent refund — rent only accrues - // for time actually used, so `compute_rent_due` already excludes the + // not entitle the lessee to a future-lease-fee refund — Lease fees only accrue + // for time actually used, so `compute_lease_fee_due` already excludes the // unused tail. - let collateral_after_rent = collateral_amount - .checked_sub(rent_payable) + let collateral_after_lease_fees = collateral_amount + .checked_sub(lease_fee_payable) .ok_or(AssetLeasingError::MathOverflow)?; - if collateral_after_rent > 0 { + if collateral_after_lease_fees > 0 { accounts .token_program .transfer( accounts.collateral_vault, accounts.lessee_collateral_account, accounts.collateral_vault, - collateral_after_rent, + collateral_after_lease_fees, ) .invoke_signed(collateral_vault_seeds)?; } diff --git a/defi/asset-leasing/quasar/src/instructions/take_lease.rs b/defi/asset-leasing/quasar/src/instructions/take_lease.rs index be789bcf3..502d34279 100644 --- a/defi/asset-leasing/quasar/src/instructions/take_lease.rs +++ b/defi/asset-leasing/quasar/src/instructions/take_lease.rs @@ -110,7 +110,7 @@ pub fn handle_take_lease(accounts: &mut TakeLease) -> Result<(), ProgramError> { lease.collateral_amount = required_collateral_amount.into(); lease.start_ts = now.into(); lease.end_ts = end_ts.into(); - lease.last_rent_paid_ts = now.into(); + lease.last_paid_ts = now.into(); lease.status = LeaseStatus::Active as u8; Ok(()) diff --git a/defi/asset-leasing/quasar/src/lib.rs b/defi/asset-leasing/quasar/src/lib.rs index 4b237bb2a..c98b4daf3 100644 --- a/defi/asset-leasing/quasar/src/lib.rs +++ b/defi/asset-leasing/quasar/src/lib.rs @@ -16,7 +16,7 @@ mod tests; // interchangeably. declare_id!("Lease11111111111111111111111111111111111111"); -/// Asset-leasing program: fixed-term token leases with a streaming rent +/// Asset-leasing program: fixed-term token leases with a streaming lease fee /// payment, collateral escrow, and Pyth-oracle-triggered liquidation. /// /// See the top-level `defi/asset-leasing/anchor/README.md` for the full @@ -34,7 +34,7 @@ mod quasar_asset_leasing { lease_id: u64, leased_amount: u64, required_collateral_amount: u64, - rent_per_second: u64, + lease_fee_per_second: u64, duration_seconds: i64, maintenance_margin_bps: u16, liquidation_bounty_bps: u16, @@ -45,7 +45,7 @@ mod quasar_asset_leasing { lease_id, leased_amount, required_collateral_amount, - rent_per_second, + lease_fee_per_second, duration_seconds, maintenance_margin_bps, liquidation_bounty_bps, @@ -60,8 +60,8 @@ mod quasar_asset_leasing { } #[instruction(discriminator = 2)] - pub fn pay_rent(ctx: Ctx) -> Result<(), ProgramError> { - instructions::handle_pay_rent(&mut ctx.accounts) + pub fn pay_lease_fee(ctx: Ctx) -> Result<(), ProgramError> { + instructions::handle_pay_lease_fee(&mut ctx.accounts) } #[instruction(discriminator = 3)] diff --git a/defi/asset-leasing/quasar/src/state.rs b/defi/asset-leasing/quasar/src/state.rs index ac911a59c..bd3a1351a 100644 --- a/defi/asset-leasing/quasar/src/state.rs +++ b/defi/asset-leasing/quasar/src/state.rs @@ -40,7 +40,7 @@ pub struct Lease { /// Caller-supplied id so one lessor can run many leases in parallel. pub lease_id: u64, - /// Signer of `create_lease`; paid rent and any final recovery. + /// Signer of `create_lease`; paid the lease fee and any final recovery. pub lessor: Address, /// Signer of `take_lease`. `Address::default()` while still `Listed`. @@ -51,25 +51,25 @@ pub struct Lease { pub leased_amount: u64, pub collateral_mint: Address, - /// Decreases as rent streams out; increases on `top_up_collateral`. + /// Decreases as lease fee streams out; increases on `top_up_collateral`. pub collateral_amount: u64, /// What the lessee must post on `take_lease`. pub required_collateral_amount: u64, /// Denominated in collateral-mint base units per second. - pub rent_per_second: u64, + pub lease_fee_per_second: u64, pub duration_seconds: i64, /// `0` while `Listed`; `unix_timestamp` of `take_lease` while `Active`. pub start_ts: i64, /// `0` while `Listed`; `start_ts + duration_seconds` once `Active`. pub end_ts: i64, - /// Rent accrues from here to `min(now, end_ts)`. - pub last_rent_paid_ts: i64, + /// Lease fee accrues from here to `min(now, end_ts)`. + pub last_paid_ts: i64, /// Collateral-over-debt ratio in basis points. /// `12_000` bps = 120%. Capped at `MAX_MAINTENANCE_MARGIN_BPS`. pub maintenance_margin_bps: u16, - /// Keeper's cut of the post-rent collateral on liquidation, in basis + /// Keeper's cut of the post-lease-fee collateral on liquidation, in basis /// points. Capped at `MAX_LIQUIDATION_BOUNTY_BPS` to stop a malicious /// lessor from draining the recovery pool via the bounty. pub liquidation_bounty_bps: u16, diff --git a/defi/asset-leasing/quasar/src/tests.rs b/defi/asset-leasing/quasar/src/tests.rs index 8edbe828d..05a58d40e 100644 --- a/defi/asset-leasing/quasar/src/tests.rs +++ b/defi/asset-leasing/quasar/src/tests.rs @@ -1,6 +1,6 @@ //! Quasar-SVM tests for the asset-leasing program. //! -//! Covers the full lifecycle: listing, taking, rent streaming, top-ups, +//! Covers the full lifecycle: listing, taking, lease fee streaming, top-ups, //! early return, keeper liquidation via a mocked Pyth `PriceUpdateV2` //! account, and lessor-initiated default recovery after expiry. //! @@ -35,7 +35,7 @@ const DECIMALS: u8 = 6; const LEASED_AMOUNT: u64 = 100_000_000; /// 200 collateral tokens at 6 decimals. const REQUIRED_COLLATERAL: u64 = 200_000_000; -const RENT_PER_SECOND: u64 = 10; +const LEASE_FEE_PER_SECOND: u64 = 10; /// 24 hours. const DURATION_SECONDS: i64 = 60 * 60 * 24; /// 120% maintenance margin, in basis points. @@ -46,7 +46,7 @@ const LIQUIDATION_BOUNTY_BPS: u16 = 500; const FEED_ID: [u8; 32] = [0xAB; 32]; /// LiteSVM's default clock starts at epoch 0; anchoring at a recent-ish -/// real timestamp keeps rent math free of sign-weirdness without any +/// real timestamp keeps lease fee math free of sign-weirdness without any /// tests having to special-case `start_ts = 0`. const DEFAULT_TIMESTAMP: i64 = 1_700_000_000; @@ -120,13 +120,13 @@ fn token(address: Pubkey, mint: Pubkey, owner: Pubkey, amount: u64) -> Account { /// Layout (after the `#[account(discriminator = 1)]` macro lowers fields /// to pod types): 1 disc + 8 lease_id + 32 lessor + 32 lessee + 32 /// leased_mint + 8 leased_amount + 32 collateral_mint + 8 collateral_amount -/// + 8 required_collateral + 8 rent_per_second + 8 duration + 8 start_ts + -/// 8 end_ts + 8 last_rent_paid_ts + 2 margin_bps + 2 bounty_bps + 32 +/// + 8 required_collateral + 8 lease_fee_per_second + 8 duration + 8 start_ts + +/// 8 end_ts + 8 last_paid_ts + 2 margin_bps + 2 bounty_bps + 32 /// feed_id + 4 status/bumps = 249 bytes. mod lease_offsets { pub const COLLATERAL_AMOUNT: usize = 1 + 8 + 32 + 32 + 32 + 8 + 32; - pub const LAST_RENT_PAID_TS: usize = COLLATERAL_AMOUNT + 8 + 8 + 8 + 8 + 8 + 8; - pub const STATUS: usize = LAST_RENT_PAID_TS + 8 + 2 + 2 + 32; + pub const LAST_PAID_TS: usize = COLLATERAL_AMOUNT + 8 + 8 + 8 + 8 + 8 + 8; + pub const STATUS: usize = LAST_PAID_TS + 8 + 2 + 2 + 32; } fn read_collateral_amount(data: &[u8]) -> u64 { @@ -170,7 +170,7 @@ fn build_create_lease_data( lease_id: u64, leased_amount: u64, required_collateral_amount: u64, - rent_per_second: u64, + lease_fee_per_second: u64, duration_seconds: i64, maintenance_margin_bps: u16, liquidation_bounty_bps: u16, @@ -180,7 +180,7 @@ fn build_create_lease_data( data.extend_from_slice(&lease_id.to_le_bytes()); data.extend_from_slice(&leased_amount.to_le_bytes()); data.extend_from_slice(&required_collateral_amount.to_le_bytes()); - data.extend_from_slice(&rent_per_second.to_le_bytes()); + data.extend_from_slice(&lease_fee_per_second.to_le_bytes()); data.extend_from_slice(&duration_seconds.to_le_bytes()); data.extend_from_slice(&maintenance_margin_bps.to_le_bytes()); data.extend_from_slice(&liquidation_bounty_bps.to_le_bytes()); @@ -279,7 +279,7 @@ fn create_lease_call(sc: &Scenario, lease_id: u64) -> (Instruction, Vec lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, - RENT_PER_SECOND, + LEASE_FEE_PER_SECOND, DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, @@ -356,7 +356,7 @@ fn create_lease_call_with_mints( lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, - RENT_PER_SECOND, + LEASE_FEE_PER_SECOND, DURATION_SECONDS, MAINTENANCE_MARGIN_BPS, LIQUIDATION_BOUNTY_BPS, @@ -457,7 +457,7 @@ fn take_lease_call(sc: &Scenario) -> (Instruction, Vec) { (ix, accounts) } -fn pay_rent_call(sc: &Scenario) -> (Instruction, Vec) { +fn pay_lease_fee_call(sc: &Scenario) -> (Instruction, Vec) { let ix = Instruction { program_id: crate::ID, accounts: vec![ @@ -688,25 +688,25 @@ fn make_and_take(svm: &mut QuasarSvm, sc: &Scenario) { } #[test] -fn pay_rent_streams_collateral_by_elapsed_time() { +fn pay_lease_fee_streams_collateral_by_elapsed_time() { let (mut svm, sc) = make_scenario(); make_and_take(&mut svm, &sc); - // Advance clock by 2 minutes and pay rent. + // Advance clock by 2 minutes and pay the lease fee. let elapsed: i64 = 120; svm.warp_to_timestamp(DEFAULT_TIMESTAMP + elapsed); - let (pay_ix, pay_accounts) = pay_rent_call(&sc); + let (pay_ix, pay_accounts) = pay_lease_fee_call(&sc); let result = svm.process_instruction(&pay_ix, &pay_accounts); - assert!(result.is_ok(), "pay_rent failed: {:?}", result.raw_result); + assert!(result.is_ok(), "pay_lease_fee failed: {:?}", result.raw_result); - let expected_rent = (elapsed as u64) * RENT_PER_SECOND; + let expected_lease_fees = (elapsed as u64) * LEASE_FEE_PER_SECOND; assert_eq!( read_token_amount(result.account(&sc.lessor_collateral_ta).unwrap()), - expected_rent + expected_lease_fees ); assert_eq!( read_token_amount(result.account(&sc.collateral_vault).unwrap()), - REQUIRED_COLLATERAL - expected_rent + REQUIRED_COLLATERAL - expected_lease_fees ); } @@ -744,18 +744,18 @@ fn return_lease_refunds_unused_collateral() { let result = svm.process_instruction(&ix, &accounts); assert!(result.is_ok(), "return_lease failed: {:?}", result.raw_result); - let rent_paid = (elapsed as u64) * RENT_PER_SECOND; - let refund_expected = REQUIRED_COLLATERAL - rent_paid; + let lease_fee_paid = (elapsed as u64) * LEASE_FEE_PER_SECOND; + let refund_expected = REQUIRED_COLLATERAL - lease_fee_paid; // Lessor got the full leased amount back. assert_eq!( read_token_amount(result.account(&sc.lessor_leased_ta).unwrap()), STARTING_BALANCE ); - // Lessor received the accrued rent. + // Lessor received the accrued lease fees. assert_eq!( read_token_amount(result.account(&sc.lessor_collateral_ta).unwrap()), - rent_paid + lease_fee_paid ); // Lessee got unused-time collateral back. assert_eq!( @@ -781,7 +781,7 @@ fn liquidate_seizes_collateral_on_price_drop() { let (mut svm, sc) = make_scenario(); make_and_take(&mut svm, &sc); - // Let 300 s of rent accrue so the handler settles rent *and* bounty + // Let 300 s of lease fees accrue so the handler settles lease fees *and* bounty // on the same vault balance. let elapsed: i64 = 300; let now_ts = DEFAULT_TIMESTAMP + elapsed; @@ -796,14 +796,14 @@ fn liquidate_seizes_collateral_on_price_drop() { let result = svm.process_instruction(&ix, &accounts); assert!(result.is_ok(), "liquidate failed: {:?}", result.raw_result); - let rent_paid = (elapsed as u64) * RENT_PER_SECOND; - let remaining_after_rent = REQUIRED_COLLATERAL - rent_paid; - let bounty = remaining_after_rent * (LIQUIDATION_BOUNTY_BPS as u64) / 10_000; - let lessor_share = remaining_after_rent - bounty; + let lease_fee_paid = (elapsed as u64) * LEASE_FEE_PER_SECOND; + let remaining_after_lease_fees = REQUIRED_COLLATERAL - lease_fee_paid; + let bounty = remaining_after_lease_fees * (LIQUIDATION_BOUNTY_BPS as u64) / 10_000; + let lessor_share = remaining_after_lease_fees - bounty; assert_eq!( read_token_amount(result.account(&sc.lessor_collateral_ta).unwrap()), - rent_paid + lessor_share + lease_fee_paid + lessor_share ); assert_eq!( read_token_amount(result.account(&sc.keeper_collateral_ta).unwrap()), From 10a6caaa53d1d4e8de6af0743ca0af338debced7 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Mon, 27 Apr 2026 23:31:23 +0000 Subject: [PATCH 13/41] docs+code: spell out all abbreviations in asset-leasing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No abbreviations anywhere — full English words for everything visible. Drops fake "COLLA" / "LEASED" placeholder tickers, expands ts → timestamp, bps → basis_points, pda → program-derived address, ata → associated token account, cpi → cross-program invocation in comments and local names, plus the usual ctx/acc/ auth/addr/tx/cfg/amt expansions. Anchor / upstream type names (CpiContext, AccountInfo, etc.) left as-is. --- defi/asset-leasing/anchor/README.md | 360 +++++----- .../anchor/programs/asset-leasing/Cargo.toml | 4 +- .../programs/asset-leasing/src/constants.rs | 20 +- .../src/instructions/close_expired.rs | 14 +- .../src/instructions/create_lease.rs | 28 +- .../src/instructions/liquidate.rs | 18 +- .../src/instructions/pay_lease_fee.rs | 24 +- .../src/instructions/return_lease.rs | 6 +- .../asset-leasing/src/instructions/shared.rs | 6 +- .../src/instructions/take_lease.rs | 14 +- .../src/instructions/top_up_collateral.rs | 2 +- .../anchor/programs/asset-leasing/src/lib.rs | 14 +- .../programs/asset-leasing/src/state/lease.rs | 22 +- .../asset-leasing/tests/test_asset_leasing.rs | 618 +++++++++--------- defi/asset-leasing/quasar/src/constants.rs | 20 +- .../quasar/src/instructions/close_expired.rs | 12 +- .../quasar/src/instructions/create_lease.rs | 26 +- .../quasar/src/instructions/liquidate.rs | 18 +- .../quasar/src/instructions/pay_lease_fee.rs | 24 +- .../quasar/src/instructions/return_lease.rs | 4 +- .../quasar/src/instructions/take_lease.rs | 12 +- .../src/instructions/top_up_collateral.rs | 2 +- defi/asset-leasing/quasar/src/lib.rs | 40 +- defi/asset-leasing/quasar/src/state.rs | 18 +- defi/asset-leasing/quasar/src/tests.rs | 532 +++++++-------- 25 files changed, 932 insertions(+), 926 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 80627df76..372a4a3ef 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -7,7 +7,7 @@ seizure path when the collateral is no longer worth enough. Every instruction handler is walked through with the exact token movements it causes. If you already know what collateral, a maintenance margin and an oracle are, you can skip straight to -[Accounts and PDAs](#2-accounts-and-pdas) or +[Accounts and program-derived addresses](#2-accounts-and-program-derived-addresses) or [Instruction handler lifecycle walkthrough](#3-instruction-handler-lifecycle-walkthrough). Solana terminology is defined at https://solana.com/docs/terminology. @@ -19,7 +19,7 @@ appear. ## Table of contents 1. [What does this program do?](#1-what-does-this-program-do) -2. [Accounts and PDAs](#2-accounts-and-pdas) +2. [Accounts and program-derived addresses](#2-accounts-and-program-derived-addresses) 3. [Instruction handler lifecycle walkthrough](#3-instruction-handler-lifecycle-walkthrough) 4. [Full-lifecycle worked examples](#4-full-lifecycle-worked-examples) 5. [Safety and edge cases](#5-safety-and-edge-cases) @@ -64,7 +64,7 @@ The program acts as a non-custodial escrow. It: The trigger for step 4 is the **maintenance margin**: a ratio, expressed in basis points (1 bp = 1/100 of a percent), of required -collateral value to debt value. `maintenance_margin_bps = 12_000` is +collateral value to debt value. `maintenance_margin_basis_points = 12_000` is 120%, meaning the collateral must stay worth at least 1.2× the leased tokens. Drop below and the position becomes liquidatable. @@ -119,8 +119,8 @@ Alice lists the lease (assume USDC is 6-decimal, xNVDA is also | `required_collateral_amount` | `22_000_000_000` (22 000 USDC) | ~122% LTV at the spot price | | `lease_fee_per_second` | `456` (USDC base units / s) | ≈ 8% APR on 18 000 USDC notional | | `duration_seconds` | `2_592_000` | 30 days | -| `maintenance_margin_bps` | `11_000` | 110% | -| `liquidation_bounty_bps` | `100` | 1% of post-fee collateral | +| `maintenance_margin_basis_points` | `11_000` | 110% | +| `liquidation_bounty_basis_points` | `100` | 1% of post-fee collateral | | `feed_id` | Pyth xNVDA/USD feed id | ([Pyth feed registry](https://www.pyth.network/price-feeds)) | Bob calls `take_lease`, posts 22 000 USDC, receives 100 xNVDA, and @@ -173,24 +173,24 @@ applied to a real asset pair. --- -## 2. Accounts and PDAs +## 2. Accounts and program-derived addresses Every call to the program touches some subset of these accounts. The -three PDAs are created on `create_lease` and destroyed on `return_lease` +three program-derived addresses are created on `create_lease` and destroyed on `return_lease` / `liquidate` / `close_expired`. ### State / data accounts -| Account | PDA? | Seeds | Kind | Authority | Holds | +| Account | program-derived address? | Seeds | Kind | Authority | Holds | |---|---|---|---|---|---| | `Lease` | yes | `["lease", lessor, lease_id]` | data | program | all the lease parameters and current lifecycle state (see below) | ### Token vaults -| Account | PDA? | Seeds | Kind | Authority | Holds | +| Account | program-derived address? | Seeds | Kind | Authority | Holds | |---|---|---|---|---|---| -| `leased_vault` | yes | `["leased_vault", lease]` | token account | itself (PDA-signed) | `leased_amount` while `Listed`; 0 while `Active` (lessee has the tokens); full amount again briefly inside `return_lease` | -| `collateral_vault` | yes | `["collateral_vault", lease]` | token account | itself (PDA-signed) | 0 while `Listed`; `collateral_amount` while `Active`, decreasing as lease fee streams out and increasing on `top_up_collateral` | +| `leased_vault` | yes | `["leased_vault", lease]` | token account | itself (program-derived address-signed) | `leased_amount` while `Listed`; 0 while `Active` (lessee has the tokens); full amount again briefly inside `return_lease` | +| `collateral_vault` | yes | `["collateral_vault", lease]` | token account | itself (program-derived address-signed) | 0 while `Listed`; `collateral_amount` while `Active`, decreasing as lease fee streams out and increasing on `top_up_collateral` | ### User accounts passed in @@ -200,11 +200,11 @@ three PDAs are created on `create_lease` and destroyed on `return_lease` | `lessee` wallet | user | `take_lease` / `top_up_collateral` / `return_lease` signer | | `keeper` wallet | user | `liquidate` signer, receives the bounty | | `payer` wallet | user | `pay_lease_fee` signer (can be anyone, not just the lessee) | -| `lessor_leased_account` | token account | lessor's ATA for the leased mint; source on `create_lease`, destination on `return_lease` / `close_expired` | -| `lessor_collateral_account` | token account | lessor's ATA for the collateral mint; destination for the lease fee and liquidation proceeds | -| `lessee_leased_account` | token account | lessee's ATA for the leased mint; destination on `take_lease`, source on `return_lease` | -| `lessee_collateral_account` | token account | lessee's ATA for the collateral mint; source on `take_lease` / `top_up_collateral`, destination for collateral refund on `return_lease` | -| `keeper_collateral_account` | token account | keeper's ATA for the collateral mint; receives the liquidation bounty | +| `lessor_leased_account` | token account | lessor's associated token account for the leased mint; source on `create_lease`, destination on `return_lease` / `close_expired` | +| `lessor_collateral_account` | token account | lessor's associated token account for the collateral mint; destination for the lease fee and liquidation proceeds | +| `lessee_leased_account` | token account | lessee's associated token account for the leased mint; destination on `take_lease`, source on `return_lease` | +| `lessee_collateral_account` | token account | lessee's associated token account for the collateral mint; source on `take_lease` / `top_up_collateral`, destination for collateral refund on `return_lease` | +| `keeper_collateral_account` | token account | keeper's associated token account for the collateral mint; receives the liquidation bounty | | `price_update` | Pyth Receiver program | `PriceUpdateV2` account for the feed the lease is pinned to | ### Fields on `Lease` @@ -226,12 +226,12 @@ pub struct Lease { pub lease_fee_per_second: u64, // denominated in collateral units pub duration_seconds: i64, - pub start_ts: i64, // 0 while Listed - pub end_ts: i64, // 0 while Listed; start_ts + duration once Active - pub last_paid_ts: i64, // Lease fee accrues from here to min(now, end_ts) + pub start_timestamp: i64, // 0 while Listed + pub end_timestamp: i64, // 0 while Listed; start_timestamp + duration once Active + pub last_paid_timestamp: i64, // Lease fee accrues from here to min(now, end_timestamp) - pub maintenance_margin_bps: u16, // e.g. 12_000 = 120% - pub liquidation_bounty_bps: u16, // e.g. 500 = 5% + pub maintenance_margin_basis_points: u16, // e.g. 12_000 = 120% + pub liquidation_bounty_basis_points: u16, // e.g. 500 = 5% pub feed_id: [u8; 32], // Pyth feed_id this lease is pinned to @@ -257,7 +257,7 @@ pub struct Lease { | Active | ----> | Closed | +---------------+ +--------+ | | | - return_lease| | | close_expired (after end_ts) + return_lease| | | close_expired (after end_timestamp) | | liquidate v v v +--------+ +-----------+ @@ -291,7 +291,7 @@ them — the order below — is: 7. `close_expired` (lessor) — **default / cancel path** For each, the shape is the same: who signs, what accounts go in, which -PDAs get created or closed, which tokens move, what state changes, what +program-derived addresses get created or closed, which tokens move, what state changes, what checks the program runs. Token-flow diagrams use the following shorthand: @@ -317,8 +317,8 @@ pub fn create_lease( required_collateral_amount: u64, lease_fee_per_second: u64, duration_seconds: i64, - maintenance_margin_bps: u16, - liquidation_bounty_bps: u16, + maintenance_margin_basis_points: u16, + liquidation_bounty_basis_points: u16, feed_id: [u8; 32], ) -> Result<()> ``` @@ -327,13 +327,13 @@ pub fn create_lease( - `lessor` (signer, mut — pays account rent) - `leased_mint`, `collateral_mint` (read-only) -- `lessor_leased_account` (mut, lessor's ATA for the leased mint — source) -- `lease` (PDA, **init**) — created here -- `leased_vault` (PDA, **init**, token account) — created here -- `collateral_vault` (PDA, **init**, token account) — created here +- `lessor_leased_account` (mut, lessor's associated token account for the leased mint — source) +- `lease` (program-derived address, **init**) — created here +- `leased_vault` (program-derived address, **init**, token account) — created here +- `collateral_vault` (program-derived address, **init**, token account) — created here - `token_program`, `system_program` -**PDAs created:** +**program-derived addresses created:** - `lease` with seeds `[b"lease", lessor, lease_id.to_le_bytes()]` - `leased_vault` with seeds `[b"leased_vault", lease]`, authority = itself @@ -346,26 +346,26 @@ pub fn create_lease( - `required_collateral_amount > 0` → `InvalidCollateralAmount` - `lease_fee_per_second > 0` → `InvalidLeaseFeePerSecond` - `duration_seconds > 0` → `InvalidDuration` -- `0 < maintenance_margin_bps <= 50_000` → `InvalidMaintenanceMargin` -- `liquidation_bounty_bps <= 2_000` → `InvalidLiquidationBounty` +- `0 < maintenance_margin_basis_points <= 50_000` → `InvalidMaintenanceMargin` +- `liquidation_bounty_basis_points <= 2_000` → `InvalidLiquidationBounty` **Token movements:** ``` - lessor_leased_account --[leased_amount of leased_mint]--> leased_vault PDA + lessor_leased_account --[leased_amount of leased_mint]--> leased_vault program-derived address ``` **State changes:** - New `Lease` account written with `status = Listed`, `lessee = - Pubkey::default()`, `collateral_amount = 0`, `start_ts = 0`, - `end_ts = 0`, `last_paid_ts = 0`, and the given parameters + Pubkey::default()`, `collateral_amount = 0`, `start_timestamp = 0`, + `end_timestamp = 0`, `last_paid_timestamp = 0`, and the given parameters including `feed_id`. All three bumps stored. **Why lock the leased tokens up-front rather than on `take_lease`?** So a lessee who calls `take_lease` cannot possibly fail because the lessor doesn't have the tokens any more — the atomicity guarantee is -transferred to the PDA the moment the lease is listed. +transferred to the program-derived address the moment the lease is listed. ### 3.2 `take_lease` @@ -378,13 +378,13 @@ take delivery. **Accounts in:** - `lessee` (signer, mut) -- `lessor` (UncheckedAccount — read for PDA seed derivation only, no +- `lessor` (UncheckedAccount — read for program-derived address seed derivation only, no signature required) - `lease` (mut, `has_one = lessor`, `has_one = leased_mint`, `has_one = collateral_mint`, must be `Listed`) - `leased_mint`, `collateral_mint` -- `leased_vault`, `collateral_vault` (both mut, both PDA-derived) -- `lessee_collateral_account` (mut, lessee's ATA — source) +- `leased_vault`, `collateral_vault` (both mut, both program-derived address-derived) +- `lessee_collateral_account` (mut, lessee's associated token account — source) - `lessee_leased_account` (mut, **init_if_needed** — destination) - `token_program`, `associated_token_program`, `system_program` @@ -398,8 +398,8 @@ take delivery. **Token movements (in order):** ``` - lessee_collateral_account --[required_collateral_amount of collateral_mint]--> collateral_vault PDA - leased_vault PDA --[leased_amount of leased_mint]-----------------> lessee_leased_account + lessee_collateral_account --[required_collateral_amount of collateral_mint]--> collateral_vault program-derived address + leased_vault program-derived address --[leased_amount of leased_mint]-----------------> lessee_leased_account ``` Collateral is deposited *first* so if the leased-token transfer fails @@ -410,9 +410,9 @@ collateral back. - `lease.lessee = lessee.key()` - `lease.collateral_amount = required_collateral_amount` -- `lease.start_ts = now` -- `lease.end_ts = now + duration_seconds` (checked add, errors on overflow) -- `lease.last_paid_ts = now` (nothing has accrued yet) +- `lease.start_timestamp = now` +- `lease.end_timestamp = now + duration_seconds` (checked add, errors on overflow) +- `lease.last_paid_timestamp = now` (nothing has accrued yet) - `lease.status = Active` ### 3.3 `pay_lease_fee` @@ -425,7 +425,7 @@ liquidation check so healthy leases stay healthy. **Accounts in:** -- `payer` (signer, mut — pays for `init_if_needed` of the lessor ATA) +- `payer` (signer, mut — pays for `init_if_needed` of the lessor associated token account) - `lessor` (UncheckedAccount, read-only — used for `has_one` check) - `lease` (mut, must be `Active`) - `collateral_mint`, `collateral_vault` @@ -436,24 +436,24 @@ liquidation check so healthy leases stay healthy. ```rust pub fn compute_lease_fee_due(lease: &Lease, now: i64) -> Result { - let cutoff = now.min(lease.end_ts); - if cutoff <= lease.last_paid_ts { + let cutoff = now.min(lease.end_timestamp); + if cutoff <= lease.last_paid_timestamp { return Ok(0); } - let elapsed = (cutoff - lease.last_paid_ts) as u64; + let elapsed = (cutoff - lease.last_paid_timestamp) as u64; elapsed.checked_mul(lease.lease_fee_per_second) .ok_or(AssetLeasingError::MathOverflow.into()) } ``` -Lease fees do not accrue past `end_ts`. Past the deadline the lessee is +Lease fees do not accrue past `end_timestamp`. Past the deadline the lessee is either returning the tokens (via `return_lease`), being liquidated, or defaulting — no more lease fees are owed. **Token movements:** ``` - collateral_vault PDA --[min(lease_fee_due, collateral_amount) of collateral_mint]--> lessor_collateral_account + collateral_vault program-derived address --[min(lease_fee_due, collateral_amount) of collateral_mint]--> lessor_collateral_account ``` If the vault does not have enough collateral to cover the full @@ -464,7 +464,7 @@ clean up. **State changes:** - `lease.collateral_amount -= payable` -- `lease.last_paid_ts = now.min(end_ts)` +- `lease.last_paid_timestamp = now.min(end_timestamp)` ### 3.4 `top_up_collateral` @@ -494,7 +494,7 @@ by adding more of the collateral mint to the vault. **Token movements:** ``` - lessee_collateral_account --[amount of collateral_mint]--> collateral_vault PDA + lessee_collateral_account --[amount of collateral_mint]--> collateral_vault program-derived address ``` **State changes:** @@ -504,8 +504,8 @@ by adding more of the collateral mint to the vault. ### 3.5 `return_lease` **Who calls it:** the lessee, while the lease is still `Active` and -before or after `end_ts` (the only timing rule is that `status == -Active`; Lease fees only accrue up to `end_ts` so returning after the +before or after `end_timestamp` (the only timing rule is that `status == +Active`; Lease fees only accrue up to `end_timestamp` so returning after the deadline does not pile on extra charges). **Signers:** `lessee`. @@ -532,27 +532,27 @@ deadline does not pile on extra charges). **Token movements (in order):** ``` - lessee_leased_account --[leased_amount of leased_mint]----------> leased_vault PDA - leased_vault PDA --[leased_amount of leased_mint]----------> lessor_leased_account - collateral_vault PDA --[lease_fee_payable of collateral_mint]-------> lessor_collateral_account - collateral_vault PDA --[collateral_after_lease_fees of collateral_mint]--> lessee_collateral_account + lessee_leased_account --[leased_amount of leased_mint]----------> leased_vault program-derived address + leased_vault program-derived address --[leased_amount of leased_mint]----------> lessor_leased_account + collateral_vault program-derived address --[lease_fee_payable of collateral_mint]-------> lessor_collateral_account + collateral_vault program-derived address --[collateral_after_lease_fees of collateral_mint]--> lessee_collateral_account ``` The leased tokens hop through the vault rather than going direct lessee→lessor because the vault's token account is already set up and -the program can reuse its PDA signing path. The atomic round-trip keeps -the vault's post-ix balance at 0 so it can be closed. +the program can reuse its program-derived address signing path. The atomic round-trip keeps +the vault's post-instruction balance at 0 so it can be closed. After the transfers: -- Both vaults are closed via `close_account` CPIs; their rent-exempt +- Both vaults are closed via `close_account` cross-program invocations; their rent-exempt lamports go to the lessor. - The `Lease` account is closed via Anchor's `close = lessor` constraint; its rent-exempt lamports go to the lessor too. **State changes before close:** -- `lease.last_paid_ts = now.min(end_ts)` +- `lease.last_paid_timestamp = now.min(end_timestamp)` - `lease.collateral_amount = 0` - `lease.status = Closed` @@ -565,7 +565,7 @@ underwater. **Accounts in:** -- `keeper` (signer, mut — pays `init_if_needed` cost for both ATAs) +- `keeper` (signer, mut — pays `init_if_needed` cost for both associated token accounts) - `lessor` (UncheckedAccount, mut — receives the lease fee + lessor_share + the `Lease` and vault rent-exempt lamports) - `lease` (mut, `close = lessor`, must be `Active`) @@ -594,7 +594,7 @@ The underwater check, in integers: ``` collateral_value_in_colla_units * 10_000 - < debt_value_in_colla_units * maintenance_margin_bps + < debt_value_in_colla_units * maintenance_margin_basis_points ``` where `debt_value = leased_amount * price * 10^exponent` (with the @@ -604,10 +604,10 @@ exponent folded into whichever side keeps the math non-negative, see **Token movements:** ``` - collateral_vault PDA --[lease_fee_payable of collateral_mint]---------------------> lessor_collateral_account - collateral_vault PDA --[bounty = remaining * bounty_bps / 10_000]-----------> keeper_collateral_account - collateral_vault PDA --[remaining - bounty of collateral_mint]--------------> lessor_collateral_account - leased_vault PDA --[0 of leased_mint] (empty — lessee kept the tokens) close only + collateral_vault program-derived address --[lease_fee_payable of collateral_mint]---------------------> lessor_collateral_account + collateral_vault program-derived address --[bounty = remaining * bounty_basis_points / 10_000]-----------> keeper_collateral_account + collateral_vault program-derived address --[remaining - bounty of collateral_mint]--------------> lessor_collateral_account + leased_vault program-derived address --[0 of leased_mint] (empty — lessee kept the tokens) close only ``` After the three outbound collateral transfers (lease fee, bounty, lessor @@ -618,7 +618,7 @@ closed the same way (Anchor `close = lessor`). **State changes before close:** - `lease.collateral_amount = 0` -- `lease.last_paid_ts = now.min(end_ts)` +- `lease.last_paid_timestamp = now.min(end_timestamp)` - `lease.status = Liquidated` ### 3.7 `close_expired` @@ -629,7 +629,7 @@ into this single handler: - **Cancel a `Listed` lease** — the lessor changes their mind, no-one has taken the lease yet. Allowed any time. - **Reclaim collateral after default** — the lease is `Active`, `now >= - end_ts`, the lessee has not called `return_lease`. The lessor takes + end_timestamp`, the lessee has not called `return_lease`. The lessor takes the whole collateral vault as compensation. **Signers:** `lessor`. @@ -648,20 +648,20 @@ into this single handler: - `status ∈ {Listed, Active}` (Anchor `constraint matches!(...)`) → `InvalidLeaseStatus` -- If `status == Active`, also `now >= end_ts` → `LeaseNotExpired` +- If `status == Active`, also `now >= end_timestamp` → `LeaseNotExpired` **Token movements:** For a `Listed` cancel: ``` - leased_vault PDA --[leased_amount of leased_mint]--> lessor_leased_account - collateral_vault PDA is empty (0 transferred) + leased_vault program-derived address --[leased_amount of leased_mint]--> lessor_leased_account + collateral_vault program-derived address is empty (0 transferred) ``` For an `Active` default: ``` - leased_vault PDA is empty (lessee kept the tokens) - collateral_vault PDA --[collateral_amount of collateral_mint]--> lessor_collateral_account + leased_vault program-derived address is empty (lessee kept the tokens) + collateral_vault program-derived address --[collateral_amount of collateral_mint]--> lessor_collateral_account ``` In both cases both vaults are then closed and the `Lease` account is @@ -669,7 +669,7 @@ closed; all three rent-exempt lamport refunds go to the lessor. **State changes before close:** -- If `Active`: `lease.last_paid_ts = now.min(end_ts)` +- If `Active`: `lease.last_paid_timestamp = now.min(end_timestamp)` (settles the accounting so any future program version that wants to split the default pot differently has a correct timestamp to start from) @@ -681,62 +681,67 @@ closed; all three rent-exempt lamport refunds go to the lessor. ## 4. Full-lifecycle worked examples All three use the same starting numbers so the arithmetic is easy to -follow. Both mints are 6-decimal tokens. "LEASED" means one base -unit of the leased mint; "COLLA" means one base unit of the collateral -mint. - -- `leased_amount = 100_000_000` LEASED (100 tokens). -- `required_collateral_amount = 200_000_000` COLLA (200 tokens). -- `lease_fee_per_second = 10` COLLA. +follow. Both mints are 6-decimal tokens, so 1 token = 1 000 000 base +units. Throughout this section, "leased units" means base units of +the leased mint and "collateral units" means base units of the +collateral mint — they are descriptive labels, not real tickers. +The diagrams use the same convention: `[ leased]` and +`[ collateral]`. + +- `leased_amount = 100_000_000` (100 leased tokens). +- `required_collateral_amount = 200_000_000` (200 collateral tokens). +- `lease_fee_per_second = 10` collateral units. - `duration_seconds = 86_400` (24 hours). -- `maintenance_margin_bps = 12_000` (120%). -- `liquidation_bounty_bps = 500` (5% of post-lease-fee collateral). +- `maintenance_margin_basis_points = 12_000` (120%). +- `liquidation_bounty_basis_points = 500` (5% of post-lease-fee collateral). - `feed_id = [0xAB; 32]` (arbitrary, consistent across all calls). -Lessor starts with 1 000 000 000 LEASED in their ATA. Lessee starts -with 1 000 000 000 COLLA in theirs. +Lessor starts with 1 000 000 000 leased units in +their associated token account. Lessee starts with 1 000 000 000 +collateral units in theirs. ### 4.1 Happy path — lessee returns on time Calls, in order: -1. **`create_lease`** — lessor posts 100 LEASED into `leased_vault`, - parameters written to `lease`. +1. **`create_lease`** — lessor posts 100 leased tokens into + `leased_vault`, parameters written to `lease`. ``` - lessor_leased_account --[100_000_000 LEASED]--> leased_vault PDA + lessor_leased_account --[100_000_000 leased]--> leased_vault program-derived address ``` - Balances after: lessor has 900 000 000 LEASED, `leased_vault` has - 100 000 000 LEASED, `collateral_vault` has 0. + Balances after: lessor has 900 000 000 leased units, `leased_vault` has + 100 000 000 leased units, `collateral_vault` has 0. -2. **`take_lease`** — lessee posts 200 COLLA, receives 100 LEASED. +2. **`take_lease`** — lessee posts 200 collateral tokens, receives + 100 leased tokens. ``` - lessee_collateral_account --[200_000_000 COLLA]--> collateral_vault PDA - leased_vault PDA --[100_000_000 LEASED]--> lessee_leased_account + lessee_collateral_account --[200_000_000 collateral]--> collateral_vault program-derived address + leased_vault program-derived address --[100_000_000 leased]--> lessee_leased_account ``` - `lease.status = Active`, `start_ts = T`, `end_ts = T + 86_400`. + `lease.status = Active`, `start_timestamp = T`, `end_timestamp = T + 86_400`. 3. **`pay_lease_fee`** called at `T + 120` seconds. Lease fee due = 120 × 10 = - 1 200 COLLA. + 1 200 collateral units. ``` - collateral_vault PDA --[1_200 COLLA]--> lessor_collateral_account + collateral_vault program-derived address --[1_200 collateral]--> lessor_collateral_account ``` `collateral_amount = 200_000_000 − 1_200 = 199_998_800`. 4. **`top_up_collateral(amount = 50_000_000)`** at `T + 600`. Lessee decides to add a cushion. ``` - lessee_collateral_account --[50_000_000 COLLA]--> collateral_vault PDA + lessee_collateral_account --[50_000_000 collateral]--> collateral_vault program-derived address ``` `collateral_amount = 199_998_800 + 50_000_000 = 249_998_800`. 5. **`return_lease`** called at `T + 3_600` (one hour in). Total lease fees - from `start_ts` to `now` is 3 600 × 10 = 36 000 COLLA; 1 200 of that - was paid in step 3. Residual lease fees = 36 000 − 1 200 = 34 800 COLLA. + from `start_timestamp` to `now` is 3 600 × 10 = 36 000 collateral units; 1 200 of that + was paid in step 3. Residual lease fees = 36 000 − 1 200 = 34 800 collateral units. ``` - lessee_leased_account --[100_000_000 LEASED]--> leased_vault PDA - leased_vault PDA --[100_000_000 LEASED]--> lessor_leased_account - collateral_vault PDA --[34_800 COLLA]--------> lessor_collateral_account - collateral_vault PDA --[249_964_000 COLLA]---> lessee_collateral_account + lessee_leased_account --[100_000_000 leased]--> leased_vault program-derived address + leased_vault program-derived address --[100_000_000 leased]--> lessor_leased_account + collateral_vault program-derived address --[34_800 collateral]--------> lessor_collateral_account + collateral_vault program-derived address --[249_964_000 collateral]---> lessee_collateral_account ``` Where `249_964_000 = 249_998_800 − 34_800`. @@ -745,11 +750,11 @@ Calls, in order: **Final balances:** -- Lessor: 1 000 000 000 LEASED (full return), 36 000 COLLA (total lease fees +- Lessor: 1 000 000 000 leased units (full return), 36 000 collateral units (total lease fees received in steps 3 + 5), plus the lamports from three account closes. -- Lessee: 100 000 000 LEASED → 0 (all returned), COLLA: started with +- Lessee: 100 000 000 leased units → 0 (all returned), collateral: started with 1 000 000 000, spent 200 000 000 on initial deposit + 50 000 000 on - top-up, got back 249 964 000, so holds 999 964 000 COLLA (net cost + top-up, got back 249 964 000, so holds 999 964 000 collateral units (net cost of 36 000 — exactly the total lease fees paid). ### 4.2 Liquidation path @@ -759,39 +764,39 @@ Same setup. Steps 1 and 2 run identically. 3. Time jumps to `T + 300`. A keeper observes a new Pyth price update: the leased-in-collateral price has spiked to 4.0 (exponent 0, price = 4). At that price, the debt value is `100_000_000 × 4 = - 400_000_000` COLLA. The collateral is still ~`200_000_000` COLLA - (minus some streamed lease fees). Maintenance ratio = `200/400 = 50%`, - well below the required 120%. + 400_000_000` collateral units. The collateral + pot is still ~`200_000_000` (minus some streamed lease fees). + Maintenance ratio = `200/400 = 50%`, well below the required 120%. The keeper calls `pay_lease_fee` first is *not* required — `liquidate` settles accrued lease fees itself. It goes straight to `liquidate`. 4. **`liquidate`** at `T + 300`: - - Lease fee due = 300 × 10 = 3 000 COLLA; collateral_amount = 200 000 000 + - Lease fee due = 300 × 10 = 3 000 collateral units; collateral_amount = 200 000 000 so `lease_fee_payable = 3 000`. ``` - collateral_vault PDA --[3_000 COLLA]--> lessor_collateral_account + collateral_vault program-derived address --[3_000 collateral]--> lessor_collateral_account ``` - - Remaining = 200 000 000 − 3 000 = 199 997 000 COLLA. - - Bounty = 199 997 000 × 500 / 10 000 = 9 999 850 COLLA. + - Remaining = 200 000 000 − 3 000 = 199 997 000 collateral units. + - Bounty = 199 997 000 × 500 / 10 000 = 9 999 850 collateral units. ``` - collateral_vault PDA --[9_999_850 COLLA]--> keeper_collateral_account + collateral_vault program-derived address --[9_999_850 collateral]--> keeper_collateral_account ``` - - Lessor share = 199 997 000 − 9 999 850 = 189 997 150 COLLA. + - Lessor share = 199 997 000 − 9 999 850 = 189 997 150 collateral units. ``` - collateral_vault PDA --[189_997_150 COLLA]--> lessor_collateral_account + collateral_vault program-derived address --[189_997_150 collateral]--> lessor_collateral_account ``` - Both vaults close; Lease closes. Status recorded as `Liquidated`. **Final balances:** -- Lessor: 900 000 000 LEASED (never got the 100 back — the lessee kept - them), `3 000 + 189 997 150 = 190 000 150` COLLA, plus rent-exempt - lamports from three closes. -- Lessee: *still* has 100 000 000 LEASED. Spent 200 000 000 COLLA on +- Lessor: 900 000 000 leased units (never got the 100 back — the + lessee kept them), `3 000 + 189 997 150 = 190 000 150` collateral + units, plus rent-exempt lamports from three closes. +- Lessee: *still* has 100 000 000 leased units. Spent 200 000 000 collateral units on deposit, got nothing back. Net: they walk away with the leased tokens but forfeited the entire collateral minus the keeper's cut. -- Keeper: 9 999 850 COLLA for their trouble. +- Keeper: 9 999 850 collateral units for their trouble. (This is the key asymmetry: liquidation does *not* reclaim the leased tokens. The collateral pays the lessor for the lost asset. The lessee @@ -811,39 +816,40 @@ Same setup. Steps 1 and 2 run identically. 3. Time jumps to `T + 300`. The leased-in-collateral price has *fallen* to 0.5 (exponent 0, price = 0). To make the math non-trivial, take exponent = −1, price = 5: the debt value is - `100_000_000 × 5 / 10 = 50_000_000` COLLA. The collateral is - ~`200_000_000` COLLA (minus a tiny bit of streamed lease fees). - Maintenance ratio = `200_000_000 / 50_000_000 = 400%`, far above - the required 120%. + `100_000_000 × 5 / 10 = 50_000_000` collateral units. The + collateral pot is ~`200_000_000` (minus a tiny bit of streamed + lease fees). Maintenance ratio = `200_000_000 / 50_000_000 = + 400%`, far above the required 120%. A keeper calling `liquidate` here would fail with `PositionHealthy` — the program refuses to seize a healthy position. The lessee is in the clear. 4. **`return_lease`** called at `T + 600` (10 minutes in). The - lessee buys 100 LEASED on the open market at the new price (about - 50 COLLA total — far less than the 200 COLLA they posted), then - returns those tokens to close out the lease. + lessee buys 100 leased tokens on the open market at the new price + (about 50 collateral tokens total — far less than the 200 + collateral tokens they posted), then returns those tokens to + close out the lease. - Lease fees accrued: 600 × 10 = 6 000 COLLA. + Lease fees accrued: 600 × 10 = 6 000 collateral units. ``` - lessee_leased_account --[100_000_000 LEASED]--> leased_vault PDA - leased_vault PDA --[100_000_000 LEASED]--> lessor_leased_account - collateral_vault PDA --[6_000 COLLA]---------> lessor_collateral_account - collateral_vault PDA --[199_994_000 COLLA]---> lessee_collateral_account + lessee_leased_account --[100_000_000 leased]--> leased_vault program-derived address + leased_vault program-derived address --[100_000_000 leased]--> lessor_leased_account + collateral_vault program-derived address --[6_000 collateral]---------> lessor_collateral_account + collateral_vault program-derived address --[199_994_000 collateral]---> lessee_collateral_account ``` **Final balances:** -- Lessor: 1 000 000 000 LEASED (full return), 6 000 COLLA in lease +- Lessor: 1 000 000 000 leased units (full return), 6 000 collateral units in lease fees. -- Lessee: 100 000 000 LEASED received → bought 100 LEASED back at - the lower price → returned them. Their net cost is the lease fee - (6 000 COLLA) plus whatever they paid on the open market for the - replacement leased tokens; their gain is the difference between - what they originally received the leased tokens at versus what - they paid to re-acquire them. +- Lessee: 100 000 000 leased units received → bought 100 leased tokens + back at the lower price → returned them. Their net cost is the + lease fee (6 000 collateral units) plus whatever + they paid on the open market for the replacement leased tokens; + their gain is the difference between what they originally received + the leased tokens at versus what they paid to re-acquire them. This is the same payoff shape as a short on the leased asset: the lessee profits from price drops and pays a small carry (the lease @@ -857,26 +863,26 @@ Same setup. Steps 1 and 2 run as usual. The lessee takes the tokens, posts collateral, then disappears. 3. `pay_lease_fee` is never called. Clock advances all the way past - `end_ts = T + 86_400`. + `end_timestamp = T + 86_400`. 4. **`close_expired`** called by the lessor at `T + 100_000`: - - `status == Active` and `now >= end_ts` → the default branch runs. + - `status == Active` and `now >= end_timestamp` → the default branch runs. - `leased_vault` is empty (lessee kept the tokens). No transfer. - - `collateral_vault` has 200 000 000 COLLA. All of it goes to the + - `collateral_vault` has 200 000 000 collateral units. All of it goes to the lessor: ``` - collateral_vault PDA --[200_000_000 COLLA]--> lessor_collateral_account + collateral_vault program-derived address --[200_000_000 collateral]--> lessor_collateral_account ``` - Both vaults close; Lease closes. - - `last_paid_ts = min(now, end_ts) = end_ts` (step added in + - `last_paid_timestamp = min(now, end_timestamp) = end_timestamp` (step added in Fix 5). **Final balances:** -- Lessor: 900 000 000 LEASED, 200 000 000 COLLA (the whole collateral - as compensation), plus three account-close refunds. -- Lessee: 100 000 000 LEASED, −200 000 000 COLLA. They paid the whole - collateral and kept the leased tokens. +- Lessor: 900 000 000 leased units, 200 000 000 collateral units (the entire + collateral pot as compensation), plus three account-close refunds. +- Lessee: 100 000 000 leased units, −200 000 000 collateral units. They paid the + full collateral and kept the leased tokens. ### 4.5 Default / expiry path — `close_expired` on a `Listed` lease @@ -885,14 +891,14 @@ This is the cheap cancel path. No lessee ever showed up. 1. `create_lease` as above. 2. `close_expired` called by the lessor immediately. - `status == Listed` → no expiry check. - - `leased_vault` holds 100 000 000 LEASED. Drain back: + - `leased_vault` holds 100 000 000 leased units. Drain back: ``` - leased_vault PDA --[100_000_000 LEASED]--> lessor_leased_account + leased_vault program-derived address --[100_000_000 leased]--> lessor_leased_account ``` - `collateral_vault` is empty. No transfer. - Both vaults close; Lease closes. -**Final balances:** lessor is back to 1 000 000 000 LEASED; nothing +**Final balances:** lessor is back to 1 000 000 000 leased units; nothing else moved. --- @@ -912,10 +918,10 @@ handler: | `InvalidLeasedAmount` | `leased_amount == 0` on `create_lease` | | `InvalidCollateralAmount` | `required_collateral_amount == 0` on `create_lease`; `amount == 0` on `top_up_collateral` | | `InvalidLeaseFeePerSecond` | `lease_fee_per_second == 0` on `create_lease` | -| `InvalidMaintenanceMargin` | `maintenance_margin_bps == 0` or `> 50_000` on `create_lease` | -| `InvalidLiquidationBounty` | `liquidation_bounty_bps > 2_000` on `create_lease` | -| `LeaseExpired` | Reserved; not currently used (Lease fee accrual naturally caps at `end_ts`) | -| `LeaseNotExpired` | `close_expired` called on an `Active` lease before `end_ts` | +| `InvalidMaintenanceMargin` | `maintenance_margin_basis_points == 0` or `> 50_000` on `create_lease` | +| `InvalidLiquidationBounty` | `liquidation_bounty_basis_points > 2_000` on `create_lease` | +| `LeaseExpired` | Reserved; not currently used (Lease fee accrual naturally caps at `end_timestamp`) | +| `LeaseNotExpired` | `close_expired` called on an `Active` lease before `end_timestamp` | | `PositionHealthy` | `liquidate` called on a lease that passes the maintenance-margin check | | `StalePrice` | Pyth price update older than 60 s, or has a future `publish_time`, or fails discriminator / length check | | `NonPositivePrice` | Pyth price is `<= 0` | @@ -959,7 +965,7 @@ handler: - **Max maintenance margin = 500%.** Without an upper bound a lessor could set a margin that is unreachable on day one and liquidate the - lessee instantly. 50 000 bps is generous — enough for truly + lessee instantly. 50 000 basis points is generous — enough for truly speculative leases — while still blocking the pathological 10 000× trap. @@ -985,12 +991,12 @@ A production protocol would want more: units per second of `lease_fee_per_second`. Choose a small `lease_fee_per_second` and short-lived leases can settle 0 lease fees if no-one calls `pay_lease_fee` for a very short period. Not a - security issue — the accrual ts only moves forward when the lease + security issue — the accrual timestamp only moves forward when the lease fee is actually settled — but worth knowing. - **Griefing on `init_if_needed`.** `take_lease`, `pay_lease_fee`, `liquidate`, `return_lease` and `close_expired` all do - `init_if_needed` on one or more ATAs. If the caller does not fund + `init_if_needed` on one or more associated token accounts. If the caller does not fund the rent-exempt reserve for those accounts, the transaction fails. This is the intended behaviour (the caller pays for the state they require) but can surprise a lessee on a tight SOL budget. @@ -998,7 +1004,7 @@ A production protocol would want more: - **No partial lease-fee refund on default.** When `close_expired` runs on an `Active` lease, the lessor takes the entire collateral regardless of how many lease fees had actually accrued by then. This is a deliberate - simplification — the `last_paid_ts` bookkeeping in Fix 5 is in + simplification — the `last_paid_timestamp` bookkeeping in Fix 5 is in place precisely so a future version can split the pot correctly. - **No pause / upgrade authority.** The program has no admin and no @@ -1041,7 +1047,7 @@ Expected output: ``` running 11 tests test close_expired_cancels_listed_lease ... ok -test close_expired_reclaims_collateral_after_end_ts ... ok +test close_expired_reclaims_collateral_after_end_timestamp ... ok test create_lease_locks_tokens_and_lists ... ok test create_lease_rejects_same_mint_for_leased_and_collateral ... ok test liquidate_rejects_healthy_position ... ok @@ -1059,14 +1065,14 @@ test top_up_collateral_increases_vault_balance ... ok |---|---| | `create_lease_locks_tokens_and_lists` | Lessor funds vault, `Lease` created, collateral vault empty | | `create_lease_rejects_same_mint_for_leased_and_collateral` | Guard against `leased_mint == collateral_mint` | -| `take_lease_posts_collateral_and_delivers_tokens` | Collateral deposit + leased-token payout in one ix | +| `take_lease_posts_collateral_and_delivers_tokens` | Collateral deposit + leased-token payout in one instruction | | `pay_lease_fee_streams_collateral_by_elapsed_time` | Lease fee math: `elapsed * lease_fee_per_second`, lease fee transferred to lessor | | `top_up_collateral_increases_vault_balance` | Collateral balance after `top_up` equals deposit + top-up | | `return_lease_refunds_unused_collateral` | Happy path round-trip — leased tokens returned, residual collateral refunded, accounts closed | | `liquidate_seizes_collateral_on_price_drop` | Price-induced underwater position → lease fee + bounty + lessor share paid, accounts closed | | `liquidate_rejects_healthy_position` | Program refuses to liquidate a position that passes the margin check | | `liquidate_rejects_mismatched_price_feed` | Program refuses a `PriceUpdateV2` whose `feed_id` ≠ `lease.feed_id` | -| `close_expired_reclaims_collateral_after_end_ts` | Default path — lessor seizes the collateral | +| `close_expired_reclaims_collateral_after_end_timestamp` | Default path — lessor seizes the collateral | | `close_expired_cancels_listed_lease` | Lessor-initiated cancel of an unrented lease | ### Note on CI @@ -1089,7 +1095,7 @@ Anchor that compiles to bare Solana program binaries without pulling in size, or simply want fewer layers between your code and the runtime. The port implements the same seven instruction handlers, the same -`Lease` state account, the same PDA seed conventions, and produces the +`Lease` state account, the same program-derived address seed conventions, and produces the same on-chain behaviour for every happy-path and adversarial test in this README. @@ -1135,13 +1141,13 @@ The Quasar example in this repo's CI workflow means the setup code is more explicit. - **No cross-program-invocation into an associated-token-account - program for ATA creation.** The Anchor version uses `init_if_needed` + program for associated token account creation.** The Anchor version uses `init_if_needed` + `associated_token::...` to let callers pass in a lessor/lessee wallet and get the token account created on demand. The Quasar port accepts pre-created token accounts for the user side of every flow, - since doing `init_if_needed` correctly for ATAs in Quasar requires - wiring in the ATA program manually and adds noise that distracts - from the lease mechanics. Production code would want the ATA + since doing `init_if_needed` correctly for associated token accounts in Quasar requires + wiring in the associated token account program manually and adds noise that distracts + from the lease mechanics. Production code would want the associated token account convenience back. - **Classic Token only, not Token-2022.** The Anchor version declares @@ -1158,16 +1164,16 @@ The Quasar example in this repo's CI workflow Quasar ones after adjusting for the one-byte discriminator. - **One lease per lessor at a time.** The Anchor version keys its - `Lease` PDA on `[LEASE_SEED, lessor, lease_id]` so one lessor can + `Lease` program-derived address on `[LEASE_SEED, lessor, lease_id]` so one lessor can run many leases in parallel. Quasar's `seeds = [...]` macro embeds raw references into generated code and does not (yet) have a borrow-safe way to splice instruction args like `lease_id.to_le_bytes()` into the seed list, so the Quasar port - keys its PDA on `[LEASE_SEED, lessor]` alone — one active lease per + keys its program-derived address on `[LEASE_SEED, lessor]` alone — one active lease per lessor. The `lease_id` is still stored on the `Lease` account for book-keeping and is a caller-supplied u64 in `create_lease`; the off-chain client just has to ensure the previous lease from the same - lessor is `Closed` or `Liquidated` (i.e. its PDA account is gone) + lessor is `Closed` or `Liquidated` (i.e. its program-derived address account is gone) before creating a new one. Swapping in a multi-lease seed is a mechanical change once Quasar grows support for dynamic-byte seeds. @@ -1185,7 +1191,7 @@ Directions a real protocol would consider, grouped by effort: ### Easy - **Add a `lease_view` read-only helper.** An off-chain indexer-style - struct that returns `{ collateral_value, debt_value, ratio_bps, + struct that returns `{ collateral_value, debt_value, ratio_basis_points, is_underwater }` given the same inputs `is_underwater` uses. Useful for UIs that want to show "you are 15% away from liquidation". @@ -1200,7 +1206,7 @@ Directions a real protocol would consider, grouped by effort: - **Partial-refund default.** In `close_expired` on `Active`, instead of giving the lessor the entire collateral, split it: `lease_fee_due` to the lessor, the rest stays with the lessee up to some - `default_haircut_bps`. `last_paid_ts` is already bumped by + `default_haircut_basis_points`. `last_paid_timestamp` is already bumped by Fix 5, so the timestamp invariants are ready. - **Multiple outstanding leases per `(lessor, lessee)` pair with the @@ -1217,7 +1223,7 @@ Directions a real protocol would consider, grouped by effort: ### Harder -- **Keeper auction.** Replace the fixed `liquidation_bounty_bps` with a +- **Keeper auction.** Replace the fixed `liquidation_bounty_basis_points` with a Dutch auction that grows the bounty linearly over some window after the position first becomes underwater. Keeps liquidators honest on tight feeds and gives lessees a chance to `top_up_collateral` before @@ -1247,7 +1253,7 @@ defi/asset-leasing/anchor/ └── programs/asset-leasing/ ├── Cargo.toml ├── src/ - │ ├── constants.rs PDA seeds, bps limits, Pyth age cap + │ ├── constants.rs program-derived address seeds, basis points limits, Pyth age cap │ ├── errors.rs │ ├── lib.rs #[program] entry points │ ├── instructions/ diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml b/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml index 99faa0ce1..35e76bfad 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml +++ b/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml @@ -21,8 +21,8 @@ custom-panic = [] [dependencies] # `init-if-needed` is required because several instructions lazily create the -# counterparty's ATAs (keeper's collateral ATA on first liquidation, lessor's -# leased ATA on first return, etc.). Anchor forces an opt-in to make us +# counterparty's associated token accounts (keeper's collateral associated token account on first liquidation, lessor's +# leased associated token account on first return, etc.). Anchor forces an opt-in to make us # re-affirm that we verify ownership on every touch — which we do via the # `associated_token::authority = ...` constraints. anchor-lang = { version = "1.0.0", features = ["init-if-needed"] } diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs index 406c15cac..fc86ae1c0 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs @@ -1,26 +1,26 @@ -/// PDA seed for the `Lease` account. Combined with the lessor pubkey and a +/// program-derived address seed for the `Lease` account. Combined with the lessor pubkey and a /// u64 `lease_id` so one lessor can run many leases in parallel. pub const LEASE_SEED: &[u8] = b"lease"; -/// PDA seed for the token vault that holds the leased tokens while the lease +/// program-derived address seed for the token vault that holds the leased tokens while the lease /// is `Listed` and that accepts returned tokens on settlement. pub const LEASED_VAULT_SEED: &[u8] = b"leased_vault"; -/// PDA seed for the token vault that escrows the lessee's collateral for the +/// program-derived address seed for the token vault that escrows the lessee's collateral for the /// life of the lease. pub const COLLATERAL_VAULT_SEED: &[u8] = b"collateral_vault"; -/// Denominator for basis-point (bps) ratios used for the maintenance margin -/// and the liquidation bounty. 10_000 bps = 100%. -pub const BPS_DENOMINATOR: u64 = 10_000; +/// Denominator for basis-point (basis points) ratios used for the maintenance margin +/// and the liquidation bounty. 10_000 basis points = 100%. +pub const BASIS_POINTS_DENOMINATOR: u64 = 10_000; -/// Maximum allowed maintenance margin: 50_000 bps = 500%. Prevents the lessor +/// Maximum allowed maintenance margin: 50_000 basis points = 500%. Prevents the lessor /// setting an impossible margin that would let them liquidate on day one. -pub const MAX_MAINTENANCE_MARGIN_BPS: u16 = 50_000; +pub const MAX_MAINTENANCE_MARGIN_BASIS_POINTS: u16 = 50_000; -/// Maximum liquidation bounty the keeper can claim: 2_000 bps = 20%. Keeps +/// Maximum liquidation bounty the keeper can claim: 2_000 basis points = 20%. Keeps /// most of the collateral flowing to the lessor on default. -pub const MAX_LIQUIDATION_BOUNTY_BPS: u16 = 2_000; +pub const MAX_LIQUIDATION_BOUNTY_BASIS_POINTS: u16 = 2_000; /// A Pyth price update is considered stale if its `publish_time` is older /// than this many seconds versus the current on-chain clock. 60 s matches the diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs index f01dc6fec..08582c8c9 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs @@ -8,7 +8,7 @@ use crate::{ constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, errors::AssetLeasingError, instructions::{ - pay_lease_fee::update_last_paid_ts, + pay_lease_fee::update_last_paid_timestamp, shared::{close_vault, transfer_tokens_from_vault}, }, state::{Lease, LeaseStatus}, @@ -18,7 +18,7 @@ use crate::{ /// /// - The lease sat in `Listed` and the lessor wants to cancel it, recovering /// the leased tokens they pre-funded. Allowed any time. -/// - The lease was `Active` but the lessee ghosted past `end_ts`. The lessor +/// - The lease was `Active` but the lessee ghosted past `end_timestamp`. The lessor /// takes the collateral as compensation and closes the books. #[derive(Accounts)] pub struct CloseExpired<'info> { @@ -93,7 +93,7 @@ pub fn handle_close_expired(context: Context) -> Result<()> { // no start/end so the check is skipped. if status == LeaseStatus::Active { require!( - now >= context.accounts.lease.end_ts, + now >= context.accounts.lease.end_timestamp, AssetLeasingError::LeaseNotExpired ); } @@ -159,18 +159,18 @@ pub fn handle_close_expired(context: Context) -> Result<()> { // // We are not forwarding any accrued lease fees to the lessor here — on default // the lessor takes the whole collateral vault as compensation — but we - // still bump \`last_paid_ts\` so the invariant - // \`last_paid_ts <= now.min(end_ts)\` stays intact. That matters for + // still bump \`last_paid_timestamp\` so the invariant + // \`last_paid_timestamp <= now.min(end_timestamp)\` stays intact. That matters for // any future version of the program that wants to split the collateral // differently (pro-rata lease fees, partial refund on default, haircut to the // lessee for unused time): such a version can read - // \`last_paid_ts\` and trust that everything up to \`now\` is already + // \`last_paid_timestamp\` and trust that everything up to \`now\` is already // settled, rather than having to reason about whether this branch ever // bumped the timestamp. // // No-op on the \`Listed\` branch because Lease fees never started accruing. if status == LeaseStatus::Active { - update_last_paid_ts(&mut context.accounts.lease, now); + update_last_paid_timestamp(&mut context.accounts.lease, now); } context.accounts.lease.collateral_amount = 0; context.accounts.lease.status = LeaseStatus::Closed; diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs index a58f24f32..e88621931 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs @@ -3,8 +3,8 @@ use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; use crate::{ constants::{ - COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED, MAX_LIQUIDATION_BOUNTY_BPS, - MAX_MAINTENANCE_MARGIN_BPS, + COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED, MAX_LIQUIDATION_BOUNTY_BASIS_POINTS, + MAX_MAINTENANCE_MARGIN_BASIS_POINTS, }, errors::AssetLeasingError, instructions::shared::transfer_tokens_from_user, @@ -40,8 +40,8 @@ pub struct CreateLease<'info> { )] pub lease: Account<'info, Lease>, - /// PDA-owned vault holding the leased tokens while `Listed`. Authority is - /// the vault PDA itself so the lease account does not need to sign for + /// program-derived address-owned vault holding the leased tokens while `Listed`. Authority is + /// the vault program-derived address itself so the lease account does not need to sign for /// returns / liquidation; any handler just signs with the vault seeds. #[account( init, @@ -77,8 +77,8 @@ pub fn handle_create_lease( required_collateral_amount: u64, lease_fee_per_second: u64, duration_seconds: i64, - maintenance_margin_bps: u16, - liquidation_bounty_bps: u16, + maintenance_margin_basis_points: u16, + liquidation_bounty_basis_points: u16, feed_id: [u8; 32], ) -> Result<()> { // Reject leased_mint == collateral_mint. Allowing both to be the same @@ -99,11 +99,11 @@ pub fn handle_create_lease( require!(lease_fee_per_second > 0, AssetLeasingError::InvalidLeaseFeePerSecond); require!(duration_seconds > 0, AssetLeasingError::InvalidDuration); require!( - maintenance_margin_bps > 0 && maintenance_margin_bps <= MAX_MAINTENANCE_MARGIN_BPS, + maintenance_margin_basis_points > 0 && maintenance_margin_basis_points <= MAX_MAINTENANCE_MARGIN_BASIS_POINTS, AssetLeasingError::InvalidMaintenanceMargin ); require!( - liquidation_bounty_bps <= MAX_LIQUIDATION_BOUNTY_BPS, + liquidation_bounty_basis_points <= MAX_LIQUIDATION_BOUNTY_BASIS_POINTS, AssetLeasingError::InvalidLiquidationBounty ); @@ -133,13 +133,13 @@ pub fn handle_create_lease( required_collateral_amount, lease_fee_per_second, duration_seconds, - // start_ts / end_ts / last_paid_ts are set when the lease + // start_timestamp / end_timestamp / last_paid_timestamp are set when the lease // activates in `take_lease`. - start_ts: 0, - end_ts: 0, - last_paid_ts: 0, - maintenance_margin_bps, - liquidation_bounty_bps, + start_timestamp: 0, + end_timestamp: 0, + last_paid_timestamp: 0, + maintenance_margin_basis_points, + liquidation_bounty_basis_points, feed_id, status: LeaseStatus::Listed, bump: context.bumps.lease, diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs index a57dd07a9..25f9d0ff3 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs @@ -6,7 +6,7 @@ use anchor_spl::{ use crate::{ constants::{ - BPS_DENOMINATOR, COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED, + BASIS_POINTS_DENOMINATOR, COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED, PYTH_MAX_AGE_SECONDS, }, errors::AssetLeasingError, @@ -34,7 +34,7 @@ pub struct Liquidate<'info> { #[account(mut)] pub keeper: Signer<'info>, - /// CHECK: PDA seed + lease-fee / collateral destination. + /// CHECK: program-derived address seed + lease-fee / collateral destination. #[account(mut)] pub lessor: UncheckedAccount<'info>, @@ -174,7 +174,7 @@ pub fn handle_liquidate(context: Context) -> Result<()> { AssetLeasingError::PositionHealthy ); - // Settle accrued lease fees first (up to end_ts) so the lessor is paid for the + // Settle accrued lease fees first (up to end_timestamp) so the lessor is paid for the // time the lessee actually used. Only then slice off bounty + remainder. let lease_fee_due = compute_lease_fee_due(&context.accounts.lease, now)?; let lease_fee_payable = lease_fee_due.min(context.accounts.lease.collateral_amount); @@ -215,9 +215,9 @@ pub fn handle_liquidate(context: Context) -> Result<()> { // Bounty is a percentage of the collateral *after* lease fees — guarantees we // never try to pay out more than what actually sits in the vault. let bounty = (remaining as u128) - .checked_mul(context.accounts.lease.liquidation_bounty_bps as u128) + .checked_mul(context.accounts.lease.liquidation_bounty_basis_points as u128) .ok_or(AssetLeasingError::MathOverflow)? - .checked_div(BPS_DENOMINATOR as u128) + .checked_div(BASIS_POINTS_DENOMINATOR as u128) .ok_or(AssetLeasingError::MathOverflow)? as u64; if bounty > 0 { @@ -264,7 +264,7 @@ pub fn handle_liquidate(context: Context) -> Result<()> { )?; context.accounts.lease.collateral_amount = 0; - context.accounts.lease.last_paid_ts = now.min(context.accounts.lease.end_ts); + context.accounts.lease.last_paid_timestamp = now.min(context.accounts.lease.end_timestamp); context.accounts.lease.status = LeaseStatus::Liquidated; Ok(()) @@ -285,8 +285,8 @@ pub fn is_underwater(lease: &Lease, price: &DecodedPriceUpdate, now: i64) -> Res let leased_amount = lease.leased_amount as u128; let collateral_amount = lease.collateral_amount as u128; - let margin_bps = lease.maintenance_margin_bps as u128; - let denom = BPS_DENOMINATOR as u128; + let margin_basis_points = lease.maintenance_margin_basis_points as u128; + let denom = BASIS_POINTS_DENOMINATOR as u128; let (collateral_scaled, debt_scaled) = if price.exponent >= 0 { let scale = ten_pow(price.exponent as u32)?; @@ -310,7 +310,7 @@ pub fn is_underwater(lease: &Lease, price: &DecodedPriceUpdate, now: i64) -> Res .checked_mul(denom) .ok_or(AssetLeasingError::MathOverflow)?; let rhs = debt_scaled - .checked_mul(margin_bps) + .checked_mul(margin_basis_points) .ok_or(AssetLeasingError::MathOverflow)?; Ok(lhs < rhs) diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_lease_fee.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_lease_fee.rs index ef3fb8db6..6e18b1169 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_lease_fee.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_lease_fee.rs @@ -19,7 +19,7 @@ pub struct PayLeaseFee<'info> { #[account(mut)] pub payer: Signer<'info>, - /// CHECK: Referenced only for PDA derivation + has_one check on `lease`. + /// CHECK: Referenced only for program-derived address derivation + has_one check on `lease`. pub lessor: UncheckedAccount<'info>, #[account( @@ -44,7 +44,7 @@ pub struct PayLeaseFee<'info> { )] pub collateral_vault: Box>, - /// Lessor's collateral-mint ATA, created on demand so the lessor does not + /// Lessor's collateral-mint associated token account, created on demand so the lessor does not /// need to pre-fund it with the lease fee. #[account( init_if_needed, @@ -65,9 +65,9 @@ pub fn handle_pay_lease_fee(context: Context) -> Result<()> { let lease_fee_amount = compute_lease_fee_due(&context.accounts.lease, now)?; - // No time has passed (or already capped at end_ts). Nothing to do. + // No time has passed (or already capped at end_timestamp). Nothing to do. if lease_fee_amount == 0 { - update_last_paid_ts(&mut context.accounts.lease, now); + update_last_paid_timestamp(&mut context.accounts.lease, now); return Ok(()); } @@ -104,28 +104,28 @@ pub fn handle_pay_lease_fee(context: Context) -> Result<()> { .ok_or(AssetLeasingError::MathOverflow)?; } - update_last_paid_ts(&mut context.accounts.lease, now); + update_last_paid_timestamp(&mut context.accounts.lease, now); Ok(()) } -/// Lease fee accrues linearly: `(min(now, end_ts) - last_paid_ts) * rate`. +/// Lease fee accrues linearly: `(min(now, end_timestamp) - last_paid_timestamp) * rate`. /// Extracted so it can be re-used by `return_lease` and `liquidate` for a /// final settlement before closing the lease. pub fn compute_lease_fee_due(lease: &Lease, now: i64) -> Result { - let cutoff = now.min(lease.end_ts); - if cutoff <= lease.last_paid_ts { + let cutoff = now.min(lease.end_timestamp); + if cutoff <= lease.last_paid_timestamp { return Ok(0); } - let elapsed = (cutoff - lease.last_paid_ts) as u64; + let elapsed = (cutoff - lease.last_paid_timestamp) as u64; elapsed .checked_mul(lease.lease_fee_per_second) .ok_or(AssetLeasingError::MathOverflow.into()) } -/// Advance `last_paid_ts` but never past the lease end — after end_ts +/// Advance `last_paid_timestamp` but never past the lease end — after end_timestamp /// the lease is settled and extra Lease fees do not accrue. -pub fn update_last_paid_ts(lease: &mut Lease, now: i64) { - lease.last_paid_ts = now.min(lease.end_ts); +pub fn update_last_paid_timestamp(lease: &mut Lease, now: i64) { + lease.last_paid_timestamp = now.min(lease.end_timestamp); } impl<'info> PayLeaseFee<'info> { diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs index d0f794e8c..db2cd80aa 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs @@ -8,7 +8,7 @@ use crate::{ constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, errors::AssetLeasingError, instructions::{ - pay_lease_fee::{compute_lease_fee_due, update_last_paid_ts}, + pay_lease_fee::{compute_lease_fee_due, update_last_paid_timestamp}, shared::{close_vault, transfer_tokens_from_user, transfer_tokens_from_vault}, }, state::{Lease, LeaseStatus}, @@ -77,7 +77,7 @@ pub struct ReturnLease<'info> { )] pub lessee_collateral_account: Box>, - /// Lessor's leased-mint ATA, created on demand. They may have sent the + /// Lessor's leased-mint associated token account, created on demand. They may have sent the /// original tokens from a different account. #[account( init_if_needed, @@ -194,7 +194,7 @@ pub fn handle_return_lease(context: Context) -> Result<()> { &[collateral_vault_seeds], )?; - update_last_paid_ts(&mut context.accounts.lease, now); + update_last_paid_timestamp(&mut context.accounts.lease, now); context.accounts.lease.collateral_amount = 0; context.accounts.lease.status = LeaseStatus::Closed; diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs index e8c3893eb..a1f94c28c 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs @@ -27,7 +27,7 @@ pub fn transfer_tokens_from_user<'info>( ) } -/// Transfer tokens out of a PDA-owned vault using the supplied signer seeds. +/// Transfer tokens out of a program-derived address-owned vault using the supplied signer seeds. /// Used by the program when moving tokens held under its authority. pub fn transfer_tokens_from_vault<'info>( from: &InterfaceAccount<'info, TokenAccount>, @@ -51,10 +51,10 @@ pub fn transfer_tokens_from_vault<'info>( ) } -/// Close a PDA-owned token vault and forward its rent-exempt lamports to +/// Close a program-derived address-owned token vault and forward its rent-exempt lamports to /// `destination`. The vault is its own token-account authority, so the caller /// just passes the same vault `AccountInfo` as both the account and the -/// authority, with the vault's signer seeds for the CPI. +/// authority, with the vault's signer seeds for the cross-program invocation. /// /// `destination` is an `AccountInfo` so callers can pass whichever wrapper /// they hold (`Signer`, `UncheckedAccount`, etc.) via `.to_account_info()`. diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs index 387ad9283..8d9c4e597 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs @@ -16,7 +16,7 @@ pub struct TakeLease<'info> { #[account(mut)] pub lessee: Signer<'info>, - /// CHECK: Only used as a reference for the PDA seeds; no data accessed. + /// CHECK: Only used as a reference for the program-derived address seeds; no data accessed. pub lessor: UncheckedAccount<'info>, #[account( @@ -63,7 +63,7 @@ pub struct TakeLease<'info> { )] pub lessee_collateral_account: Box>, - /// Lessee's ATA for the leased mint. Created on-demand if missing so the + /// Lessee's associated token account for the leased mint. Created on-demand if missing so the /// UI only has to hand over a lessee keypair plus the two mints. #[account( init_if_needed, @@ -98,7 +98,7 @@ pub fn handle_take_lease(context: Context) -> Result<()> { &context.accounts.token_program, )?; - // Pay out leased tokens from the vault PDA. + // Pay out leased tokens from the vault program-derived address. let lease_key = context.accounts.lease.key(); let leased_vault_bump = context.accounts.lease.leased_vault_bump; let leased_vault_seeds: &[&[u8]] = &[ @@ -118,16 +118,16 @@ pub fn handle_take_lease(context: Context) -> Result<()> { &signer_seeds, )?; - let end_ts = now + let end_timestamp = now .checked_add(duration_seconds) .ok_or(AssetLeasingError::MathOverflow)?; let lease = &mut context.accounts.lease; lease.lessee = context.accounts.lessee.key(); lease.collateral_amount = required_collateral_amount; - lease.start_ts = now; - lease.end_ts = end_ts; - lease.last_paid_ts = now; + lease.start_timestamp = now; + lease.end_timestamp = end_timestamp; + lease.last_paid_timestamp = now; lease.status = LeaseStatus::Active; Ok(()) diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/top_up_collateral.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/top_up_collateral.rs index 9a8984c52..e6a90b5a9 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/top_up_collateral.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/top_up_collateral.rs @@ -13,7 +13,7 @@ pub struct TopUpCollateral<'info> { #[account(mut)] pub lessee: Signer<'info>, - /// CHECK: PDA seed reference; no reads. + /// CHECK: program-derived address seed reference; no reads. pub lessor: UncheckedAccount<'info>, #[account( diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs index b58c29a83..dad279635 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs @@ -25,8 +25,8 @@ pub mod asset_leasing { required_collateral_amount: u64, lease_fee_per_second: u64, duration_seconds: i64, - maintenance_margin_bps: u16, - liquidation_bounty_bps: u16, + maintenance_margin_basis_points: u16, + liquidation_bounty_basis_points: u16, feed_id: [u8; 32], ) -> Result<()> { instructions::create_lease::handle_create_lease( @@ -36,8 +36,8 @@ pub mod asset_leasing { required_collateral_amount, lease_fee_per_second, duration_seconds, - maintenance_margin_bps, - liquidation_bounty_bps, + maintenance_margin_basis_points, + liquidation_bounty_basis_points, feed_id, ) } @@ -48,7 +48,7 @@ pub mod asset_leasing { instructions::take_lease::handle_take_lease(context) } - /// Stream the lease fee from the collateral vault to the lessor, up to `end_ts`. + /// Stream the lease fee from the collateral vault to the lessor, up to `end_timestamp`. /// Anyone may call this to keep the lease current. pub fn pay_lease_fee(context: Context) -> Result<()> { instructions::pay_lease_fee::handle_pay_lease_fee(context) @@ -59,7 +59,7 @@ pub mod asset_leasing { instructions::top_up_collateral::handle_top_up_collateral(context, amount) } - /// Lessee returns the leased tokens (at or before `end_ts`). Accrued lease fees + /// Lessee returns the leased tokens (at or before `end_timestamp`). Accrued lease fees /// is settled and the remaining collateral is refunded. pub fn return_lease(context: Context) -> Result<()> { instructions::return_lease::handle_return_lease(context) @@ -71,7 +71,7 @@ pub mod asset_leasing { instructions::liquidate::handle_liquidate(context) } - /// After `end_ts`, if the lessee never returned the tokens, the lessor + /// After `end_timestamp`, if the lessee never returned the tokens, the lessor /// reclaims the collateral as compensation and closes the lease. Also /// used by the lessor to cancel an unrented (`Listed`) lease. pub fn close_expired(context: Context) -> Result<()> { diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs index 2a5406398..6276165ff 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs @@ -5,7 +5,7 @@ use anchor_lang::prelude::*; /// Active --return_lease--> Closed /// Active --liquidate--> Liquidated /// Listed --close_expired--> Closed (lessor cancels unrented lease) -/// Active --close_expired--> Closed (after end_ts, defaulted lessee) +/// Active --close_expired--> Closed (after end_timestamp, defaulted lessee) #[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, Debug, InitSpace)] pub enum LeaseStatus { Listed, @@ -18,7 +18,7 @@ pub enum LeaseStatus { #[derive(InitSpace)] pub struct Lease { /// Caller-supplied id so one lessor can run many leases in parallel. The - /// PDA is seeded by (LEASE_SEED, lessor, lease_id). + /// program-derived address is seeded by (LEASE_SEED, lessor, lease_id). pub lease_id: u64, /// Account that listed the lease and receives the lease fee. Always set. pub lessor: Pubkey, @@ -42,21 +42,21 @@ pub struct Lease { /// from the collateral vault to the lessor on each `pay_lease_fee`. pub lease_fee_per_second: u64, /// Length of the lease, in seconds. Set at creation, used to compute - /// `end_ts` when the lease activates. + /// `end_timestamp` when the lease activates. pub duration_seconds: i64, /// Unix timestamp when the lease becomes active (set on `take_lease`). - pub start_ts: i64, + pub start_timestamp: i64, /// Unix timestamp after which the lease expires. 0 while `Listed`. - pub end_ts: i64, - /// Last time the lease fee was settled. Lease fee accrues from here to `now.min(end_ts)`. - pub last_paid_ts: i64, + pub end_timestamp: i64, + /// Last time the lease fee was settled. Lease fee accrues from here to `now.min(end_timestamp)`. + pub last_paid_timestamp: i64, /// Required collateral value as a percentage of the leased value, - /// expressed in basis points. 12_000 bps = 120%. - pub maintenance_margin_bps: u16, + /// expressed in basis points. 12_000 basis points = 120%. + pub maintenance_margin_basis_points: u16, /// Share of the seized collateral paid to the keeper that liquidates the /// lease, expressed in basis points of `collateral_amount`. - pub liquidation_bounty_bps: u16, + pub liquidation_bounty_basis_points: u16, /// Pyth `PriceUpdateV2.feed_id` that this lease is pinned to. The /// liquidation handler refuses price updates whose on-account `feed_id` @@ -68,7 +68,7 @@ pub struct Lease { /// Current lifecycle state. pub status: LeaseStatus, - /// Bump seeds — stored so CPIs can sign without re-deriving. + /// Bump seeds — stored so cross-program invocations can sign without re-deriving. pub bump: u8, pub leased_vault_bump: u8, pub collateral_vault_bump: u8, diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs b/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs index a1bad0f52..49995a2c1 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs @@ -41,21 +41,21 @@ fn token_program_id() -> Pubkey { .unwrap() } -fn ata_program_id() -> Pubkey { +fn associated_token_account_program_id() -> Pubkey { "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" .parse() .unwrap() } -fn derive_ata(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { - let (ata, _bump) = Pubkey::find_program_address( +fn derive_associated_token_account(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { + let (associated_token_account, _bump) = Pubkey::find_program_address( &[wallet.as_ref(), token_program_id().as_ref(), mint.as_ref()], - &ata_program_id(), + &associated_token_account_program_id(), ); - ata + associated_token_account } -fn lease_pdas(program_id: &Pubkey, lessor: &Pubkey, lease_id: u64) -> (Pubkey, Pubkey, Pubkey) { +fn lease_program_derived_addresses(program_id: &Pubkey, lessor: &Pubkey, lease_id: u64) -> (Pubkey, Pubkey, Pubkey) { let (lease, _) = Pubkey::find_program_address( &[LEASE_SEED, lessor.as_ref(), &lease_id.to_le_bytes()], program_id, @@ -70,7 +70,7 @@ fn lease_pdas(program_id: &Pubkey, lessor: &Pubkey, lease_id: u64) -> (Pubkey, P struct Scenario { svm: LiteSVM, program_id: Pubkey, - // `payer` funds the mint authority + ATA creations during setup but is + // `payer` funds the mint authority + associated token account creations during setup but is // not used directly by the tests afterwards. #[allow(dead_code)] payer: Keypair, @@ -79,8 +79,8 @@ struct Scenario { keeper: Keypair, leased_mint: Pubkey, collateral_mint: Pubkey, - lessor_leased_ata: Pubkey, - lessee_collateral_ata: Pubkey, + lessor_leased_associated_token_account: Pubkey, + lessee_collateral_associated_token_account: Pubkey, } fn full_setup() -> Scenario { @@ -99,31 +99,31 @@ fn full_setup() -> Scenario { let leased_mint = create_token_mint(&mut svm, &payer, decimals, None).unwrap(); let collateral_mint = create_token_mint(&mut svm, &payer, decimals, None).unwrap(); - let lessor_leased_ata = + let lessor_leased_associated_token_account = create_associated_token_account(&mut svm, &lessor.pubkey(), &leased_mint, &payer).unwrap(); mint_tokens_to_token_account( &mut svm, &leased_mint, - &lessor_leased_ata, + &lessor_leased_associated_token_account, 1_000_000_000, &payer, ) .unwrap(); - let lessee_collateral_ata = + let lessee_collateral_associated_token_account = create_associated_token_account(&mut svm, &lessee.pubkey(), &collateral_mint, &payer) .unwrap(); mint_tokens_to_token_account( &mut svm, &collateral_mint, - &lessee_collateral_ata, + &lessee_collateral_associated_token_account, 1_000_000_000, &payer, ) .unwrap(); // Anchor macros init the Lease + vault accounts — LiteSVM's default clock - // is epoch 0 which makes the first `take_lease` have start_ts=0 and look + // is epoch 0 which makes the first `take_lease` have start_timestamp=0 and look // identical to a Listed lease. Advance once so lease fee math has signal. advance_clock_to(&mut svm, 1_700_000_000); @@ -136,8 +136,8 @@ fn full_setup() -> Scenario { keeper, leased_mint, collateral_mint, - lessor_leased_ata, - lessee_collateral_ata, + lessor_leased_associated_token_account, + lessee_collateral_associated_token_account, } } @@ -162,37 +162,37 @@ fn current_clock(svm: &LiteSVM) -> i64 { // --------------------------------------------------------------------------- #[allow(clippy::too_many_arguments)] -fn build_create_lease_ix( - sc: &Scenario, +fn build_create_lease_instruction( + scenario: &Scenario, lease_id: u64, leased_amount: u64, required_collateral_amount: u64, lease_fee_per_second: u64, duration_seconds: i64, - maintenance_margin_bps: u16, - liquidation_bounty_bps: u16, + maintenance_margin_basis_points: u16, + liquidation_bounty_basis_points: u16, feed_id: [u8; 32], ) -> Instruction { let (lease, leased_vault, collateral_vault) = - lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); Instruction::new_with_bytes( - sc.program_id, + scenario.program_id, &asset_leasing::instruction::CreateLease { lease_id, leased_amount, required_collateral_amount, lease_fee_per_second, duration_seconds, - maintenance_margin_bps, - liquidation_bounty_bps, + maintenance_margin_basis_points, + liquidation_bounty_basis_points, feed_id, } .data(), asset_leasing::accounts::CreateLease { - lessor: sc.lessor.pubkey(), - leased_mint: sc.leased_mint, - collateral_mint: sc.collateral_mint, - lessor_leased_account: sc.lessor_leased_ata, + lessor: scenario.lessor.pubkey(), + leased_mint: scenario.leased_mint, + collateral_mint: scenario.collateral_mint, + lessor_leased_account: scenario.lessor_leased_associated_token_account, lease, leased_vault, collateral_vault, @@ -203,145 +203,145 @@ fn build_create_lease_ix( ) } -fn build_take_lease_ix(sc: &Scenario, lease_id: u64) -> Instruction { +fn build_take_lease_instruction(scenario: &Scenario, lease_id: u64) -> Instruction { let (lease, leased_vault, collateral_vault) = - lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); - let lessee_leased_ata = derive_ata(&sc.lessee.pubkey(), &sc.leased_mint); + lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + let lessee_leased_associated_token_account = derive_associated_token_account(&scenario.lessee.pubkey(), &scenario.leased_mint); Instruction::new_with_bytes( - sc.program_id, + scenario.program_id, &asset_leasing::instruction::TakeLease {}.data(), asset_leasing::accounts::TakeLease { - lessee: sc.lessee.pubkey(), - lessor: sc.lessor.pubkey(), + lessee: scenario.lessee.pubkey(), + lessor: scenario.lessor.pubkey(), lease, - leased_mint: sc.leased_mint, - collateral_mint: sc.collateral_mint, + leased_mint: scenario.leased_mint, + collateral_mint: scenario.collateral_mint, leased_vault, collateral_vault, - lessee_collateral_account: sc.lessee_collateral_ata, - lessee_leased_account: lessee_leased_ata, + lessee_collateral_account: scenario.lessee_collateral_associated_token_account, + lessee_leased_account: lessee_leased_associated_token_account, token_program: token_program_id(), - associated_token_program: ata_program_id(), + associated_token_program: associated_token_account_program_id(), system_program: system_program::id(), } .to_account_metas(None), ) } -fn build_pay_lease_fee_ix(sc: &Scenario, lease_id: u64) -> Instruction { +fn build_pay_lease_fee_instruction(scenario: &Scenario, lease_id: u64) -> Instruction { let (lease, _leased_vault, collateral_vault) = - lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); - let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); + lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + let lessor_collateral_associated_token_account = derive_associated_token_account(&scenario.lessor.pubkey(), &scenario.collateral_mint); Instruction::new_with_bytes( - sc.program_id, + scenario.program_id, &asset_leasing::instruction::PayLeaseFee {}.data(), asset_leasing::accounts::PayLeaseFee { - payer: sc.lessee.pubkey(), - lessor: sc.lessor.pubkey(), + payer: scenario.lessee.pubkey(), + lessor: scenario.lessor.pubkey(), lease, - collateral_mint: sc.collateral_mint, + collateral_mint: scenario.collateral_mint, collateral_vault, - lessor_collateral_account: lessor_collateral_ata, + lessor_collateral_account: lessor_collateral_associated_token_account, token_program: token_program_id(), - associated_token_program: ata_program_id(), + associated_token_program: associated_token_account_program_id(), system_program: system_program::id(), } .to_account_metas(None), ) } -fn build_top_up_ix(sc: &Scenario, lease_id: u64, amount: u64) -> Instruction { +fn build_top_up_instruction(scenario: &Scenario, lease_id: u64, amount: u64) -> Instruction { let (lease, _leased_vault, collateral_vault) = - lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); Instruction::new_with_bytes( - sc.program_id, + scenario.program_id, &asset_leasing::instruction::TopUpCollateral { amount }.data(), asset_leasing::accounts::TopUpCollateral { - lessee: sc.lessee.pubkey(), - lessor: sc.lessor.pubkey(), + lessee: scenario.lessee.pubkey(), + lessor: scenario.lessor.pubkey(), lease, - collateral_mint: sc.collateral_mint, + collateral_mint: scenario.collateral_mint, collateral_vault, - lessee_collateral_account: sc.lessee_collateral_ata, + lessee_collateral_account: scenario.lessee_collateral_associated_token_account, token_program: token_program_id(), } .to_account_metas(None), ) } -fn build_return_lease_ix(sc: &Scenario, lease_id: u64) -> Instruction { +fn build_return_lease_instruction(scenario: &Scenario, lease_id: u64) -> Instruction { let (lease, leased_vault, collateral_vault) = - lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); - let lessee_leased_ata = derive_ata(&sc.lessee.pubkey(), &sc.leased_mint); - let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); + lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + let lessee_leased_associated_token_account = derive_associated_token_account(&scenario.lessee.pubkey(), &scenario.leased_mint); + let lessor_collateral_associated_token_account = derive_associated_token_account(&scenario.lessor.pubkey(), &scenario.collateral_mint); Instruction::new_with_bytes( - sc.program_id, + scenario.program_id, &asset_leasing::instruction::ReturnLease {}.data(), asset_leasing::accounts::ReturnLease { - lessee: sc.lessee.pubkey(), - lessor: sc.lessor.pubkey(), + lessee: scenario.lessee.pubkey(), + lessor: scenario.lessor.pubkey(), lease, - leased_mint: sc.leased_mint, - collateral_mint: sc.collateral_mint, + leased_mint: scenario.leased_mint, + collateral_mint: scenario.collateral_mint, leased_vault, collateral_vault, - lessee_leased_account: lessee_leased_ata, - lessee_collateral_account: sc.lessee_collateral_ata, - lessor_leased_account: sc.lessor_leased_ata, - lessor_collateral_account: lessor_collateral_ata, + lessee_leased_account: lessee_leased_associated_token_account, + lessee_collateral_account: scenario.lessee_collateral_associated_token_account, + lessor_leased_account: scenario.lessor_leased_associated_token_account, + lessor_collateral_account: lessor_collateral_associated_token_account, token_program: token_program_id(), - associated_token_program: ata_program_id(), + associated_token_program: associated_token_account_program_id(), system_program: system_program::id(), } .to_account_metas(None), ) } -fn build_liquidate_ix(sc: &Scenario, lease_id: u64, price_update: Pubkey) -> Instruction { +fn build_liquidate_instruction(scenario: &Scenario, lease_id: u64, price_update: Pubkey) -> Instruction { let (lease, leased_vault, collateral_vault) = - lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); - let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); - let keeper_collateral_ata = derive_ata(&sc.keeper.pubkey(), &sc.collateral_mint); + lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + let lessor_collateral_associated_token_account = derive_associated_token_account(&scenario.lessor.pubkey(), &scenario.collateral_mint); + let keeper_collateral_associated_token_account = derive_associated_token_account(&scenario.keeper.pubkey(), &scenario.collateral_mint); Instruction::new_with_bytes( - sc.program_id, + scenario.program_id, &asset_leasing::instruction::Liquidate {}.data(), asset_leasing::accounts::Liquidate { - keeper: sc.keeper.pubkey(), - lessor: sc.lessor.pubkey(), + keeper: scenario.keeper.pubkey(), + lessor: scenario.lessor.pubkey(), lease, - leased_mint: sc.leased_mint, - collateral_mint: sc.collateral_mint, + leased_mint: scenario.leased_mint, + collateral_mint: scenario.collateral_mint, leased_vault, collateral_vault, - lessor_collateral_account: lessor_collateral_ata, - keeper_collateral_account: keeper_collateral_ata, + lessor_collateral_account: lessor_collateral_associated_token_account, + keeper_collateral_account: keeper_collateral_associated_token_account, price_update, token_program: token_program_id(), - associated_token_program: ata_program_id(), + associated_token_program: associated_token_account_program_id(), system_program: system_program::id(), } .to_account_metas(None), ) } -fn build_close_expired_ix(sc: &Scenario, lease_id: u64) -> Instruction { +fn build_close_expired_instruction(scenario: &Scenario, lease_id: u64) -> Instruction { let (lease, leased_vault, collateral_vault) = - lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); - let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); + lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + let lessor_collateral_associated_token_account = derive_associated_token_account(&scenario.lessor.pubkey(), &scenario.collateral_mint); Instruction::new_with_bytes( - sc.program_id, + scenario.program_id, &asset_leasing::instruction::CloseExpired {}.data(), asset_leasing::accounts::CloseExpired { - lessor: sc.lessor.pubkey(), + lessor: scenario.lessor.pubkey(), lease, - leased_mint: sc.leased_mint, - collateral_mint: sc.collateral_mint, + leased_mint: scenario.leased_mint, + collateral_mint: scenario.collateral_mint, leased_vault, collateral_vault, - lessor_leased_account: sc.lessor_leased_ata, - lessor_collateral_account: lessor_collateral_ata, + lessor_leased_account: scenario.lessor_leased_associated_token_account, + lessor_collateral_account: lessor_collateral_associated_token_account, token_program: token_program_id(), - associated_token_program: ata_program_id(), + associated_token_program: associated_token_account_program_id(), system_program: system_program::id(), } .to_account_metas(None), @@ -410,12 +410,12 @@ fn mock_price_update( // --------------------------------------------------------------------------- // Shared lease parameters so the sanity assertions line up across tests. -const LEASED_AMOUNT: u64 = 100_000_000; // 100 "leased" tokens (6 dp) +const LEASED_AMOUNT: u64 = 100_000_000; // 100 "leased" tokens (6 decimal places) const REQUIRED_COLLATERAL: u64 = 200_000_000; // 200 collateral tokens const LEASE_FEE_PER_SECOND: u64 = 10; // 10 base-units / sec const DURATION_SECONDS: i64 = 60 * 60 * 24; // 24h -const MAINTENANCE_MARGIN_BPS: u16 = 12_000; // 120% -const LIQUIDATION_BOUNTY_BPS: u16 = 500; // 5% +const MAINTENANCE_MARGIN_BASIS_POINTS: u16 = 12_000; // 120% +const LIQUIDATION_BOUNTY_BASIS_POINTS: u16 = 500; // 5% // Arbitrary 32-byte Pyth feed id the tests pin their leases to. The // mocked `PriceUpdateV2` accounts carry the same id so the feed-pinning // check in liquidate passes. `liquidate_rejects_mismatched_price_feed` @@ -424,229 +424,229 @@ const FEED_ID: [u8; 32] = [0xAB; 32]; #[test] fn create_lease_locks_tokens_and_lists() { - let mut sc = full_setup(); + let mut scenario = full_setup(); let lease_id = 1u64; - let ix = build_create_lease_ix( - &sc, + let instruction = build_create_lease_instruction( + &scenario, lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, LEASE_FEE_PER_SECOND, DURATION_SECONDS, - MAINTENANCE_MARGIN_BPS, - LIQUIDATION_BOUNTY_BPS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, FEED_ID, ); - send_transaction_from_instructions(&mut sc.svm, vec![ix], &[&sc.lessor], &sc.lessor.pubkey()) + send_transaction_from_instructions(&mut scenario.svm, vec![instruction], &[&scenario.lessor], &scenario.lessor.pubkey()) .unwrap(); - let (lease_pda, leased_vault, collateral_vault) = - lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + let (lease_program_derived_address, leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); // Leased tokens escrowed. assert_eq!( - get_token_account_balance(&sc.svm, &leased_vault).unwrap(), + get_token_account_balance(&scenario.svm, &leased_vault).unwrap(), LEASED_AMOUNT ); // Collateral vault exists but has no collateral yet. assert_eq!( - get_token_account_balance(&sc.svm, &collateral_vault).unwrap(), + get_token_account_balance(&scenario.svm, &collateral_vault).unwrap(), 0 ); // Lessor's leased balance dropped by the escrowed amount. assert_eq!( - get_token_account_balance(&sc.svm, &sc.lessor_leased_ata).unwrap(), + get_token_account_balance(&scenario.svm, &scenario.lessor_leased_associated_token_account).unwrap(), 1_000_000_000 - LEASED_AMOUNT ); // Lease account exists and is owned by the program. - let lease_account = sc.svm.get_account(&lease_pda).expect("lease PDA missing"); - assert_eq!(lease_account.owner, sc.program_id); + let lease_account = scenario.svm.get_account(&lease_program_derived_address).expect("lease program-derived address missing"); + assert_eq!(lease_account.owner, scenario.program_id); assert!(!lease_account.data.is_empty()); } #[test] fn take_lease_posts_collateral_and_delivers_tokens() { - let mut sc = full_setup(); + let mut scenario = full_setup(); let lease_id = 2u64; - let create_ix = build_create_lease_ix( - &sc, + let create_instruction = build_create_lease_instruction( + &scenario, lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, LEASE_FEE_PER_SECOND, DURATION_SECONDS, - MAINTENANCE_MARGIN_BPS, - LIQUIDATION_BOUNTY_BPS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, FEED_ID, ); send_transaction_from_instructions( - &mut sc.svm, - vec![create_ix], - &[&sc.lessor], - &sc.lessor.pubkey(), + &mut scenario.svm, + vec![create_instruction], + &[&scenario.lessor], + &scenario.lessor.pubkey(), ) .unwrap(); - let take_ix = build_take_lease_ix(&sc, lease_id); + let take_instruction = build_take_lease_instruction(&scenario, lease_id); send_transaction_from_instructions( - &mut sc.svm, - vec![take_ix], - &[&sc.lessee], - &sc.lessee.pubkey(), + &mut scenario.svm, + vec![take_instruction], + &[&scenario.lessee], + &scenario.lessee.pubkey(), ) .unwrap(); let (_, leased_vault, collateral_vault) = - lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); - let lessee_leased_ata = derive_ata(&sc.lessee.pubkey(), &sc.leased_mint); + lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + let lessee_leased_associated_token_account = derive_associated_token_account(&scenario.lessee.pubkey(), &scenario.leased_mint); // Leased vault drained into the lessee. - assert_eq!(get_token_account_balance(&sc.svm, &leased_vault).unwrap(), 0); + assert_eq!(get_token_account_balance(&scenario.svm, &leased_vault).unwrap(), 0); assert_eq!( - get_token_account_balance(&sc.svm, &lessee_leased_ata).unwrap(), + get_token_account_balance(&scenario.svm, &lessee_leased_associated_token_account).unwrap(), LEASED_AMOUNT ); // Collateral moved from the lessee into the collateral vault. assert_eq!( - get_token_account_balance(&sc.svm, &collateral_vault).unwrap(), + get_token_account_balance(&scenario.svm, &collateral_vault).unwrap(), REQUIRED_COLLATERAL ); assert_eq!( - get_token_account_balance(&sc.svm, &sc.lessee_collateral_ata).unwrap(), + get_token_account_balance(&scenario.svm, &scenario.lessee_collateral_associated_token_account).unwrap(), 1_000_000_000 - REQUIRED_COLLATERAL ); } #[test] fn pay_lease_fee_streams_collateral_by_elapsed_time() { - let mut sc = full_setup(); + let mut scenario = full_setup(); let lease_id = 3u64; - let create_ix = build_create_lease_ix( - &sc, + let create_instruction = build_create_lease_instruction( + &scenario, lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, LEASE_FEE_PER_SECOND, DURATION_SECONDS, - MAINTENANCE_MARGIN_BPS, - LIQUIDATION_BOUNTY_BPS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, FEED_ID, ); - let take_ix = build_take_lease_ix(&sc, lease_id); + let take_instruction = build_take_lease_instruction(&scenario, lease_id); send_transaction_from_instructions( - &mut sc.svm, - vec![create_ix, take_ix], - &[&sc.lessor, &sc.lessee], - &sc.lessor.pubkey(), + &mut scenario.svm, + vec![create_instruction, take_instruction], + &[&scenario.lessor, &scenario.lessee], + &scenario.lessor.pubkey(), ) .unwrap(); let elapsed: i64 = 120; // 2 minutes - advance_clock_by(&mut sc.svm, elapsed); + advance_clock_by(&mut scenario.svm, elapsed); - let pay_ix = build_pay_lease_fee_ix(&sc, lease_id); + let pay_instruction = build_pay_lease_fee_instruction(&scenario, lease_id); send_transaction_from_instructions( - &mut sc.svm, - vec![pay_ix], - &[&sc.lessee], - &sc.lessee.pubkey(), + &mut scenario.svm, + vec![pay_instruction], + &[&scenario.lessee], + &scenario.lessee.pubkey(), ) .unwrap(); let expected_lease_fees = (elapsed as u64) * LEASE_FEE_PER_SECOND; - let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); + let lessor_collateral_associated_token_account = derive_associated_token_account(&scenario.lessor.pubkey(), &scenario.collateral_mint); assert_eq!( - get_token_account_balance(&sc.svm, &lessor_collateral_ata).unwrap(), + get_token_account_balance(&scenario.svm, &lessor_collateral_associated_token_account).unwrap(), expected_lease_fees ); - let (_, _, collateral_vault) = lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + let (_, _, collateral_vault) = lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); assert_eq!( - get_token_account_balance(&sc.svm, &collateral_vault).unwrap(), + get_token_account_balance(&scenario.svm, &collateral_vault).unwrap(), REQUIRED_COLLATERAL - expected_lease_fees ); } #[test] fn top_up_collateral_increases_vault_balance() { - let mut sc = full_setup(); + let mut scenario = full_setup(); let lease_id = 4u64; - let create_ix = build_create_lease_ix( - &sc, + let create_instruction = build_create_lease_instruction( + &scenario, lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, LEASE_FEE_PER_SECOND, DURATION_SECONDS, - MAINTENANCE_MARGIN_BPS, - LIQUIDATION_BOUNTY_BPS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, FEED_ID, ); - let take_ix = build_take_lease_ix(&sc, lease_id); + let take_instruction = build_take_lease_instruction(&scenario, lease_id); send_transaction_from_instructions( - &mut sc.svm, - vec![create_ix, take_ix], - &[&sc.lessor, &sc.lessee], - &sc.lessor.pubkey(), + &mut scenario.svm, + vec![create_instruction, take_instruction], + &[&scenario.lessor, &scenario.lessee], + &scenario.lessor.pubkey(), ) .unwrap(); let top_up_amount: u64 = 50_000_000; - let top_up_ix = build_top_up_ix(&sc, lease_id, top_up_amount); + let top_up_instruction = build_top_up_instruction(&scenario, lease_id, top_up_amount); send_transaction_from_instructions( - &mut sc.svm, - vec![top_up_ix], - &[&sc.lessee], - &sc.lessee.pubkey(), + &mut scenario.svm, + vec![top_up_instruction], + &[&scenario.lessee], + &scenario.lessee.pubkey(), ) .unwrap(); - let (_, _, collateral_vault) = lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); + let (_, _, collateral_vault) = lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); assert_eq!( - get_token_account_balance(&sc.svm, &collateral_vault).unwrap(), + get_token_account_balance(&scenario.svm, &collateral_vault).unwrap(), REQUIRED_COLLATERAL + top_up_amount ); } #[test] fn return_lease_refunds_unused_collateral() { - let mut sc = full_setup(); + let mut scenario = full_setup(); let lease_id = 5u64; - let create_ix = build_create_lease_ix( - &sc, + let create_instruction = build_create_lease_instruction( + &scenario, lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, LEASE_FEE_PER_SECOND, DURATION_SECONDS, - MAINTENANCE_MARGIN_BPS, - LIQUIDATION_BOUNTY_BPS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, FEED_ID, ); - let take_ix = build_take_lease_ix(&sc, lease_id); + let take_instruction = build_take_lease_instruction(&scenario, lease_id); send_transaction_from_instructions( - &mut sc.svm, - vec![create_ix, take_ix], - &[&sc.lessor, &sc.lessee], - &sc.lessor.pubkey(), + &mut scenario.svm, + vec![create_instruction, take_instruction], + &[&scenario.lessor, &scenario.lessee], + &scenario.lessor.pubkey(), ) .unwrap(); // Lessee returns early — 10 minutes in, for a 24h lease. let elapsed: i64 = 600; - advance_clock_by(&mut sc.svm, elapsed); + advance_clock_by(&mut scenario.svm, elapsed); - let return_ix = build_return_lease_ix(&sc, lease_id); + let return_instruction = build_return_lease_instruction(&scenario, lease_id); send_transaction_from_instructions( - &mut sc.svm, - vec![return_ix], - &[&sc.lessee], - &sc.lessee.pubkey(), + &mut scenario.svm, + vec![return_instruction], + &[&scenario.lessee], + &scenario.lessee.pubkey(), ) .unwrap(); @@ -655,66 +655,66 @@ fn return_lease_refunds_unused_collateral() { // Lessor got their leased tokens back. assert_eq!( - get_token_account_balance(&sc.svm, &sc.lessor_leased_ata).unwrap(), + get_token_account_balance(&scenario.svm, &scenario.lessor_leased_associated_token_account).unwrap(), 1_000_000_000 ); // Lessor also received the accrued lease fees. - let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); + let lessor_collateral_associated_token_account = derive_associated_token_account(&scenario.lessor.pubkey(), &scenario.collateral_mint); assert_eq!( - get_token_account_balance(&sc.svm, &lessor_collateral_ata).unwrap(), + get_token_account_balance(&scenario.svm, &lessor_collateral_associated_token_account).unwrap(), lease_fee_paid ); // Lessee got the unused-time portion of their collateral back. assert_eq!( - get_token_account_balance(&sc.svm, &sc.lessee_collateral_ata).unwrap(), + get_token_account_balance(&scenario.svm, &scenario.lessee_collateral_associated_token_account).unwrap(), 1_000_000_000 - REQUIRED_COLLATERAL + refund_expected ); - // Lease + vault PDAs are closed. - let (lease_pda, leased_vault, collateral_vault) = - lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); - assert!(sc.svm.get_account(&lease_pda).is_none()); - assert!(sc.svm.get_account(&leased_vault).is_none()); - assert!(sc.svm.get_account(&collateral_vault).is_none()); + // Lease + vault program-derived addresses are closed. + let (lease_program_derived_address, leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + assert!(scenario.svm.get_account(&lease_program_derived_address).is_none()); + assert!(scenario.svm.get_account(&leased_vault).is_none()); + assert!(scenario.svm.get_account(&collateral_vault).is_none()); } #[test] fn liquidate_seizes_collateral_on_price_drop() { - let mut sc = full_setup(); + let mut scenario = full_setup(); let lease_id = 6u64; - let create_ix = build_create_lease_ix( - &sc, + let create_instruction = build_create_lease_instruction( + &scenario, lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, LEASE_FEE_PER_SECOND, DURATION_SECONDS, - MAINTENANCE_MARGIN_BPS, - LIQUIDATION_BOUNTY_BPS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, FEED_ID, ); - let take_ix = build_take_lease_ix(&sc, lease_id); + let take_instruction = build_take_lease_instruction(&scenario, lease_id); send_transaction_from_instructions( - &mut sc.svm, - vec![create_ix, take_ix], - &[&sc.lessor, &sc.lessee], - &sc.lessor.pubkey(), + &mut scenario.svm, + vec![create_instruction, take_instruction], + &[&scenario.lessor, &scenario.lessee], + &scenario.lessor.pubkey(), ) .unwrap(); // A bit of Lease fee accrues before the liquidation call so the handler has to // settle the lease fee *and* bounty on the same vault balance. let elapsed: i64 = 300; - advance_clock_by(&mut sc.svm, elapsed); + advance_clock_by(&mut scenario.svm, elapsed); // Install a Pyth price that quotes leased-in-collateral at 4.0 per unit // with exponent 0. At 100 leased units the debt is 400 collateral units // vs. the 200 collateral we hold — ratio 50%, well below 120% margin. let price_update_key = Keypair::new(); - let now = current_clock(&sc.svm); + let now = current_clock(&scenario.svm); mock_price_update( - &mut sc.svm, + &mut scenario.svm, price_update_key.pubkey(), FEED_ID, 4, @@ -722,62 +722,62 @@ fn liquidate_seizes_collateral_on_price_drop() { now, // fresh publish_time ); - let liq_ix = build_liquidate_ix(&sc, lease_id, price_update_key.pubkey()); + let liq_ix = build_liquidate_instruction(&scenario, lease_id, price_update_key.pubkey()); send_transaction_from_instructions( - &mut sc.svm, + &mut scenario.svm, vec![liq_ix], - &[&sc.keeper], - &sc.keeper.pubkey(), + &[&scenario.keeper], + &scenario.keeper.pubkey(), ) .unwrap(); let lease_fee_paid = (elapsed as u64) * LEASE_FEE_PER_SECOND; let remaining_after_lease_fees = REQUIRED_COLLATERAL - lease_fee_paid; - let bounty = remaining_after_lease_fees * (LIQUIDATION_BOUNTY_BPS as u64) / 10_000; + let bounty = remaining_after_lease_fees * (LIQUIDATION_BOUNTY_BASIS_POINTS as u64) / 10_000; let lessor_share = remaining_after_lease_fees - bounty; - let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); - let keeper_collateral_ata = derive_ata(&sc.keeper.pubkey(), &sc.collateral_mint); + let lessor_collateral_associated_token_account = derive_associated_token_account(&scenario.lessor.pubkey(), &scenario.collateral_mint); + let keeper_collateral_associated_token_account = derive_associated_token_account(&scenario.keeper.pubkey(), &scenario.collateral_mint); assert_eq!( - get_token_account_balance(&sc.svm, &lessor_collateral_ata).unwrap(), + get_token_account_balance(&scenario.svm, &lessor_collateral_associated_token_account).unwrap(), lease_fee_paid + lessor_share ); assert_eq!( - get_token_account_balance(&sc.svm, &keeper_collateral_ata).unwrap(), + get_token_account_balance(&scenario.svm, &keeper_collateral_associated_token_account).unwrap(), bounty ); // Vaults and lease account closed. - let (lease_pda, leased_vault, collateral_vault) = - lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); - assert!(sc.svm.get_account(&lease_pda).is_none()); - assert!(sc.svm.get_account(&leased_vault).is_none()); - assert!(sc.svm.get_account(&collateral_vault).is_none()); + let (lease_program_derived_address, leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + assert!(scenario.svm.get_account(&lease_program_derived_address).is_none()); + assert!(scenario.svm.get_account(&leased_vault).is_none()); + assert!(scenario.svm.get_account(&collateral_vault).is_none()); } #[test] fn liquidate_rejects_healthy_position() { - let mut sc = full_setup(); + let mut scenario = full_setup(); let lease_id = 7u64; - let create_ix = build_create_lease_ix( - &sc, + let create_instruction = build_create_lease_instruction( + &scenario, lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, LEASE_FEE_PER_SECOND, DURATION_SECONDS, - MAINTENANCE_MARGIN_BPS, - LIQUIDATION_BOUNTY_BPS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, FEED_ID, ); - let take_ix = build_take_lease_ix(&sc, lease_id); + let take_instruction = build_take_lease_instruction(&scenario, lease_id); send_transaction_from_instructions( - &mut sc.svm, - vec![create_ix, take_ix], - &[&sc.lessor, &sc.lessee], - &sc.lessor.pubkey(), + &mut scenario.svm, + vec![create_instruction, take_instruction], + &[&scenario.lessor, &scenario.lessee], + &scenario.lessor.pubkey(), ) .unwrap(); @@ -785,15 +785,15 @@ fn liquidate_rejects_healthy_position() { // = 200 → ratio 200% ≥ 120% maintenance margin. Expect the transaction // to fail with `PositionHealthy`. let price_update_key = Keypair::new(); - let now = current_clock(&sc.svm); - mock_price_update(&mut sc.svm, price_update_key.pubkey(), FEED_ID, 1, 0, now); + let now = current_clock(&scenario.svm); + mock_price_update(&mut scenario.svm, price_update_key.pubkey(), FEED_ID, 1, 0, now); - let liq_ix = build_liquidate_ix(&sc, lease_id, price_update_key.pubkey()); + let liq_ix = build_liquidate_instruction(&scenario, lease_id, price_update_key.pubkey()); let result = send_transaction_from_instructions( - &mut sc.svm, + &mut scenario.svm, vec![liq_ix], - &[&sc.keeper], - &sc.keeper.pubkey(), + &[&scenario.keeper], + &scenario.keeper.pubkey(), ); assert!(result.is_err(), "healthy liquidation must fail"); } @@ -804,26 +804,26 @@ fn liquidate_rejects_mismatched_price_feed() { // internal feed_id is different. Even when the price would push the // position underwater, the liquidate call must bail with // `PriceFeedMismatch` before running the undercollateralisation check. - let mut sc = full_setup(); + let mut scenario = full_setup(); let lease_id = 100u64; - let create_ix = build_create_lease_ix( - &sc, + let create_instruction = build_create_lease_instruction( + &scenario, lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, LEASE_FEE_PER_SECOND, DURATION_SECONDS, - MAINTENANCE_MARGIN_BPS, - LIQUIDATION_BOUNTY_BPS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, FEED_ID, ); - let take_ix = build_take_lease_ix(&sc, lease_id); + let take_instruction = build_take_lease_instruction(&scenario, lease_id); send_transaction_from_instructions( - &mut sc.svm, - vec![create_ix, take_ix], - &[&sc.lessor, &sc.lessee], - &sc.lessor.pubkey(), + &mut scenario.svm, + vec![create_instruction, take_instruction], + &[&scenario.lessor, &scenario.lessee], + &scenario.lessor.pubkey(), ) .unwrap(); @@ -834,9 +834,9 @@ fn liquidate_rejects_mismatched_price_feed() { // same as `liquidate_seizes_collateral_on_price_drop`) — except this // update carries the wrong feed id. let price_update_key = Keypair::new(); - let now = current_clock(&sc.svm); + let now = current_clock(&scenario.svm); mock_price_update( - &mut sc.svm, + &mut scenario.svm, price_update_key.pubkey(), wrong_feed_id, 4, @@ -844,12 +844,12 @@ fn liquidate_rejects_mismatched_price_feed() { now, ); - let liq_ix = build_liquidate_ix(&sc, lease_id, price_update_key.pubkey()); + let liq_ix = build_liquidate_instruction(&scenario, lease_id, price_update_key.pubkey()); let result = send_transaction_from_instructions( - &mut sc.svm, + &mut scenario.svm, vec![liq_ix], - &[&sc.keeper], - &sc.keeper.pubkey(), + &[&scenario.keeper], + &scenario.keeper.pubkey(), ); let err = result.expect_err("liquidate must reject foreign price feeds"); let rendered = format!("{err:?}"); @@ -861,106 +861,106 @@ fn liquidate_rejects_mismatched_price_feed() { } #[test] -fn close_expired_reclaims_collateral_after_end_ts() { - let mut sc = full_setup(); +fn close_expired_reclaims_collateral_after_end_timestamp() { + let mut scenario = full_setup(); let lease_id = 8u64; - let create_ix = build_create_lease_ix( - &sc, + let create_instruction = build_create_lease_instruction( + &scenario, lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, LEASE_FEE_PER_SECOND, DURATION_SECONDS, - MAINTENANCE_MARGIN_BPS, - LIQUIDATION_BOUNTY_BPS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, FEED_ID, ); - let take_ix = build_take_lease_ix(&sc, lease_id); + let take_instruction = build_take_lease_instruction(&scenario, lease_id); send_transaction_from_instructions( - &mut sc.svm, - vec![create_ix, take_ix], - &[&sc.lessor, &sc.lessee], - &sc.lessor.pubkey(), + &mut scenario.svm, + vec![create_instruction, take_instruction], + &[&scenario.lessor, &scenario.lessee], + &scenario.lessor.pubkey(), ) .unwrap(); // Jump past the lease end. - advance_clock_by(&mut sc.svm, DURATION_SECONDS + 1); + advance_clock_by(&mut scenario.svm, DURATION_SECONDS + 1); - let close_ix = build_close_expired_ix(&sc, lease_id); + let close_instruction = build_close_expired_instruction(&scenario, lease_id); send_transaction_from_instructions( - &mut sc.svm, - vec![close_ix], - &[&sc.lessor], - &sc.lessor.pubkey(), + &mut scenario.svm, + vec![close_instruction], + &[&scenario.lessor], + &scenario.lessor.pubkey(), ) .unwrap(); // Full collateral forfeited to the lessor. Leased tokens are gone (the // lessee kept them on default) so the lessor's leased balance is only // what they had after the initial escrow minus the leased amount. - let lessor_collateral_ata = derive_ata(&sc.lessor.pubkey(), &sc.collateral_mint); + let lessor_collateral_associated_token_account = derive_associated_token_account(&scenario.lessor.pubkey(), &scenario.collateral_mint); assert_eq!( - get_token_account_balance(&sc.svm, &lessor_collateral_ata).unwrap(), + get_token_account_balance(&scenario.svm, &lessor_collateral_associated_token_account).unwrap(), REQUIRED_COLLATERAL ); assert_eq!( - get_token_account_balance(&sc.svm, &sc.lessor_leased_ata).unwrap(), + get_token_account_balance(&scenario.svm, &scenario.lessor_leased_associated_token_account).unwrap(), 1_000_000_000 - LEASED_AMOUNT ); - let (lease_pda, leased_vault, collateral_vault) = - lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); - assert!(sc.svm.get_account(&lease_pda).is_none()); - assert!(sc.svm.get_account(&leased_vault).is_none()); - assert!(sc.svm.get_account(&collateral_vault).is_none()); + let (lease_program_derived_address, leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + assert!(scenario.svm.get_account(&lease_program_derived_address).is_none()); + assert!(scenario.svm.get_account(&leased_vault).is_none()); + assert!(scenario.svm.get_account(&collateral_vault).is_none()); } #[test] fn close_expired_cancels_listed_lease() { - let mut sc = full_setup(); + let mut scenario = full_setup(); let lease_id = 9u64; - let create_ix = build_create_lease_ix( - &sc, + let create_instruction = build_create_lease_instruction( + &scenario, lease_id, LEASED_AMOUNT, REQUIRED_COLLATERAL, LEASE_FEE_PER_SECOND, DURATION_SECONDS, - MAINTENANCE_MARGIN_BPS, - LIQUIDATION_BOUNTY_BPS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, FEED_ID, ); send_transaction_from_instructions( - &mut sc.svm, - vec![create_ix], - &[&sc.lessor], - &sc.lessor.pubkey(), + &mut scenario.svm, + vec![create_instruction], + &[&scenario.lessor], + &scenario.lessor.pubkey(), ) .unwrap(); // Lessor bails before anyone takes the lease — allowed immediately. - let close_ix = build_close_expired_ix(&sc, lease_id); + let close_instruction = build_close_expired_instruction(&scenario, lease_id); send_transaction_from_instructions( - &mut sc.svm, - vec![close_ix], - &[&sc.lessor], - &sc.lessor.pubkey(), + &mut scenario.svm, + vec![close_instruction], + &[&scenario.lessor], + &scenario.lessor.pubkey(), ) .unwrap(); // Lessor recovered the full leased amount. No collateral was ever posted. assert_eq!( - get_token_account_balance(&sc.svm, &sc.lessor_leased_ata).unwrap(), + get_token_account_balance(&scenario.svm, &scenario.lessor_leased_associated_token_account).unwrap(), 1_000_000_000 ); - let (lease_pda, leased_vault, collateral_vault) = - lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); - assert!(sc.svm.get_account(&lease_pda).is_none()); - assert!(sc.svm.get_account(&leased_vault).is_none()); - assert!(sc.svm.get_account(&collateral_vault).is_none()); + let (lease_program_derived_address, leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + assert!(scenario.svm.get_account(&lease_program_derived_address).is_none()); + assert!(scenario.svm.get_account(&leased_vault).is_none()); + assert!(scenario.svm.get_account(&collateral_vault).is_none()); } #[test] @@ -970,33 +970,33 @@ fn create_lease_rejects_same_mint_for_leased_and_collateral() { // same authority seed pattern) and make lease-fee-vs-collateral accounting // ambiguous. The program rejects this up-front with // `LeasedMintEqualsCollateralMint`. - let mut sc = full_setup(); + let mut scenario = full_setup(); let lease_id = 42u64; // Build a `create_lease` instruction where the collateral_mint field - // carries the same mint as leased_mint. We bypass `build_create_lease_ix` + // carries the same mint as leased_mint. We bypass `build_create_lease_instruction` // because that helper always wires the two mints from the scenario. let (lease, leased_vault, collateral_vault) = - lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id); - let ix = Instruction::new_with_bytes( - sc.program_id, + lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + let instruction = Instruction::new_with_bytes( + scenario.program_id, &asset_leasing::instruction::CreateLease { lease_id, leased_amount: LEASED_AMOUNT, required_collateral_amount: REQUIRED_COLLATERAL, lease_fee_per_second: LEASE_FEE_PER_SECOND, duration_seconds: DURATION_SECONDS, - maintenance_margin_bps: MAINTENANCE_MARGIN_BPS, - liquidation_bounty_bps: LIQUIDATION_BOUNTY_BPS, + maintenance_margin_basis_points: MAINTENANCE_MARGIN_BASIS_POINTS, + liquidation_bounty_basis_points: LIQUIDATION_BOUNTY_BASIS_POINTS, feed_id: FEED_ID, } .data(), asset_leasing::accounts::CreateLease { - lessor: sc.lessor.pubkey(), - leased_mint: sc.leased_mint, + lessor: scenario.lessor.pubkey(), + leased_mint: scenario.leased_mint, // Same mint on both sides — should be rejected. - collateral_mint: sc.leased_mint, - lessor_leased_account: sc.lessor_leased_ata, + collateral_mint: scenario.leased_mint, + lessor_leased_account: scenario.lessor_leased_associated_token_account, lease, leased_vault, collateral_vault, @@ -1007,10 +1007,10 @@ fn create_lease_rejects_same_mint_for_leased_and_collateral() { ); let result = send_transaction_from_instructions( - &mut sc.svm, - vec![ix], - &[&sc.lessor], - &sc.lessor.pubkey(), + &mut scenario.svm, + vec![instruction], + &[&scenario.lessor], + &scenario.lessor.pubkey(), ); let err = result.expect_err("create_lease must reject identical leased/collateral mints"); diff --git a/defi/asset-leasing/quasar/src/constants.rs b/defi/asset-leasing/quasar/src/constants.rs index 3810ee493..36bb189b7 100644 --- a/defi/asset-leasing/quasar/src/constants.rs +++ b/defi/asset-leasing/quasar/src/constants.rs @@ -1,26 +1,26 @@ -/// PDA seed for the `Lease` account. Combined with the lessor pubkey and a +/// program-derived address seed for the `Lease` account. Combined with the lessor pubkey and a /// u64 `lease_id` so one lessor can run many leases in parallel. pub const LEASE_SEED: &[u8] = b"lease"; -/// PDA seed for the token vault that holds the leased tokens while the lease +/// program-derived address seed for the token vault that holds the leased tokens while the lease /// is `Listed` and that accepts returned tokens on settlement. pub const LEASED_VAULT_SEED: &[u8] = b"leased_vault"; -/// PDA seed for the token vault that escrows the lessee's collateral for the +/// program-derived address seed for the token vault that escrows the lessee's collateral for the /// life of the lease. pub const COLLATERAL_VAULT_SEED: &[u8] = b"collateral_vault"; -/// Denominator for basis-point (bps) ratios used for the maintenance margin -/// and the liquidation bounty. 10_000 bps = 100%. -pub const BPS_DENOMINATOR: u64 = 10_000; +/// Denominator for basis-point (basis points) ratios used for the maintenance margin +/// and the liquidation bounty. 10_000 basis points = 100%. +pub const BASIS_POINTS_DENOMINATOR: u64 = 10_000; -/// Maximum allowed maintenance margin: 50_000 bps = 500%. Prevents the lessor +/// Maximum allowed maintenance margin: 50_000 basis points = 500%. Prevents the lessor /// setting an impossible margin that would let them liquidate on day one. -pub const MAX_MAINTENANCE_MARGIN_BPS: u16 = 50_000; +pub const MAX_MAINTENANCE_MARGIN_BASIS_POINTS: u16 = 50_000; -/// Maximum liquidation bounty the keeper can claim: 2_000 bps = 20%. Keeps +/// Maximum liquidation bounty the keeper can claim: 2_000 basis points = 20%. Keeps /// most of the collateral flowing to the lessor on default. -pub const MAX_LIQUIDATION_BOUNTY_BPS: u16 = 2_000; +pub const MAX_LIQUIDATION_BOUNTY_BASIS_POINTS: u16 = 2_000; /// A Pyth price update is considered stale if its `publish_time` is older /// than this many seconds versus the current on-chain clock. 60 s matches diff --git a/defi/asset-leasing/quasar/src/instructions/close_expired.rs b/defi/asset-leasing/quasar/src/instructions/close_expired.rs index 26e1ae735..f4d6b7c91 100644 --- a/defi/asset-leasing/quasar/src/instructions/close_expired.rs +++ b/defi/asset-leasing/quasar/src/instructions/close_expired.rs @@ -2,7 +2,7 @@ use { crate::{ constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, errors::AssetLeasingError, - instructions::pay_lease_fee::update_last_paid_ts, + instructions::pay_lease_fee::update_last_paid_timestamp, state::{Lease, LeaseStatus}, }, quasar_lang::prelude::*, @@ -13,7 +13,7 @@ use { /// /// - The lease sat in `Listed` and the lessor wants to cancel it, /// recovering the leased tokens they pre-funded. Allowed any time. -/// - The lease was `Active` but the lessee ghosted past `end_ts`. The +/// - The lease was `Active` but the lessee ghosted past `end_timestamp`. The /// lessor takes the collateral as compensation and closes the books. #[derive(Accounts)] pub struct CloseExpired<'info> { @@ -71,8 +71,8 @@ pub fn handle_close_expired(accounts: &mut CloseExpired) -> Result<(), ProgramEr // Active leases can only be closed after they expire. Listed leases // have no start/end so the check is skipped. if status == LeaseStatus::Active { - let end_ts = accounts.lease.end_ts.get(); - if now < end_ts { + let end_timestamp = accounts.lease.end_timestamp.get(); + if now < end_timestamp { return Err(AssetLeasingError::LeaseNotExpired.into()); } } @@ -144,10 +144,10 @@ pub fn handle_close_expired(accounts: &mut CloseExpired) -> Result<(), ProgramEr // lessor takes the whole collateral vault as compensation here, but // any future version of the program that wants to split the // collateral differently (pro-rata lease fees, partial refund on default) - // can read `last_paid_ts` and trust that everything up to + // can read `last_paid_timestamp` and trust that everything up to // `now` is already settled. if status == LeaseStatus::Active { - update_last_paid_ts(accounts.lease, now); + update_last_paid_timestamp(accounts.lease, now); } accounts.lease.collateral_amount = 0u64.into(); accounts.lease.status = LeaseStatus::Closed as u8; diff --git a/defi/asset-leasing/quasar/src/instructions/create_lease.rs b/defi/asset-leasing/quasar/src/instructions/create_lease.rs index afbc64dae..e9715b622 100644 --- a/defi/asset-leasing/quasar/src/instructions/create_lease.rs +++ b/defi/asset-leasing/quasar/src/instructions/create_lease.rs @@ -1,8 +1,8 @@ use { crate::{ constants::{ - COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED, MAX_LIQUIDATION_BOUNTY_BPS, - MAX_MAINTENANCE_MARGIN_BPS, + COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED, MAX_LIQUIDATION_BOUNTY_BASIS_POINTS, + MAX_MAINTENANCE_MARGIN_BASIS_POINTS, }, errors::AssetLeasingError, state::{Lease, LeaseStatus}, @@ -12,7 +12,7 @@ use { }; /// Accounts needed to create a new `Listed` lease. The lessor funds the -/// lease state account and both PDA-owned token vaults up front, then +/// lease state account and both program-derived address-owned token vaults up front, then /// transfers the leased tokens into the leased vault in the same /// transaction so a lessee can never accept a lease the lessor has not /// pre-funded. @@ -25,8 +25,8 @@ pub struct CreateLease<'info> { pub collateral_mint: &'info Account, /// Lessor's existing token account for the leased mint. Pre-created by - /// the caller — the Quasar port does not do `init_if_needed` ATAs - /// (the Anchor version does, via CPI to the Associated Token Account + /// the caller — the Quasar port does not do `init_if_needed` associated token accounts + /// (the Anchor version does, via cross-program invocation to the Associated Token Account /// program; see the Quasar section of the README for the rationale). #[account(mut)] pub lessor_leased_account: &'info mut Account, @@ -40,7 +40,7 @@ pub struct CreateLease<'info> { )] pub lease: &'info mut Account, - /// Leased-token vault. Authority is the vault PDA itself — signing + /// Leased-token vault. Authority is the vault program-derived address itself — signing /// with the vault seeds is the only way to move tokens out. #[account( mut, @@ -79,8 +79,8 @@ pub fn handle_create_lease( required_collateral_amount: u64, lease_fee_per_second: u64, duration_seconds: i64, - maintenance_margin_bps: u16, - liquidation_bounty_bps: u16, + maintenance_margin_basis_points: u16, + liquidation_bounty_basis_points: u16, feed_id: [u8; 32], bumps: &CreateLeaseBumps, ) -> Result<(), ProgramError> { @@ -103,11 +103,11 @@ pub fn handle_create_lease( ); require!(duration_seconds > 0, AssetLeasingError::InvalidDuration); require!( - maintenance_margin_bps > 0 && maintenance_margin_bps <= MAX_MAINTENANCE_MARGIN_BPS, + maintenance_margin_basis_points > 0 && maintenance_margin_basis_points <= MAX_MAINTENANCE_MARGIN_BASIS_POINTS, AssetLeasingError::InvalidMaintenanceMargin ); require!( - liquidation_bounty_bps <= MAX_LIQUIDATION_BOUNTY_BPS, + liquidation_bounty_basis_points <= MAX_LIQUIDATION_BOUNTY_BASIS_POINTS, AssetLeasingError::InvalidLiquidationBounty ); @@ -138,12 +138,12 @@ pub fn handle_create_lease( required_collateral_amount, lease_fee_per_second, duration_seconds, - // start_ts / end_ts / last_paid_ts set on `take_lease`. + // start_timestamp / end_timestamp / last_paid_timestamp set on `take_lease`. 0, 0, 0, - maintenance_margin_bps, - liquidation_bounty_bps, + maintenance_margin_basis_points, + liquidation_bounty_basis_points, feed_id, LeaseStatus::Listed as u8, bumps.lease, diff --git a/defi/asset-leasing/quasar/src/instructions/liquidate.rs b/defi/asset-leasing/quasar/src/instructions/liquidate.rs index 519fdf4d9..c922e4a86 100644 --- a/defi/asset-leasing/quasar/src/instructions/liquidate.rs +++ b/defi/asset-leasing/quasar/src/instructions/liquidate.rs @@ -1,7 +1,7 @@ use { crate::{ constants::{ - BPS_DENOMINATOR, COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED, + BASIS_POINTS_DENOMINATOR, COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED, PYTH_MAX_AGE_SECONDS, }, errors::AssetLeasingError, @@ -162,7 +162,7 @@ pub fn handle_liquidate(accounts: &mut Liquidate) -> Result<(), ProgramError> { return Err(AssetLeasingError::PositionHealthy.into()); } - // Settle accrued lease fees first (up to end_ts) so the lessor is paid for + // Settle accrued lease fees first (up to end_timestamp) so the lessor is paid for // the time the lessee actually used. Only then slice off bounty + // remainder. let lease_fee_due = compute_lease_fee_due(accounts.lease, now)?; @@ -202,9 +202,9 @@ pub fn handle_liquidate(accounts: &mut Liquidate) -> Result<(), ProgramError> { // Bounty is a percentage of the collateral *after* lease fees — guarantees // we never try to pay out more than what actually sits in the vault. let bounty = (remaining as u128) - .checked_mul(accounts.lease.liquidation_bounty_bps.get() as u128) + .checked_mul(accounts.lease.liquidation_bounty_basis_points.get() as u128) .ok_or(AssetLeasingError::MathOverflow)? - .checked_div(BPS_DENOMINATOR as u128) + .checked_div(BASIS_POINTS_DENOMINATOR as u128) .ok_or(AssetLeasingError::MathOverflow)? as u64; if bounty > 0 { @@ -255,8 +255,8 @@ pub fn handle_liquidate(accounts: &mut Liquidate) -> Result<(), ProgramError> { .invoke_signed(collateral_vault_seeds)?; accounts.lease.collateral_amount = 0u64.into(); - let end_ts = accounts.lease.end_ts.get(); - accounts.lease.last_paid_ts = now.min(end_ts).into(); + let end_timestamp = accounts.lease.end_timestamp.get(); + accounts.lease.last_paid_timestamp = now.min(end_timestamp).into(); accounts.lease.status = LeaseStatus::Liquidated as u8; Ok(()) @@ -287,8 +287,8 @@ pub fn is_underwater( let leased_amount = lease.leased_amount.get() as u128; let collateral_amount = lease.collateral_amount.get() as u128; - let margin_bps = lease.maintenance_margin_bps.get() as u128; - let denom = BPS_DENOMINATOR as u128; + let margin_basis_points = lease.maintenance_margin_basis_points.get() as u128; + let denom = BASIS_POINTS_DENOMINATOR as u128; let (collateral_scaled, debt_scaled) = if price.exponent >= 0 { let scale = ten_pow(price.exponent as u32)?; @@ -312,7 +312,7 @@ pub fn is_underwater( .checked_mul(denom) .ok_or(AssetLeasingError::MathOverflow)?; let rhs = debt_scaled - .checked_mul(margin_bps) + .checked_mul(margin_basis_points) .ok_or(AssetLeasingError::MathOverflow)?; Ok(lhs < rhs) diff --git a/defi/asset-leasing/quasar/src/instructions/pay_lease_fee.rs b/defi/asset-leasing/quasar/src/instructions/pay_lease_fee.rs index e72ed929a..cbb10b24a 100644 --- a/defi/asset-leasing/quasar/src/instructions/pay_lease_fee.rs +++ b/defi/asset-leasing/quasar/src/instructions/pay_lease_fee.rs @@ -16,7 +16,7 @@ pub struct PayLeaseFee<'info> { #[account(mut)] pub payer: &'info Signer, - /// PDA seed + `has_one` target. Not read directly. + /// program-derived address seed + `has_one` target. Not read directly. pub lessor: &'info UncheckedAccount, #[account( @@ -53,7 +53,7 @@ pub fn handle_pay_lease_fee(accounts: &mut PayLeaseFee) -> Result<(), ProgramErr let lease_fee_amount = compute_lease_fee_due(accounts.lease, now)?; if lease_fee_amount == 0 { - update_last_paid_ts(accounts.lease, now); + update_last_paid_timestamp(accounts.lease, now); return Ok(()); } @@ -87,16 +87,16 @@ pub fn handle_pay_lease_fee(accounts: &mut PayLeaseFee) -> Result<(), ProgramErr accounts.lease.collateral_amount = new_collateral.into(); } - update_last_paid_ts(accounts.lease, now); + update_last_paid_timestamp(accounts.lease, now); Ok(()) } -/// Lease fee accrues linearly: `(min(now, end_ts) - last_paid_ts) * rate`. +/// Lease fee accrues linearly: `(min(now, end_timestamp) - last_paid_timestamp) * rate`. /// Shared with `return_lease` and `liquidate` for final settlement. pub fn compute_lease_fee_due(lease: &Lease, now: i64) -> Result { - let end_ts = lease.end_ts.get(); - let last_paid = lease.last_paid_ts.get(); - let cutoff = now.min(end_ts); + let end_timestamp = lease.end_timestamp.get(); + let last_paid = lease.last_paid_timestamp.get(); + let cutoff = now.min(end_timestamp); if cutoff <= last_paid { return Ok(0); } @@ -106,10 +106,10 @@ pub fn compute_lease_fee_due(lease: &Lease, now: i64) -> Result Result<(), ProgramErro ) .invoke_signed(collateral_vault_seeds)?; - update_last_paid_ts(accounts.lease, now); + update_last_paid_timestamp(accounts.lease, now); accounts.lease.collateral_amount = 0u64.into(); accounts.lease.status = LeaseStatus::Closed as u8; diff --git a/defi/asset-leasing/quasar/src/instructions/take_lease.rs b/defi/asset-leasing/quasar/src/instructions/take_lease.rs index 502d34279..2e007906c 100644 --- a/defi/asset-leasing/quasar/src/instructions/take_lease.rs +++ b/defi/asset-leasing/quasar/src/instructions/take_lease.rs @@ -17,7 +17,7 @@ pub struct TakeLease<'info> { pub lessee: &'info Signer, /// Pubkey of the lessor who created the lease. Referenced only for - /// `Lease` PDA derivation and the `has_one` check below. + /// `Lease` program-derived address derivation and the `has_one` check below. pub lessor: &'info UncheckedAccount, #[account( @@ -82,7 +82,7 @@ pub fn handle_take_lease(accounts: &mut TakeLease) -> Result<(), ProgramError> { ) .invoke()?; - // Pay out leased tokens from the vault PDA. Signer seeds reproduce the + // Pay out leased tokens from the vault program-derived address. Signer seeds reproduce the // vault's derivation: [LEASED_VAULT_SEED, lease, bump]. let leased_vault_bump = [accounts.lease.leased_vault_bump]; let lease_address = *accounts.lease.address(); @@ -101,16 +101,16 @@ pub fn handle_take_lease(accounts: &mut TakeLease) -> Result<(), ProgramError> { ) .invoke_signed(vault_seeds)?; - let end_ts = now + let end_timestamp = now .checked_add(duration_seconds) .ok_or(AssetLeasingError::MathOverflow)?; let lease = &mut accounts.lease; lease.lessee = *accounts.lessee.address(); lease.collateral_amount = required_collateral_amount.into(); - lease.start_ts = now.into(); - lease.end_ts = end_ts.into(); - lease.last_paid_ts = now.into(); + lease.start_timestamp = now.into(); + lease.end_timestamp = end_timestamp.into(); + lease.last_paid_timestamp = now.into(); lease.status = LeaseStatus::Active as u8; Ok(()) diff --git a/defi/asset-leasing/quasar/src/instructions/top_up_collateral.rs b/defi/asset-leasing/quasar/src/instructions/top_up_collateral.rs index 679c94c91..5fb1292ab 100644 --- a/defi/asset-leasing/quasar/src/instructions/top_up_collateral.rs +++ b/defi/asset-leasing/quasar/src/instructions/top_up_collateral.rs @@ -16,7 +16,7 @@ pub struct TopUpCollateral<'info> { #[account(mut)] pub lessee: &'info Signer, - /// PDA seed only — not read directly. + /// program-derived address seed only — not read directly. pub lessor: &'info UncheckedAccount, #[account( diff --git a/defi/asset-leasing/quasar/src/lib.rs b/defi/asset-leasing/quasar/src/lib.rs index c98b4daf3..acff28198 100644 --- a/defi/asset-leasing/quasar/src/lib.rs +++ b/defi/asset-leasing/quasar/src/lib.rs @@ -12,7 +12,7 @@ use instructions::*; mod tests; // Same program id as the Anchor version so off-chain tooling that derives -// PDAs or looks up the program on-chain works against both binaries +// program-derived addresses or looks up the program on-chain works against both binaries // interchangeably. declare_id!("Lease11111111111111111111111111111111111111"); @@ -30,60 +30,60 @@ mod quasar_asset_leasing { /// lifecycle (create → take → pay/top-up → return/liquidate/close). #[instruction(discriminator = 0)] pub fn create_lease( - ctx: Ctx, + context: Ctx, lease_id: u64, leased_amount: u64, required_collateral_amount: u64, lease_fee_per_second: u64, duration_seconds: i64, - maintenance_margin_bps: u16, - liquidation_bounty_bps: u16, + maintenance_margin_basis_points: u16, + liquidation_bounty_basis_points: u16, feed_id: [u8; 32], ) -> Result<(), ProgramError> { instructions::handle_create_lease( - &mut ctx.accounts, + &mut context.accounts, lease_id, leased_amount, required_collateral_amount, lease_fee_per_second, duration_seconds, - maintenance_margin_bps, - liquidation_bounty_bps, + maintenance_margin_basis_points, + liquidation_bounty_basis_points, feed_id, - &ctx.bumps, + &context.bumps, ) } #[instruction(discriminator = 1)] - pub fn take_lease(ctx: Ctx) -> Result<(), ProgramError> { - instructions::handle_take_lease(&mut ctx.accounts) + pub fn take_lease(context: Ctx) -> Result<(), ProgramError> { + instructions::handle_take_lease(&mut context.accounts) } #[instruction(discriminator = 2)] - pub fn pay_lease_fee(ctx: Ctx) -> Result<(), ProgramError> { - instructions::handle_pay_lease_fee(&mut ctx.accounts) + pub fn pay_lease_fee(context: Ctx) -> Result<(), ProgramError> { + instructions::handle_pay_lease_fee(&mut context.accounts) } #[instruction(discriminator = 3)] pub fn top_up_collateral( - ctx: Ctx, + context: Ctx, amount: u64, ) -> Result<(), ProgramError> { - instructions::handle_top_up_collateral(&mut ctx.accounts, amount) + instructions::handle_top_up_collateral(&mut context.accounts, amount) } #[instruction(discriminator = 4)] - pub fn return_lease(ctx: Ctx) -> Result<(), ProgramError> { - instructions::handle_return_lease(&mut ctx.accounts) + pub fn return_lease(context: Ctx) -> Result<(), ProgramError> { + instructions::handle_return_lease(&mut context.accounts) } #[instruction(discriminator = 5)] - pub fn liquidate(ctx: Ctx) -> Result<(), ProgramError> { - instructions::handle_liquidate(&mut ctx.accounts) + pub fn liquidate(context: Ctx) -> Result<(), ProgramError> { + instructions::handle_liquidate(&mut context.accounts) } #[instruction(discriminator = 6)] - pub fn close_expired(ctx: Ctx) -> Result<(), ProgramError> { - instructions::handle_close_expired(&mut ctx.accounts) + pub fn close_expired(context: Ctx) -> Result<(), ProgramError> { + instructions::handle_close_expired(&mut context.accounts) } } diff --git a/defi/asset-leasing/quasar/src/state.rs b/defi/asset-leasing/quasar/src/state.rs index bd3a1351a..cac02347d 100644 --- a/defi/asset-leasing/quasar/src/state.rs +++ b/defi/asset-leasing/quasar/src/state.rs @@ -60,19 +60,19 @@ pub struct Lease { pub lease_fee_per_second: u64, pub duration_seconds: i64, /// `0` while `Listed`; `unix_timestamp` of `take_lease` while `Active`. - pub start_ts: i64, - /// `0` while `Listed`; `start_ts + duration_seconds` once `Active`. - pub end_ts: i64, - /// Lease fee accrues from here to `min(now, end_ts)`. - pub last_paid_ts: i64, + pub start_timestamp: i64, + /// `0` while `Listed`; `start_timestamp + duration_seconds` once `Active`. + pub end_timestamp: i64, + /// Lease fee accrues from here to `min(now, end_timestamp)`. + pub last_paid_timestamp: i64, /// Collateral-over-debt ratio in basis points. - /// `12_000` bps = 120%. Capped at `MAX_MAINTENANCE_MARGIN_BPS`. - pub maintenance_margin_bps: u16, + /// `12_000` basis points = 120%. Capped at `MAX_MAINTENANCE_MARGIN_BASIS_POINTS`. + pub maintenance_margin_basis_points: u16, /// Keeper's cut of the post-lease-fee collateral on liquidation, in basis - /// points. Capped at `MAX_LIQUIDATION_BOUNTY_BPS` to stop a malicious + /// points. Capped at `MAX_LIQUIDATION_BOUNTY_BASIS_POINTS` to stop a malicious /// lessor from draining the recovery pool via the bounty. - pub liquidation_bounty_bps: u16, + pub liquidation_bounty_basis_points: u16, /// Pyth feed id this lease is pinned to at creation. Enforced on every /// `liquidate` so a keeper cannot swap in an unrelated feed to force an diff --git a/defi/asset-leasing/quasar/src/tests.rs b/defi/asset-leasing/quasar/src/tests.rs index 05a58d40e..4d9225ffc 100644 --- a/defi/asset-leasing/quasar/src/tests.rs +++ b/defi/asset-leasing/quasar/src/tests.rs @@ -39,15 +39,15 @@ const LEASE_FEE_PER_SECOND: u64 = 10; /// 24 hours. const DURATION_SECONDS: i64 = 60 * 60 * 24; /// 120% maintenance margin, in basis points. -const MAINTENANCE_MARGIN_BPS: u16 = 12_000; +const MAINTENANCE_MARGIN_BASIS_POINTS: u16 = 12_000; /// 5% keeper bounty, in basis points. -const LIQUIDATION_BOUNTY_BPS: u16 = 500; +const LIQUIDATION_BOUNTY_BASIS_POINTS: u16 = 500; /// Arbitrary 32-byte Pyth feed id the tests pin their leases to. const FEED_ID: [u8; 32] = [0xAB; 32]; /// LiteSVM's default clock starts at epoch 0; anchoring at a recent-ish /// real timestamp keeps lease fee math free of sign-weirdness without any -/// tests having to special-case `start_ts = 0`. +/// tests having to special-case `start_timestamp = 0`. const DEFAULT_TIMESTAMP: i64 = 1_700_000_000; /// Starting wallet balance for lessor and lessee token accounts. @@ -120,13 +120,13 @@ fn token(address: Pubkey, mint: Pubkey, owner: Pubkey, amount: u64) -> Account { /// Layout (after the `#[account(discriminator = 1)]` macro lowers fields /// to pod types): 1 disc + 8 lease_id + 32 lessor + 32 lessee + 32 /// leased_mint + 8 leased_amount + 32 collateral_mint + 8 collateral_amount -/// + 8 required_collateral + 8 lease_fee_per_second + 8 duration + 8 start_ts + -/// 8 end_ts + 8 last_paid_ts + 2 margin_bps + 2 bounty_bps + 32 +/// + 8 required_collateral + 8 lease_fee_per_second + 8 duration + 8 start_timestamp + +/// 8 end_timestamp + 8 last_paid_timestamp + 2 margin_basis_points + 2 bounty_basis_points + 32 /// feed_id + 4 status/bumps = 249 bytes. mod lease_offsets { pub const COLLATERAL_AMOUNT: usize = 1 + 8 + 32 + 32 + 32 + 8 + 32; - pub const LAST_PAID_TS: usize = COLLATERAL_AMOUNT + 8 + 8 + 8 + 8 + 8 + 8; - pub const STATUS: usize = LAST_PAID_TS + 8 + 2 + 2 + 32; + pub const LAST_PAID_TIMESTAMP: usize = COLLATERAL_AMOUNT + 8 + 8 + 8 + 8 + 8 + 8; + pub const STATUS: usize = LAST_PAID_TIMESTAMP + 8 + 2 + 2 + 32; } fn read_collateral_amount(data: &[u8]) -> u64 { @@ -146,18 +146,18 @@ fn read_token_amount(account: &Account) -> u64 { } // --------------------------------------------------------------------------- -// PDA derivations (mirror the program's `#[account(seeds = ...)]`) +// program-derived address derivations (mirror the program's `#[account(seeds = ...)]`) // --------------------------------------------------------------------------- -fn lease_pda(lessor: &Pubkey) -> (Pubkey, u8) { +fn lease_program_derived_address(lessor: &Pubkey) -> (Pubkey, u8) { Pubkey::find_program_address(&[LEASE_SEED, lessor.as_ref()], &crate::ID) } -fn leased_vault_pda(lease: &Pubkey) -> (Pubkey, u8) { +fn leased_vault_program_derived_address(lease: &Pubkey) -> (Pubkey, u8) { Pubkey::find_program_address(&[LEASED_VAULT_SEED, lease.as_ref()], &crate::ID) } -fn collateral_vault_pda(lease: &Pubkey) -> (Pubkey, u8) { +fn collateral_vault_program_derived_address(lease: &Pubkey) -> (Pubkey, u8) { Pubkey::find_program_address(&[COLLATERAL_VAULT_SEED, lease.as_ref()], &crate::ID) } @@ -172,8 +172,8 @@ fn build_create_lease_data( required_collateral_amount: u64, lease_fee_per_second: u64, duration_seconds: i64, - maintenance_margin_bps: u16, - liquidation_bounty_bps: u16, + maintenance_margin_basis_points: u16, + liquidation_bounty_basis_points: u16, feed_id: [u8; 32], ) -> Vec { let mut data = vec![0u8]; // discriminator for create_lease @@ -182,8 +182,8 @@ fn build_create_lease_data( data.extend_from_slice(&required_collateral_amount.to_le_bytes()); data.extend_from_slice(&lease_fee_per_second.to_le_bytes()); data.extend_from_slice(&duration_seconds.to_le_bytes()); - data.extend_from_slice(&maintenance_margin_bps.to_le_bytes()); - data.extend_from_slice(&liquidation_bounty_bps.to_le_bytes()); + data.extend_from_slice(&maintenance_margin_basis_points.to_le_bytes()); + data.extend_from_slice(&liquidation_bounty_basis_points.to_le_bytes()); data.extend_from_slice(&feed_id); data } @@ -200,15 +200,15 @@ struct Scenario { collateral_mint: Pubkey, /// Pre-created lessor token account for the leased mint, starts at /// `STARTING_BALANCE`. - lessor_leased_ta: Pubkey, - /// Lessor's collateral ATA, starts empty. - lessor_collateral_ta: Pubkey, - /// Lessee's collateral ATA, starts at `STARTING_BALANCE`. - lessee_collateral_ta: Pubkey, - /// Lessee's leased ATA, starts empty. - lessee_leased_ta: Pubkey, - /// Keeper's collateral ATA, starts empty — bounty destination. - keeper_collateral_ta: Pubkey, + lessor_leased_token_account: Pubkey, + /// Lessor's collateral associated token account, starts empty. + lessor_collateral_token_account: Pubkey, + /// Lessee's collateral associated token account, starts at `STARTING_BALANCE`. + lessee_collateral_token_account: Pubkey, + /// Lessee's leased associated token account, starts empty. + lessee_leased_token_account: Pubkey, + /// Keeper's collateral associated token account, starts empty — bounty destination. + keeper_collateral_token_account: Pubkey, lease: Pubkey, leased_vault: Pubkey, collateral_vault: Pubkey, @@ -221,25 +221,25 @@ fn make_scenario() -> (QuasarSvm, Scenario) { let keeper = Pubkey::new_unique(); let leased_mint = Pubkey::new_unique(); let collateral_mint = Pubkey::new_unique(); - let lessor_leased_ta = Pubkey::new_unique(); - let lessor_collateral_ta = Pubkey::new_unique(); - let lessee_collateral_ta = Pubkey::new_unique(); - let lessee_leased_ta = Pubkey::new_unique(); - let keeper_collateral_ta = Pubkey::new_unique(); - let (lease, _lease_bump) = lease_pda(&lessor); - let (leased_vault, _leased_vault_bump) = leased_vault_pda(&lease); - let (collateral_vault, _collateral_vault_bump) = collateral_vault_pda(&lease); + let lessor_leased_token_account = Pubkey::new_unique(); + let lessor_collateral_token_account = Pubkey::new_unique(); + let lessee_collateral_token_account = Pubkey::new_unique(); + let lessee_leased_token_account = Pubkey::new_unique(); + let keeper_collateral_token_account = Pubkey::new_unique(); + let (lease, _lease_bump) = lease_program_derived_address(&lessor); + let (leased_vault, _leased_vault_bump) = leased_vault_program_derived_address(&lease); + let (collateral_vault, _collateral_vault_bump) = collateral_vault_program_derived_address(&lease); let scenario = Scenario { lessor, lessee, keeper, leased_mint, collateral_mint, - lessor_leased_ta, - lessor_collateral_ta, - lessee_collateral_ta, - lessee_leased_ta, - keeper_collateral_ta, + lessor_leased_token_account, + lessor_collateral_token_account, + lessee_collateral_token_account, + lessee_leased_token_account, + keeper_collateral_token_account, lease, leased_vault, collateral_vault, @@ -258,19 +258,19 @@ fn make_scenario() -> (QuasarSvm, Scenario) { // --------------------------------------------------------------------------- #[allow(clippy::too_many_arguments)] -fn create_lease_call(sc: &Scenario, lease_id: u64) -> (Instruction, Vec) { +fn create_lease_call(scenario: &Scenario, lease_id: u64) -> (Instruction, Vec) { // `init + seeds` fields self-sign via `invoke_signed` inside the // program, so only the lessor (index 0) is a true signer here. - let ix = Instruction { + let instruction = Instruction { program_id: crate::ID, accounts: vec![ - AccountMeta::new(sc.lessor, true), - AccountMeta::new_readonly(sc.leased_mint, false), - AccountMeta::new_readonly(sc.collateral_mint, false), - AccountMeta::new(sc.lessor_leased_ta, false), - AccountMeta::new(sc.lease, false), - AccountMeta::new(sc.leased_vault, false), - AccountMeta::new(sc.collateral_vault, false), + AccountMeta::new(scenario.lessor, true), + AccountMeta::new_readonly(scenario.leased_mint, false), + AccountMeta::new_readonly(scenario.collateral_mint, false), + AccountMeta::new(scenario.lessor_leased_token_account, false), + AccountMeta::new(scenario.lease, false), + AccountMeta::new(scenario.leased_vault, false), + AccountMeta::new(scenario.collateral_vault, false), AccountMeta::new_readonly(quasar_svm::solana_sdk_ids::sysvar::rent::ID, false), AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), AccountMeta::new_readonly(quasar_svm::system_program::ID, false), @@ -281,23 +281,23 @@ fn create_lease_call(sc: &Scenario, lease_id: u64) -> (Instruction, Vec REQUIRED_COLLATERAL, LEASE_FEE_PER_SECOND, DURATION_SECONDS, - MAINTENANCE_MARGIN_BPS, - LIQUIDATION_BOUNTY_BPS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, FEED_ID, ), }; let accounts = vec![ - signer(sc.lessor), - mint(sc.leased_mint, sc.lessor), - mint(sc.collateral_mint, sc.lessor), - token(sc.lessor_leased_ta, sc.leased_mint, sc.lessor, STARTING_BALANCE), - empty(sc.lease), - empty(sc.leased_vault), - empty(sc.collateral_vault), + signer(scenario.lessor), + mint(scenario.leased_mint, scenario.lessor), + mint(scenario.collateral_mint, scenario.lessor), + token(scenario.lessor_leased_token_account, scenario.leased_mint, scenario.lessor, STARTING_BALANCE), + empty(scenario.lease), + empty(scenario.leased_vault), + empty(scenario.collateral_vault), ]; - (ix, accounts) + (instruction, accounts) } // --------------------------------------------------------------------------- @@ -306,24 +306,24 @@ fn create_lease_call(sc: &Scenario, lease_id: u64) -> (Instruction, Vec #[test] fn create_lease_locks_tokens_and_lists() { - let (mut svm, sc) = make_scenario(); - let (ix, accounts) = create_lease_call(&sc, 1); - let result = svm.process_instruction(&ix, &accounts); + let (mut svm, scenario) = make_scenario(); + let (instruction, accounts) = create_lease_call(&scenario, 1); + let result = svm.process_instruction(&instruction, &accounts); assert!(result.is_ok(), "create_lease failed: {:?}", result.raw_result); // Lease created, vaults initialised. - let lease_account = result.account(&sc.lease).expect("lease PDA missing"); + let lease_account = result.account(&scenario.lease).expect("lease program-derived address missing"); assert_eq!(lease_account.owner, crate::ID); assert_eq!(read_status(&lease_account.data), LeaseStatus::Listed as u8); // Leased tokens escrowed; lessor balance dropped. - let leased_vault = result.account(&sc.leased_vault).unwrap(); + let leased_vault = result.account(&scenario.leased_vault).unwrap(); assert_eq!(read_token_amount(leased_vault), LEASED_AMOUNT); - let lessor_ta = result.account(&sc.lessor_leased_ta).unwrap(); - assert_eq!(read_token_amount(lessor_ta), STARTING_BALANCE - LEASED_AMOUNT); + let lessor_token_account = result.account(&scenario.lessor_leased_token_account).unwrap(); + assert_eq!(read_token_amount(lessor_token_account), STARTING_BALANCE - LEASED_AMOUNT); // Collateral vault exists, empty. - let collateral_vault = result.account(&sc.collateral_vault).unwrap(); + let collateral_vault = result.account(&scenario.collateral_vault).unwrap(); assert_eq!(read_token_amount(collateral_vault), 0); println!(" CREATE CU: {}", result.compute_units_consumed); @@ -333,21 +333,21 @@ fn create_lease_locks_tokens_and_lists() { /// — used to exercise the same-mint rejection path. #[allow(clippy::too_many_arguments)] fn create_lease_call_with_mints( - sc: &Scenario, + scenario: &Scenario, lease_id: u64, leased_mint: Pubkey, collateral_mint: Pubkey, ) -> (Instruction, Vec) { - let ix = Instruction { + let instruction = Instruction { program_id: crate::ID, accounts: vec![ - AccountMeta::new(sc.lessor, true), + AccountMeta::new(scenario.lessor, true), AccountMeta::new_readonly(leased_mint, false), AccountMeta::new_readonly(collateral_mint, false), - AccountMeta::new(sc.lessor_leased_ta, false), - AccountMeta::new(sc.lease, false), - AccountMeta::new(sc.leased_vault, false), - AccountMeta::new(sc.collateral_vault, false), + AccountMeta::new(scenario.lessor_leased_token_account, false), + AccountMeta::new(scenario.lease, false), + AccountMeta::new(scenario.leased_vault, false), + AccountMeta::new(scenario.collateral_vault, false), AccountMeta::new_readonly(quasar_svm::solana_sdk_ids::sysvar::rent::ID, false), AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), AccountMeta::new_readonly(quasar_svm::system_program::ID, false), @@ -358,21 +358,21 @@ fn create_lease_call_with_mints( REQUIRED_COLLATERAL, LEASE_FEE_PER_SECOND, DURATION_SECONDS, - MAINTENANCE_MARGIN_BPS, - LIQUIDATION_BOUNTY_BPS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, FEED_ID, ), }; let accounts = vec![ - signer(sc.lessor), - mint(leased_mint, sc.lessor), - mint(collateral_mint, sc.lessor), - token(sc.lessor_leased_ta, leased_mint, sc.lessor, STARTING_BALANCE), - empty(sc.lease), - empty(sc.leased_vault), - empty(sc.collateral_vault), + signer(scenario.lessor), + mint(leased_mint, scenario.lessor), + mint(collateral_mint, scenario.lessor), + token(scenario.lessor_leased_token_account, leased_mint, scenario.lessor, STARTING_BALANCE), + empty(scenario.lease), + empty(scenario.leased_vault), + empty(scenario.collateral_vault), ]; - (ix, accounts) + (instruction, accounts) } /// Pyth `PriceUpdateV2` body with only the fields liquidate actually reads @@ -422,175 +422,175 @@ fn install_price_update( }); } -fn take_lease_call(sc: &Scenario) -> (Instruction, Vec) { - let ix = Instruction { +fn take_lease_call(scenario: &Scenario) -> (Instruction, Vec) { + let instruction = Instruction { program_id: crate::ID, accounts: vec![ - AccountMeta::new(sc.lessee, true), - AccountMeta::new_readonly(sc.lessor, false), - AccountMeta::new(sc.lease, false), - AccountMeta::new_readonly(sc.leased_mint, false), - AccountMeta::new_readonly(sc.collateral_mint, false), - AccountMeta::new(sc.leased_vault, false), - AccountMeta::new(sc.collateral_vault, false), - AccountMeta::new(sc.lessee_collateral_ta, false), - AccountMeta::new(sc.lessee_leased_ta, false), + AccountMeta::new(scenario.lessee, true), + AccountMeta::new_readonly(scenario.lessor, false), + AccountMeta::new(scenario.lease, false), + AccountMeta::new_readonly(scenario.leased_mint, false), + AccountMeta::new_readonly(scenario.collateral_mint, false), + AccountMeta::new(scenario.leased_vault, false), + AccountMeta::new(scenario.collateral_vault, false), + AccountMeta::new(scenario.lessee_collateral_token_account, false), + AccountMeta::new(scenario.lessee_leased_token_account, false), AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), ], data: vec![1u8], // discriminator = take_lease }; let accounts = vec![ - signer(sc.lessee), - empty(sc.lessor), + signer(scenario.lessee), + empty(scenario.lessor), // `lease` is sourced from the SVM database, already pre-installed. - mint(sc.leased_mint, sc.lessor), - mint(sc.collateral_mint, sc.lessor), + mint(scenario.leased_mint, scenario.lessor), + mint(scenario.collateral_mint, scenario.lessor), // `leased_vault` and `collateral_vault` similarly pre-installed. token( - sc.lessee_collateral_ta, - sc.collateral_mint, - sc.lessee, + scenario.lessee_collateral_token_account, + scenario.collateral_mint, + scenario.lessee, STARTING_BALANCE, ), - token(sc.lessee_leased_ta, sc.leased_mint, sc.lessee, 0), + token(scenario.lessee_leased_token_account, scenario.leased_mint, scenario.lessee, 0), ]; - (ix, accounts) + (instruction, accounts) } -fn pay_lease_fee_call(sc: &Scenario) -> (Instruction, Vec) { - let ix = Instruction { +fn pay_lease_fee_call(scenario: &Scenario) -> (Instruction, Vec) { + let instruction = Instruction { program_id: crate::ID, accounts: vec![ - AccountMeta::new(sc.lessee, true), - AccountMeta::new_readonly(sc.lessor, false), - AccountMeta::new(sc.lease, false), - AccountMeta::new_readonly(sc.collateral_mint, false), - AccountMeta::new(sc.collateral_vault, false), - AccountMeta::new(sc.lessor_collateral_ta, false), + AccountMeta::new(scenario.lessee, true), + AccountMeta::new_readonly(scenario.lessor, false), + AccountMeta::new(scenario.lease, false), + AccountMeta::new_readonly(scenario.collateral_mint, false), + AccountMeta::new(scenario.collateral_vault, false), + AccountMeta::new(scenario.lessor_collateral_token_account, false), AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), ], data: vec![2u8], }; let accounts = vec![ - signer(sc.lessee), - empty(sc.lessor), - mint(sc.collateral_mint, sc.lessor), - token(sc.lessor_collateral_ta, sc.collateral_mint, sc.lessor, 0), + signer(scenario.lessee), + empty(scenario.lessor), + mint(scenario.collateral_mint, scenario.lessor), + token(scenario.lessor_collateral_token_account, scenario.collateral_mint, scenario.lessor, 0), ]; - (ix, accounts) + (instruction, accounts) } -fn top_up_call(sc: &Scenario, amount: u64) -> (Instruction, Vec) { +fn top_up_call(scenario: &Scenario, amount: u64) -> (Instruction, Vec) { let mut data = vec![3u8]; data.extend_from_slice(&amount.to_le_bytes()); - let ix = Instruction { + let instruction = Instruction { program_id: crate::ID, accounts: vec![ - AccountMeta::new(sc.lessee, true), - AccountMeta::new_readonly(sc.lessor, false), - AccountMeta::new(sc.lease, false), - AccountMeta::new_readonly(sc.collateral_mint, false), - AccountMeta::new(sc.collateral_vault, false), - AccountMeta::new(sc.lessee_collateral_ta, false), + AccountMeta::new(scenario.lessee, true), + AccountMeta::new_readonly(scenario.lessor, false), + AccountMeta::new(scenario.lease, false), + AccountMeta::new_readonly(scenario.collateral_mint, false), + AccountMeta::new(scenario.collateral_vault, false), + AccountMeta::new(scenario.lessee_collateral_token_account, false), AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), ], data, }; let accounts = vec![ - signer(sc.lessee), - empty(sc.lessor), - mint(sc.collateral_mint, sc.lessor), + signer(scenario.lessee), + empty(scenario.lessor), + mint(scenario.collateral_mint, scenario.lessor), ]; - (ix, accounts) + (instruction, accounts) } -fn return_lease_call(sc: &Scenario) -> (Instruction, Vec) { - let ix = Instruction { +fn return_lease_call(scenario: &Scenario) -> (Instruction, Vec) { + let instruction = Instruction { program_id: crate::ID, accounts: vec![ - AccountMeta::new(sc.lessee, true), - AccountMeta::new(sc.lessor, false), - AccountMeta::new(sc.lease, false), - AccountMeta::new_readonly(sc.leased_mint, false), - AccountMeta::new_readonly(sc.collateral_mint, false), - AccountMeta::new(sc.leased_vault, false), - AccountMeta::new(sc.collateral_vault, false), - AccountMeta::new(sc.lessee_leased_ta, false), - AccountMeta::new(sc.lessee_collateral_ta, false), - AccountMeta::new(sc.lessor_leased_ta, false), - AccountMeta::new(sc.lessor_collateral_ta, false), + AccountMeta::new(scenario.lessee, true), + AccountMeta::new(scenario.lessor, false), + AccountMeta::new(scenario.lease, false), + AccountMeta::new_readonly(scenario.leased_mint, false), + AccountMeta::new_readonly(scenario.collateral_mint, false), + AccountMeta::new(scenario.leased_vault, false), + AccountMeta::new(scenario.collateral_vault, false), + AccountMeta::new(scenario.lessee_leased_token_account, false), + AccountMeta::new(scenario.lessee_collateral_token_account, false), + AccountMeta::new(scenario.lessor_leased_token_account, false), + AccountMeta::new(scenario.lessor_collateral_token_account, false), AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), ], data: vec![4u8], }; let accounts = vec![ - signer(sc.lessee), - empty(sc.lessor), - mint(sc.leased_mint, sc.lessor), - mint(sc.collateral_mint, sc.lessor), - token(sc.lessor_collateral_ta, sc.collateral_mint, sc.lessor, 0), + signer(scenario.lessee), + empty(scenario.lessor), + mint(scenario.leased_mint, scenario.lessor), + mint(scenario.collateral_mint, scenario.lessor), + token(scenario.lessor_collateral_token_account, scenario.collateral_mint, scenario.lessor, 0), ]; - (ix, accounts) + (instruction, accounts) } -fn liquidate_call(sc: &Scenario, price_update: Pubkey) -> (Instruction, Vec) { - let ix = Instruction { +fn liquidate_call(scenario: &Scenario, price_update: Pubkey) -> (Instruction, Vec) { + let instruction = Instruction { program_id: crate::ID, accounts: vec![ - AccountMeta::new(sc.keeper, true), - AccountMeta::new(sc.lessor, false), - AccountMeta::new(sc.lease, false), - AccountMeta::new_readonly(sc.leased_mint, false), - AccountMeta::new_readonly(sc.collateral_mint, false), - AccountMeta::new(sc.leased_vault, false), - AccountMeta::new(sc.collateral_vault, false), - AccountMeta::new(sc.lessor_collateral_ta, false), - AccountMeta::new(sc.keeper_collateral_ta, false), + AccountMeta::new(scenario.keeper, true), + AccountMeta::new(scenario.lessor, false), + AccountMeta::new(scenario.lease, false), + AccountMeta::new_readonly(scenario.leased_mint, false), + AccountMeta::new_readonly(scenario.collateral_mint, false), + AccountMeta::new(scenario.leased_vault, false), + AccountMeta::new(scenario.collateral_vault, false), + AccountMeta::new(scenario.lessor_collateral_token_account, false), + AccountMeta::new(scenario.keeper_collateral_token_account, false), AccountMeta::new_readonly(price_update, false), AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), ], data: vec![5u8], }; let accounts = vec![ - signer(sc.keeper), - empty(sc.lessor), - mint(sc.leased_mint, sc.lessor), - mint(sc.collateral_mint, sc.lessor), - token(sc.lessor_collateral_ta, sc.collateral_mint, sc.lessor, 0), - token(sc.keeper_collateral_ta, sc.collateral_mint, sc.keeper, 0), + signer(scenario.keeper), + empty(scenario.lessor), + mint(scenario.leased_mint, scenario.lessor), + mint(scenario.collateral_mint, scenario.lessor), + token(scenario.lessor_collateral_token_account, scenario.collateral_mint, scenario.lessor, 0), + token(scenario.keeper_collateral_token_account, scenario.collateral_mint, scenario.keeper, 0), ]; - (ix, accounts) + (instruction, accounts) } -fn close_expired_call(sc: &Scenario) -> (Instruction, Vec) { - let ix = Instruction { +fn close_expired_call(scenario: &Scenario) -> (Instruction, Vec) { + let instruction = Instruction { program_id: crate::ID, accounts: vec![ - AccountMeta::new(sc.lessor, true), - AccountMeta::new(sc.lease, false), - AccountMeta::new_readonly(sc.leased_mint, false), - AccountMeta::new_readonly(sc.collateral_mint, false), - AccountMeta::new(sc.leased_vault, false), - AccountMeta::new(sc.collateral_vault, false), - AccountMeta::new(sc.lessor_leased_ta, false), - AccountMeta::new(sc.lessor_collateral_ta, false), + AccountMeta::new(scenario.lessor, true), + AccountMeta::new(scenario.lease, false), + AccountMeta::new_readonly(scenario.leased_mint, false), + AccountMeta::new_readonly(scenario.collateral_mint, false), + AccountMeta::new(scenario.leased_vault, false), + AccountMeta::new(scenario.collateral_vault, false), + AccountMeta::new(scenario.lessor_leased_token_account, false), + AccountMeta::new(scenario.lessor_collateral_token_account, false), AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), ], data: vec![6u8], }; let accounts = vec![ - signer(sc.lessor), - mint(sc.leased_mint, sc.lessor), - mint(sc.collateral_mint, sc.lessor), + signer(scenario.lessor), + mint(scenario.leased_mint, scenario.lessor), + mint(scenario.collateral_mint, scenario.lessor), token( - sc.lessor_leased_ta, - sc.leased_mint, - sc.lessor, + scenario.lessor_leased_token_account, + scenario.leased_mint, + scenario.lessor, STARTING_BALANCE - LEASED_AMOUNT, ), - token(sc.lessor_collateral_ta, sc.collateral_mint, sc.lessor, 0), + token(scenario.lessor_collateral_token_account, scenario.collateral_mint, scenario.lessor, 0), ]; - (ix, accounts) + (instruction, accounts) } /// After a successful `create_lease`, install the resulting vault + lease @@ -617,131 +617,131 @@ fn commit_state<'a>( #[test] fn take_lease_posts_collateral_and_delivers_tokens() { - let (mut svm, sc) = make_scenario(); + let (mut svm, scenario) = make_scenario(); // Run create_lease and commit its output (lease + both vaults). - let (create_ix, create_accounts) = create_lease_call(&sc, 2); - let create_result = svm.process_instruction(&create_ix, &create_accounts); + let (create_instruction, create_accounts) = create_lease_call(&scenario, 2); + let create_result = svm.process_instruction(&create_instruction, &create_accounts); assert!(create_result.is_ok(), "create_lease failed: {:?}", create_result.raw_result); commit_state( &mut svm, &create_result, - &[sc.lease, sc.leased_vault, sc.collateral_vault, sc.lessor_leased_ta], + &[scenario.lease, scenario.leased_vault, scenario.collateral_vault, scenario.lessor_leased_token_account], ); // Now the lessee takes it. - let (take_ix, take_accounts) = take_lease_call(&sc); - let take_result = svm.process_instruction(&take_ix, &take_accounts); + let (take_instruction, take_accounts) = take_lease_call(&scenario); + let take_result = svm.process_instruction(&take_instruction, &take_accounts); assert!(take_result.is_ok(), "take_lease failed: {:?}", take_result.raw_result); // Leased vault drained into the lessee. assert_eq!( - read_token_amount(take_result.account(&sc.leased_vault).unwrap()), + read_token_amount(take_result.account(&scenario.leased_vault).unwrap()), 0 ); assert_eq!( - read_token_amount(take_result.account(&sc.lessee_leased_ta).unwrap()), + read_token_amount(take_result.account(&scenario.lessee_leased_token_account).unwrap()), LEASED_AMOUNT ); // Collateral moved from the lessee into the collateral vault. assert_eq!( - read_token_amount(take_result.account(&sc.collateral_vault).unwrap()), + read_token_amount(take_result.account(&scenario.collateral_vault).unwrap()), REQUIRED_COLLATERAL ); assert_eq!( - read_token_amount(take_result.account(&sc.lessee_collateral_ta).unwrap()), + read_token_amount(take_result.account(&scenario.lessee_collateral_token_account).unwrap()), STARTING_BALANCE - REQUIRED_COLLATERAL ); // Lease transitioned Listed -> Active. assert_eq!( - read_status(&take_result.account(&sc.lease).unwrap().data), + read_status(&take_result.account(&scenario.lease).unwrap().data), LeaseStatus::Active as u8 ); } /// Helper: run create + take atomically and commit all resulting state so /// the next call starts from an `Active` lease. -fn make_and_take(svm: &mut QuasarSvm, sc: &Scenario) { - let (create_ix, create_accounts) = create_lease_call(sc, 1); - let create_result = svm.process_instruction(&create_ix, &create_accounts); +fn make_and_take(svm: &mut QuasarSvm, scenario: &Scenario) { + let (create_instruction, create_accounts) = create_lease_call(scenario, 1); + let create_result = svm.process_instruction(&create_instruction, &create_accounts); assert!(create_result.is_ok(), "create_lease failed: {:?}", create_result.raw_result); commit_state( svm, &create_result, - &[sc.lease, sc.leased_vault, sc.collateral_vault, sc.lessor_leased_ta], + &[scenario.lease, scenario.leased_vault, scenario.collateral_vault, scenario.lessor_leased_token_account], ); - let (take_ix, take_accounts) = take_lease_call(sc); - let take_result = svm.process_instruction(&take_ix, &take_accounts); + let (take_instruction, take_accounts) = take_lease_call(scenario); + let take_result = svm.process_instruction(&take_instruction, &take_accounts); assert!(take_result.is_ok(), "take_lease failed: {:?}", take_result.raw_result); commit_state( svm, &take_result, &[ - sc.lease, - sc.leased_vault, - sc.collateral_vault, - sc.lessee_collateral_ta, - sc.lessee_leased_ta, + scenario.lease, + scenario.leased_vault, + scenario.collateral_vault, + scenario.lessee_collateral_token_account, + scenario.lessee_leased_token_account, ], ); } #[test] fn pay_lease_fee_streams_collateral_by_elapsed_time() { - let (mut svm, sc) = make_scenario(); - make_and_take(&mut svm, &sc); + let (mut svm, scenario) = make_scenario(); + make_and_take(&mut svm, &scenario); // Advance clock by 2 minutes and pay the lease fee. let elapsed: i64 = 120; svm.warp_to_timestamp(DEFAULT_TIMESTAMP + elapsed); - let (pay_ix, pay_accounts) = pay_lease_fee_call(&sc); - let result = svm.process_instruction(&pay_ix, &pay_accounts); + let (pay_instruction, pay_accounts) = pay_lease_fee_call(&scenario); + let result = svm.process_instruction(&pay_instruction, &pay_accounts); assert!(result.is_ok(), "pay_lease_fee failed: {:?}", result.raw_result); let expected_lease_fees = (elapsed as u64) * LEASE_FEE_PER_SECOND; assert_eq!( - read_token_amount(result.account(&sc.lessor_collateral_ta).unwrap()), + read_token_amount(result.account(&scenario.lessor_collateral_token_account).unwrap()), expected_lease_fees ); assert_eq!( - read_token_amount(result.account(&sc.collateral_vault).unwrap()), + read_token_amount(result.account(&scenario.collateral_vault).unwrap()), REQUIRED_COLLATERAL - expected_lease_fees ); } #[test] fn top_up_collateral_increases_vault_balance() { - let (mut svm, sc) = make_scenario(); - make_and_take(&mut svm, &sc); + let (mut svm, scenario) = make_scenario(); + make_and_take(&mut svm, &scenario); let top_up_amount: u64 = 50_000_000; - let (ix, accounts) = top_up_call(&sc, top_up_amount); - let result = svm.process_instruction(&ix, &accounts); + let (instruction, accounts) = top_up_call(&scenario, top_up_amount); + let result = svm.process_instruction(&instruction, &accounts); assert!(result.is_ok(), "top_up failed: {:?}", result.raw_result); assert_eq!( - read_token_amount(result.account(&sc.collateral_vault).unwrap()), + read_token_amount(result.account(&scenario.collateral_vault).unwrap()), REQUIRED_COLLATERAL + top_up_amount ); // Collateral amount on the lease bumps too. assert_eq!( - read_collateral_amount(&result.account(&sc.lease).unwrap().data), + read_collateral_amount(&result.account(&scenario.lease).unwrap().data), REQUIRED_COLLATERAL + top_up_amount ); } #[test] fn return_lease_refunds_unused_collateral() { - let (mut svm, sc) = make_scenario(); - make_and_take(&mut svm, &sc); + let (mut svm, scenario) = make_scenario(); + make_and_take(&mut svm, &scenario); // Lessee returns 10 minutes in, for a 24h lease. let elapsed: i64 = 600; svm.warp_to_timestamp(DEFAULT_TIMESTAMP + elapsed); - let (ix, accounts) = return_lease_call(&sc); - let result = svm.process_instruction(&ix, &accounts); + let (instruction, accounts) = return_lease_call(&scenario); + let result = svm.process_instruction(&instruction, &accounts); assert!(result.is_ok(), "return_lease failed: {:?}", result.raw_result); let lease_fee_paid = (elapsed as u64) * LEASE_FEE_PER_SECOND; @@ -749,17 +749,17 @@ fn return_lease_refunds_unused_collateral() { // Lessor got the full leased amount back. assert_eq!( - read_token_amount(result.account(&sc.lessor_leased_ta).unwrap()), + read_token_amount(result.account(&scenario.lessor_leased_token_account).unwrap()), STARTING_BALANCE ); // Lessor received the accrued lease fees. assert_eq!( - read_token_amount(result.account(&sc.lessor_collateral_ta).unwrap()), + read_token_amount(result.account(&scenario.lessor_collateral_token_account).unwrap()), lease_fee_paid ); // Lessee got unused-time collateral back. assert_eq!( - read_token_amount(result.account(&sc.lessee_collateral_ta).unwrap()), + read_token_amount(result.account(&scenario.lessee_collateral_token_account).unwrap()), STARTING_BALANCE - REQUIRED_COLLATERAL + refund_expected ); @@ -767,69 +767,69 @@ fn return_lease_refunds_unused_collateral() { // lamports=0 / data empty. We check lamports drained rather than // .is_none(), which is stricter than needed. assert_eq!( - result.account(&sc.leased_vault).map(|a| a.lamports).unwrap_or(0), + result.account(&scenario.leased_vault).map(|a| a.lamports).unwrap_or(0), 0 ); assert_eq!( - result.account(&sc.collateral_vault).map(|a| a.lamports).unwrap_or(0), + result.account(&scenario.collateral_vault).map(|a| a.lamports).unwrap_or(0), 0 ); } #[test] fn liquidate_seizes_collateral_on_price_drop() { - let (mut svm, sc) = make_scenario(); - make_and_take(&mut svm, &sc); + let (mut svm, scenario) = make_scenario(); + make_and_take(&mut svm, &scenario); // Let 300 s of lease fees accrue so the handler settles lease fees *and* bounty // on the same vault balance. let elapsed: i64 = 300; - let now_ts = DEFAULT_TIMESTAMP + elapsed; - svm.warp_to_timestamp(now_ts); + let now_timestamp = DEFAULT_TIMESTAMP + elapsed; + svm.warp_to_timestamp(now_timestamp); // Price 4.0 with exponent 0 — debt = 400 collateral vs. 200 held, // ratio 50% ≪ 120% margin. let price_update = Pubkey::new_unique(); - install_price_update(&mut svm, price_update, FEED_ID, 4, 0, now_ts); + install_price_update(&mut svm, price_update, FEED_ID, 4, 0, now_timestamp); - let (ix, accounts) = liquidate_call(&sc, price_update); - let result = svm.process_instruction(&ix, &accounts); + let (instruction, accounts) = liquidate_call(&scenario, price_update); + let result = svm.process_instruction(&instruction, &accounts); assert!(result.is_ok(), "liquidate failed: {:?}", result.raw_result); let lease_fee_paid = (elapsed as u64) * LEASE_FEE_PER_SECOND; let remaining_after_lease_fees = REQUIRED_COLLATERAL - lease_fee_paid; - let bounty = remaining_after_lease_fees * (LIQUIDATION_BOUNTY_BPS as u64) / 10_000; + let bounty = remaining_after_lease_fees * (LIQUIDATION_BOUNTY_BASIS_POINTS as u64) / 10_000; let lessor_share = remaining_after_lease_fees - bounty; assert_eq!( - read_token_amount(result.account(&sc.lessor_collateral_ta).unwrap()), + read_token_amount(result.account(&scenario.lessor_collateral_token_account).unwrap()), lease_fee_paid + lessor_share ); assert_eq!( - read_token_amount(result.account(&sc.keeper_collateral_ta).unwrap()), + read_token_amount(result.account(&scenario.keeper_collateral_token_account).unwrap()), bounty ); assert_eq!( - result.account(&sc.leased_vault).map(|a| a.lamports).unwrap_or(0), + result.account(&scenario.leased_vault).map(|a| a.lamports).unwrap_or(0), 0 ); assert_eq!( - result.account(&sc.collateral_vault).map(|a| a.lamports).unwrap_or(0), + result.account(&scenario.collateral_vault).map(|a| a.lamports).unwrap_or(0), 0 ); } #[test] fn liquidate_rejects_healthy_position() { - let (mut svm, sc) = make_scenario(); - make_and_take(&mut svm, &sc); + let (mut svm, scenario) = make_scenario(); + make_and_take(&mut svm, &scenario); // Price 1.0 → debt = 100 vs. 200 collateral → ratio 200% ≥ 120%. let price_update = Pubkey::new_unique(); install_price_update(&mut svm, price_update, FEED_ID, 1, 0, DEFAULT_TIMESTAMP); - let (ix, accounts) = liquidate_call(&sc, price_update); - let result = svm.process_instruction(&ix, &accounts); + let (instruction, accounts) = liquidate_call(&scenario, price_update); + let result = svm.process_instruction(&instruction, &accounts); assert!( result.is_err(), "healthy liquidation must fail: {:?}", @@ -839,8 +839,8 @@ fn liquidate_rejects_healthy_position() { #[test] fn liquidate_rejects_mismatched_price_feed() { - let (mut svm, sc) = make_scenario(); - make_and_take(&mut svm, &sc); + let (mut svm, scenario) = make_scenario(); + make_and_take(&mut svm, &scenario); // Price that *would* trigger liquidation but with a foreign feed id — // the feed-pinning check must reject before the undercollateralisation @@ -849,8 +849,8 @@ fn liquidate_rejects_mismatched_price_feed() { let price_update = Pubkey::new_unique(); install_price_update(&mut svm, price_update, wrong_feed_id, 4, 0, DEFAULT_TIMESTAMP); - let (ix, accounts) = liquidate_call(&sc, price_update); - let result = svm.process_instruction(&ix, &accounts); + let (instruction, accounts) = liquidate_call(&scenario, price_update); + let result = svm.process_instruction(&instruction, &accounts); assert!( result.is_err(), "liquidate must reject foreign price feeds: {:?}", @@ -859,75 +859,75 @@ fn liquidate_rejects_mismatched_price_feed() { } #[test] -fn close_expired_reclaims_collateral_after_end_ts() { - let (mut svm, sc) = make_scenario(); - make_and_take(&mut svm, &sc); +fn close_expired_reclaims_collateral_after_end_timestamp() { + let (mut svm, scenario) = make_scenario(); + make_and_take(&mut svm, &scenario); - // Jump past end_ts. + // Jump past end_timestamp. svm.warp_to_timestamp(DEFAULT_TIMESTAMP + DURATION_SECONDS + 1); - let (ix, accounts) = close_expired_call(&sc); - let result = svm.process_instruction(&ix, &accounts); + let (instruction, accounts) = close_expired_call(&scenario); + let result = svm.process_instruction(&instruction, &accounts); assert!(result.is_ok(), "close_expired failed: {:?}", result.raw_result); // Full collateral forfeited to the lessor. assert_eq!( - read_token_amount(result.account(&sc.lessor_collateral_ta).unwrap()), + read_token_amount(result.account(&scenario.lessor_collateral_token_account).unwrap()), REQUIRED_COLLATERAL ); // Lessor's leased balance is only what remained after the initial // escrow (the lessee kept the tokens on default). assert_eq!( - read_token_amount(result.account(&sc.lessor_leased_ta).unwrap()), + read_token_amount(result.account(&scenario.lessor_leased_token_account).unwrap()), STARTING_BALANCE - LEASED_AMOUNT ); assert_eq!( - result.account(&sc.leased_vault).map(|a| a.lamports).unwrap_or(0), + result.account(&scenario.leased_vault).map(|a| a.lamports).unwrap_or(0), 0 ); assert_eq!( - result.account(&sc.collateral_vault).map(|a| a.lamports).unwrap_or(0), + result.account(&scenario.collateral_vault).map(|a| a.lamports).unwrap_or(0), 0 ); } #[test] fn close_expired_cancels_listed_lease() { - let (mut svm, sc) = make_scenario(); - let (create_ix, create_accounts) = create_lease_call(&sc, 1); - let create_result = svm.process_instruction(&create_ix, &create_accounts); + let (mut svm, scenario) = make_scenario(); + let (create_instruction, create_accounts) = create_lease_call(&scenario, 1); + let create_result = svm.process_instruction(&create_instruction, &create_accounts); assert!(create_result.is_ok(), "create_lease failed: {:?}", create_result.raw_result); commit_state( &mut svm, &create_result, - &[sc.lease, sc.leased_vault, sc.collateral_vault, sc.lessor_leased_ta], + &[scenario.lease, scenario.leased_vault, scenario.collateral_vault, scenario.lessor_leased_token_account], ); // Lessor bails while the lease is still `Listed` — allowed immediately. - let (ix, accounts) = close_expired_call(&sc); - let result = svm.process_instruction(&ix, &accounts); + let (instruction, accounts) = close_expired_call(&scenario); + let result = svm.process_instruction(&instruction, &accounts); assert!(result.is_ok(), "close_expired on Listed failed: {:?}", result.raw_result); // Lessor recovered the full leased amount. No collateral was posted. assert_eq!( - read_token_amount(result.account(&sc.lessor_leased_ta).unwrap()), + read_token_amount(result.account(&scenario.lessor_leased_token_account).unwrap()), STARTING_BALANCE ); assert_eq!( - result.account(&sc.leased_vault).map(|a| a.lamports).unwrap_or(0), + result.account(&scenario.leased_vault).map(|a| a.lamports).unwrap_or(0), 0 ); assert_eq!( - result.account(&sc.collateral_vault).map(|a| a.lamports).unwrap_or(0), + result.account(&scenario.collateral_vault).map(|a| a.lamports).unwrap_or(0), 0 ); } #[test] fn create_lease_rejects_same_mint_for_leased_and_collateral() { - let (mut svm, sc) = make_scenario(); - let (ix, accounts) = create_lease_call_with_mints(&sc, 42, sc.leased_mint, sc.leased_mint); - let result = svm.process_instruction(&ix, &accounts); + let (mut svm, scenario) = make_scenario(); + let (instruction, accounts) = create_lease_call_with_mints(&scenario, 42, scenario.leased_mint, scenario.leased_mint); + let result = svm.process_instruction(&instruction, &accounts); assert!( result.is_err(), "create_lease must reject identical mints: {:?}", From 28eea9a1d2d942915d38d5553823eb2653bcb7b3 Mon Sep 17 00:00:00 2001 From: "Edward (subagent)" Date: Tue, 28 Apr 2026 01:52:36 +0000 Subject: [PATCH 14/41] docs: reframe asset-leasing as on-chain securities lending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The program's lessee role is mechanically a short seller — borrow fungibles, sell now, rebuy cheaper, return equivalent. Physical- asset framing (leasing, gold bars) misled readers about what the product actually is. README now opens with "on-chain securities lending used primarily for short selling" and uses lender/borrower terminology alongside the lessor/lessee identifiers. Code unchanged. --- defi/asset-leasing/anchor/README.md | 182 ++++++++++++++-------------- 1 file changed, 93 insertions(+), 89 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 372a4a3ef..949c44528 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -1,8 +1,24 @@ # Asset Leasing -A fixed-term token lease on Solana, with a second-by-second lease fee -stream, a separate collateral deposit, and a Pyth-oracle-triggered -seizure path when the collateral is no longer worth enough. +**On-chain securities lending.** Long holders rent out fungible token +inventory to short sellers. Borrowers post collateral, pay a +second-by-second lending fee, and return equivalent tokens before +expiry. If the borrowed asset rallies past the maintenance margin, +keepers liquidate the position; if it falls, the borrower profits and +returns equivalent tokens cheaply. + +This is the same primitive that underpins traditional securities +lending: long inventory holders (exchange-traded funds and pension +funds in traditional finance; passive holders on-chain) earn yield on +assets they would hold anyway, and short sellers and arbitrageurs get +the borrow they need. The program is written in Anchor; a parallel +[Quasar port](#7-quasar-port) implements the same on-chain behaviour. + +The code uses `lessor` / `lessee` identifiers throughout — those names +predate the framing change and stay as-is so the source is grep-able. +The README freely uses **lender** for the lessor and **borrower** (or +**short seller**) for the lessee; they refer to the same on-chain +roles. Every instruction handler is walked through with the exact token movements it causes. If you already know what collateral, a @@ -31,74 +47,62 @@ appear. ## 1. What does this program do? -Two users, a **lessor** and a **lessee**, want to swap tokens -temporarily: - -- The lessor has some number of tokens of mint **A** (call it the - "leased mint") they would like to hand over for a fixed period of - time. -- The lessee has tokens of a different mint **B** (the "collateral - mint") they can lock up as a security deposit. +A **lessor (lender)** offers some quantity of one fungible token — +mint **A**, the "leased mint" — for a fixed term. A **lessee +(borrower / short seller)** posts collateral in a different mint +**B** — the "collateral mint" — to take delivery. The borrower will +typically sell the A tokens immediately on a market like Jupiter, then +re-acquire equivalent A tokens later to close out. Because mint A is +fungible, the borrower only has to return the same *quantity*, not the +exact units they received. The program acts as a non-custodial escrow. It: -1. Takes the lessor's A tokens and locks them in a program-owned vault - until a lessee shows up. -2. When a lessee calls `take_lease`, the program locks the lessee's B - tokens as collateral and hands the A tokens to the lessee. -3. While the lease is live, a second-by-second **lease fee stream** - pays the lessor out of the collateral vault. -4. If the price of A (measured in B) moves against the lessee far enough - that the locked collateral is no longer enough to cover the cost of - re-acquiring the leased tokens, anyone can call `liquidate` — the - collateral is seized, most of it goes to the lessor, and a small - percentage (the **liquidation bounty**) goes to whoever called it. - Such a caller is known as a **keeper** — a bot or anyone else who - watches the chain for positions that have gone underwater and earns - the bounty by cleaning them up. -5. If the lessee returns the full A amount before the deadline, they get - back whatever collateral is left after lease fees. -6. If the lessee ghosts past the deadline without returning anything, - the lessor calls `close_expired` and sweeps the collateral as - compensation. +1. Takes the lender's A tokens and locks them in a program-owned vault + until a borrower shows up. +2. When a borrower calls `take_lease`, the program locks the + borrower's B tokens as collateral and hands the A tokens to the + borrower. +3. While the loan is live, a second-by-second **lending fee stream** + pays the lender out of the collateral vault. +4. If the price of A (measured in B) rises far enough that the locked + collateral is no longer enough to cover the cost of re-acquiring + the borrowed tokens, anyone can call `liquidate` — the collateral + is seized, most of it goes to the lender, and a small percentage + (the **liquidation bounty**) goes to whoever called it. Such a + caller is known as a **keeper** — a bot or anyone else who watches + the chain for positions that have gone underwater and earns the + bounty by cleaning them up. +5. If the borrower returns the full A amount before the deadline, they + get back whatever collateral is left after lending fees. +6. If the borrower ghosts past the deadline without returning + anything, the lender calls `close_expired` and sweeps the + collateral as compensation. The trigger for step 4 is the **maintenance margin**: a ratio, -expressed in basis points (1 bp = 1/100 of a percent), of required -collateral value to debt value. `maintenance_margin_basis_points = 12_000` is -120%, meaning the collateral must stay worth at least 1.2× the leased -tokens. Drop below and the position becomes liquidatable. +expressed in basis points (1 basis point = 1/100 of a percent), of +required collateral value to debt value. +`maintenance_margin_basis_points = 12_000` is 120%, meaning the +collateral must stay worth at least 1.2× the borrowed tokens. Drop +below and the position becomes liquidatable. The program is a pair of vaults, a small piece of state that tracks -how much has been paid, and an oracle check. It is written in Anchor. - -### The tradfi picture, briefly - -Two analogies from finance for the uninitiated; the on-chain mechanics -above are the canonical description. - -- **Leasing gold bars from a bullion dealer.** The dealer hands over a - fixed amount of physical gold for a fixed period; the counterparty - pays a per-day leasing fee and posts cash collateral worth more than - the gold. If the gold price rises enough that the posted cash no - longer covers the value of the bars, the dealer can seize the cash - before the position goes further underwater. The leased tokens here - play the role of the gold; the collateral plays the role of the cash; - the oracle plays the role of a live gold price feed. +how much has been paid, and an oracle check. -- **Securities lending — borrowing stock to short.** A broker lends - shares (say, NVIDIA) to a short seller for a fee. The short seller - posts cash collateral worth more than the shares. If NVIDIA rallies, - the collateral ratio falls; if it falls far enough, the broker issues - a margin call and, if unmet, liquidates the position by buying back - the shares from the collateral. This program's `liquidate` - instruction handler is the on-chain equivalent of that forced - buy-back. +### Roles -Neither analogy is exact — real bullion leases and real securities -lending add features the program doesn't model (recall rights, rebate -rates, haircuts). +- **Lessor / lender.** Long the asset, willing to part with it + temporarily to earn the lending fee. The economic match for this + role is a passive holder — someone who would hold the asset anyway + and is happy to earn yield on idle inventory. +- **Lessee / borrower / short seller.** Pays the lending fee for the + right to sell the borrowed tokens now and buy them back later. The + payoff shape is the same as a short: profit if the borrowed asset + falls, loss (and possible liquidation) if it rises. +- **Keeper / liquidator.** Standard role — watches for + undercollateralised positions and takes the bounty for closing them. -### Worked example: leasing xNVDA against USDC +### Worked example: shorting xNVDA via the lending market Concrete numbers using assets that already trade on Solana — [xNVDA](https://www.backed.fi/) (a Backed Finance / xStocks tokenised @@ -106,9 +110,9 @@ NVIDIA share) and USDC. xNVDA has its own Pyth feed; the program takes the feed id verbatim at `create_lease`. Alice holds 100 xNVDA at ~$180 / share, ~$18 000 notional. She wants -yield without selling the underlying. +yield on inventory she would hold anyway. -Bob wants short exposure to NVIDIA without using a perp. +Bob wants short exposure to NVIDIA without using a perpetual future. Alice lists the lease (assume USDC is 6-decimal, xNVDA is also 6-decimal for round numbers): @@ -151,11 +155,11 @@ sells them on Jupiter for ~18 000 USDC at the spot price. accrued lease fee. The remaining ~22 000 USDC (minus fees paid) refunds to Bob. - Bob's profit ≈ `$18 000 − $16 000 − fees − trading costs ≈ $2 000` - minus carry — the same payoff shape as a 30-day short on NVIDIA. + minus carry. This is a 30-day short on NVIDIA, expressed on-chain. -The asymmetry: liquidation only ever fires when the *leased* asset -rallies against the collateral. A drop in the leased asset price is -purely beneficial to the lessee. The streaming lease fee is the +The asymmetry: liquidation only ever fires when the *borrowed* asset +rallies against the collateral. A drop in the borrowed asset price is +purely beneficial to the borrower. The streaming lending fee is the position's only ongoing cost in either direction. §4 walks the on-chain token flows for each path with abstract numbers @@ -680,11 +684,14 @@ closed; all three rent-exempt lamport refunds go to the lessor. ## 4. Full-lifecycle worked examples -All three use the same starting numbers so the arithmetic is easy to -follow. Both mints are 6-decimal tokens, so 1 token = 1 000 000 base -units. Throughout this section, "leased units" means base units of -the leased mint and "collateral units" means base units of the -collateral mint — they are descriptive labels, not real tickers. +These are abstract walkthroughs of the same machinery the §1 xNVDA +example uses, with round numbers chosen to make the arithmetic easy +to follow and to match the LiteSVM tests one-to-one. All paths share +the same starting parameters. Both mints are 6-decimal tokens, so +1 token = 1 000 000 base units. Throughout this section, "leased +units" means base units of the leased mint and "collateral units" +means base units of the collateral mint — they are descriptive +labels, not real tickers. The diagrams use the same convention: `[ leased]` and `[ collateral]`. @@ -802,14 +809,13 @@ Same setup. Steps 1 and 2 run identically. tokens. The collateral pays the lessor for the lost asset. The lessee has effectively bought the leased tokens at the forfeit price.) -### 4.3 Falling-price path — lessee benefits +### 4.3 Falling-price path — borrower profits Liquidation is a one-sided risk: it only ever fires when the leased asset *appreciates* against the collateral. If the leased asset -depreciates, the collateral ratio rises and the lessee's position -gets safer. Mechanically the position behaves like a short on the -leased asset — gains accrue to the lessee, the only ongoing cost is -the streaming lease fee. +depreciates, the collateral ratio rises and the borrower's position +gets safer. The streaming lending fee is the position's only ongoing +cost. Same setup. Steps 1 and 2 run identically. @@ -844,18 +850,16 @@ Same setup. Steps 1 and 2 run identically. - Lessor: 1 000 000 000 leased units (full return), 6 000 collateral units in lease fees. -- Lessee: 100 000 000 leased units received → bought 100 leased tokens - back at the lower price → returned them. Their net cost is the - lease fee (6 000 collateral units) plus whatever - they paid on the open market for the replacement leased tokens; - their gain is the difference between what they originally received - the leased tokens at versus what they paid to re-acquire them. - -This is the same payoff shape as a short on the leased asset: the -lessee profits from price drops and pays a small carry (the lease -fee) for the duration. Only adverse moves trigger liquidation, and -the lessee can defend a borderline position with `top_up_collateral` -or close it early via `return_lease`. +- Lessee: received 100 000 000 leased units, sold them at the + original price, bought 100 leased tokens back at the lower price, + returned them. Net cost is the lending fee (6 000 collateral units) + plus whatever they paid on the open market for the replacement + tokens; gain is the difference between the original sale price and + the buy-back price. The standard short payoff. + +The borrower can defend a borderline position with +`top_up_collateral` or close it early via `return_lease`. Only +adverse price moves trigger liquidation. ### 4.4 Default / expiry path — `close_expired` on an `Active` lease From cde6b0d52a011bb0ce596dfc752702ab4c928b53 Mon Sep 17 00:00:00 2001 From: "Edward (mikemaccana-edwardbot)" Date: Tue, 28 Apr 2026 02:00:56 +0000 Subject: [PATCH 15/41] =?UTF-8?q?docs:=20"on-chain"=20=E2=86=92=20"onchain?= =?UTF-8?q?"=20throughout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One word, like "online". Per Solana Foundation and US government style. --- defi/asset-leasing/anchor/README.md | 14 +++++++------- .../anchor/programs/asset-leasing/Cargo.toml | 2 +- .../anchor/programs/asset-leasing/src/constants.rs | 2 +- defi/asset-leasing/quasar/Cargo.toml | 2 +- defi/asset-leasing/quasar/src/constants.rs | 2 +- defi/asset-leasing/quasar/src/lib.rs | 2 +- defi/asset-leasing/quasar/src/state.rs | 2 +- defi/asset-leasing/quasar/src/tests.rs | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 949c44528..716e3769e 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -1,6 +1,6 @@ # Asset Leasing -**On-chain securities lending.** Long holders rent out fungible token +**Onchain securities lending.** Long holders rent out fungible token inventory to short sellers. Borrowers post collateral, pay a second-by-second lending fee, and return equivalent tokens before expiry. If the borrowed asset rallies past the maintenance margin, @@ -9,15 +9,15 @@ returns equivalent tokens cheaply. This is the same primitive that underpins traditional securities lending: long inventory holders (exchange-traded funds and pension -funds in traditional finance; passive holders on-chain) earn yield on +funds in traditional finance; passive holders onchain) earn yield on assets they would hold anyway, and short sellers and arbitrageurs get the borrow they need. The program is written in Anchor; a parallel -[Quasar port](#7-quasar-port) implements the same on-chain behaviour. +[Quasar port](#7-quasar-port) implements the same onchain behaviour. The code uses `lessor` / `lessee` identifiers throughout — those names predate the framing change and stay as-is so the source is grep-able. The README freely uses **lender** for the lessor and **borrower** (or -**short seller**) for the lessee; they refer to the same on-chain +**short seller**) for the lessee; they refer to the same onchain roles. Every instruction handler is walked through with the exact token @@ -155,14 +155,14 @@ sells them on Jupiter for ~18 000 USDC at the spot price. accrued lease fee. The remaining ~22 000 USDC (minus fees paid) refunds to Bob. - Bob's profit ≈ `$18 000 − $16 000 − fees − trading costs ≈ $2 000` - minus carry. This is a 30-day short on NVIDIA, expressed on-chain. + minus carry. This is a 30-day short on NVIDIA, expressed onchain. The asymmetry: liquidation only ever fires when the *borrowed* asset rallies against the collateral. A drop in the borrowed asset price is purely beneficial to the borrower. The streaming lending fee is the position's only ongoing cost in either direction. -§4 walks the on-chain token flows for each path with abstract numbers +§4 walks the onchain token flows for each path with abstract numbers that match the LiteSVM tests; the example above is the same machinery applied to a real asset pair. @@ -1100,7 +1100,7 @@ size, or simply want fewer layers between your code and the runtime. The port implements the same seven instruction handlers, the same `Lease` state account, the same program-derived address seed conventions, and produces the -same on-chain behaviour for every happy-path and adversarial test in +same onchain behaviour for every happy-path and adversarial test in this README. ### Building and testing diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml b/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml index 35e76bfad..61c6b4293 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml +++ b/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml @@ -33,7 +33,7 @@ anchor-spl = "1.0.0" # oracles/pyth/anchor example is flagged "not building" for the same reason). # Instead we parse the fixed layout of the Pyth Receiver `PriceUpdateV2` # account by hand in `instructions/liquidate.rs`, matching the published -# on-chain schema. +# onchain schema. [dev-dependencies] # Match the test stack used by tokens/escrow and tokens/token-fundraiser so diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs index fc86ae1c0..a4bc88d1c 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs @@ -23,6 +23,6 @@ pub const MAX_MAINTENANCE_MARGIN_BASIS_POINTS: u16 = 50_000; pub const MAX_LIQUIDATION_BOUNTY_BASIS_POINTS: u16 = 2_000; /// A Pyth price update is considered stale if its `publish_time` is older -/// than this many seconds versus the current on-chain clock. 60 s matches the +/// than this many seconds versus the current onchain clock. 60 s matches the /// default staleness window used in the Pyth SDK docs. pub const PYTH_MAX_AGE_SECONDS: u64 = 60; diff --git a/defi/asset-leasing/quasar/Cargo.toml b/defi/asset-leasing/quasar/Cargo.toml index 79ad3d9e4..79d44c426 100644 --- a/defi/asset-leasing/quasar/Cargo.toml +++ b/defi/asset-leasing/quasar/Cargo.toml @@ -15,7 +15,7 @@ check-cfg = [ ] [lib] -# `cdylib` for the on-chain .so; `lib` so `cargo test` can link the Rust +# `cdylib` for the onchain .so; `lib` so `cargo test` can link the Rust # code as a regular library and exercise handlers against QuasarSvm. crate-type = ["cdylib", "lib"] diff --git a/defi/asset-leasing/quasar/src/constants.rs b/defi/asset-leasing/quasar/src/constants.rs index 36bb189b7..c4204af8e 100644 --- a/defi/asset-leasing/quasar/src/constants.rs +++ b/defi/asset-leasing/quasar/src/constants.rs @@ -23,6 +23,6 @@ pub const MAX_MAINTENANCE_MARGIN_BASIS_POINTS: u16 = 50_000; pub const MAX_LIQUIDATION_BOUNTY_BASIS_POINTS: u16 = 2_000; /// A Pyth price update is considered stale if its `publish_time` is older -/// than this many seconds versus the current on-chain clock. 60 s matches +/// than this many seconds versus the current onchain clock. 60 s matches /// the default staleness window used in the Pyth SDK docs. pub const PYTH_MAX_AGE_SECONDS: u64 = 60; diff --git a/defi/asset-leasing/quasar/src/lib.rs b/defi/asset-leasing/quasar/src/lib.rs index acff28198..ac4714852 100644 --- a/defi/asset-leasing/quasar/src/lib.rs +++ b/defi/asset-leasing/quasar/src/lib.rs @@ -12,7 +12,7 @@ use instructions::*; mod tests; // Same program id as the Anchor version so off-chain tooling that derives -// program-derived addresses or looks up the program on-chain works against both binaries +// program-derived addresses or looks up the program onchain works against both binaries // interchangeably. declare_id!("Lease11111111111111111111111111111111111111"); diff --git a/defi/asset-leasing/quasar/src/state.rs b/defi/asset-leasing/quasar/src/state.rs index cac02347d..49f148fad 100644 --- a/defi/asset-leasing/quasar/src/state.rs +++ b/defi/asset-leasing/quasar/src/state.rs @@ -32,7 +32,7 @@ impl LeaseStatus { /// /// Field order mirrors the Anchor version; integers are promoted to their /// `PodXX` counterparts by the `#[account]` macro so the struct stays -/// alignment-1 and the on-chain bytes match Anchor's little-endian layout +/// alignment-1 and the onchain bytes match Anchor's little-endian layout /// (after the one-byte Quasar discriminator replaces Anchor's 8-byte /// sha256 prefix). #[account(discriminator = 1)] diff --git a/defi/asset-leasing/quasar/src/tests.rs b/defi/asset-leasing/quasar/src/tests.rs index 4d9225ffc..3b3e560be 100644 --- a/defi/asset-leasing/quasar/src/tests.rs +++ b/defi/asset-leasing/quasar/src/tests.rs @@ -595,7 +595,7 @@ fn close_expired_call(scenario: &Scenario) -> (Instruction, Vec) { /// After a successful `create_lease`, install the resulting vault + lease /// state in the SVM database so the next handler call has something to -/// read from. Copies the authentic on-chain bytes (discriminator, token +/// read from. Copies the authentic onchain bytes (discriminator, token /// amounts, lease fields) straight out of the previous execution result. fn commit_state<'a>( svm: &mut QuasarSvm, From 62fac5f7fa438c961723442e53b9a36c9739a55a Mon Sep 17 00:00:00 2001 From: mikemaccana-edwardbot Date: Tue, 28 Apr 2026 20:09:14 +0000 Subject: [PATCH 16/41] defi/asset-leasing: rename roles to holder/short_seller; rewrite README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHY - Mike's terminology rule: one canonical name per concept, used consistently across code, tests, and docs. The old README mixed "lessor", "lender", "long holder", "lessee", "borrower", and "short seller" for the same two parties; the code used yet another pair (lessor/lessee) that the README excused as a legacy artefact. That's three names per role for readers to keep straight, which is two too many. - Mike's writing rules (no abbreviations, no ambiguous "it"/"this", no "TradFi-vs-onchain" framing for things that exist onchain too, no "skip ahead" suggestions, no inline glossary when Solana docs already define the terms). - "Securities lending" framing is wrong: SOL is not legally a security, this program isn't restricted to tokenised securities, and the mechanics work for any directional token loan. WHAT Code + tests: - Renamed `lessor` -> `holder` and `lessee` -> `short_seller` across every Rust source file, the test file, and Cargo.toml. Includes struct fields (Lease.holder, Lease.short_seller), account context fields (e.g. holder_leased_account, short_seller_collateral_account), local variables, function parameters, comments, and doc strings. - Updated PDA seed bytes to match: b"lessor" -> b"holder", b"lessee" -> b"short_seller". This breaks address compatibility with the previous build, which is fine — the program is not deployed. - Cleaned up a couple of incidental abbreviations Mike's coding style forbids (denom -> denominator in liquidate.rs, liq_ix -> liquidate_instruction in tests, "8 disc" comment -> "8 discriminator"). - All 11 LiteSVM integration tests still pass; `anchor build` produces a clean .so. README: - Used "holder" and "short seller" exclusively; dropped lessor / lessee / lender / borrower / long holder framings entirely. - Replaced the ambiguous "if it falls" sentence with explicit antecedents ("if the asset falls, the short seller profits"). - Reframed the TradFi parenthetical: the entities listed (ETFs, pension funds, passive allocators) exist both in TradFi and onchain, so dropped the "vs onchain" split. - Removed the "you can skip straight to" suggestion. Background sections are useful for everyone. - Replaced "onchain securities lending" with "directional token lending" in the lede; SOL isn't a security, and the program works for any directional loan. - Inline-linked Solana terminology (Anchor, instruction handler, program-derived address, associated token account, cross-program invocation) to https://solana.com/docs/terminology on first occurrence. No glossary footer note. - Removed the paragraph that excused the lessor/lessee identifiers as legacy — those identifiers no longer exist. - Misc consistency pass: "instruction handler" not "instruction" for the function/code, "onchain" one word everywhere, kept "TradFi" only where the analogy is to actual TradFi (securities lending). --- defi/asset-leasing/anchor/README.md | 672 +++++++++--------- .../anchor/programs/asset-leasing/Cargo.toml | 2 +- .../programs/asset-leasing/src/constants.rs | 10 +- .../src/instructions/close_expired.rs | 48 +- .../src/instructions/create_lease.rs | 30 +- .../src/instructions/liquidate.rs | 40 +- .../src/instructions/pay_lease_fee.rs | 18 +- .../src/instructions/return_lease.rs | 64 +- .../src/instructions/take_lease.rs | 34 +- .../src/instructions/top_up_collateral.rs | 18 +- .../anchor/programs/asset-leasing/src/lib.rs | 18 +- .../programs/asset-leasing/src/state/lease.rs | 22 +- .../asset-leasing/tests/test_asset_leasing.rs | 272 +++---- 13 files changed, 625 insertions(+), 623 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 716e3769e..b62b7a20e 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -1,34 +1,25 @@ # Asset Leasing -**Onchain securities lending.** Long holders rent out fungible token -inventory to short sellers. Borrowers post collateral, pay a +**Directional token lending.** Holders rent out fungible token +inventory to short sellers. Short sellers post collateral, pay a second-by-second lending fee, and return equivalent tokens before expiry. If the borrowed asset rallies past the maintenance margin, -keepers liquidate the position; if it falls, the borrower profits and -returns equivalent tokens cheaply. +keepers liquidate the position; if the asset falls, the short seller +profits and returns equivalent tokens cheaply. This is the same primitive that underpins traditional securities -lending: long inventory holders (exchange-traded funds and pension -funds in traditional finance; passive holders onchain) earn yield on -assets they would hold anyway, and short sellers and arbitrageurs get -the borrow they need. The program is written in Anchor; a parallel +lending in TradFi: holders earn yield on inventory they would hold +anyway (think exchange-traded funds, pension funds, or any passive +allocator), and short sellers and arbitrageurs get the borrow they +need. The program is written in +[Anchor](https://solana.com/docs/terminology); a parallel [Quasar port](#7-quasar-port) implements the same onchain behaviour. -The code uses `lessor` / `lessee` identifiers throughout — those names -predate the framing change and stay as-is so the source is grep-able. -The README freely uses **lender** for the lessor and **borrower** (or -**short seller**) for the lessee; they refer to the same onchain -roles. - -Every instruction handler is walked through with the exact token -movements it causes. If you already know what collateral, a -maintenance margin and an oracle are, you can skip straight to -[Accounts and program-derived addresses](#2-accounts-and-program-derived-addresses) or -[Instruction handler lifecycle walkthrough](#3-instruction-handler-lifecycle-walkthrough). - -Solana terminology is defined at https://solana.com/docs/terminology. -Terms specific to this program are explained inline when they first -appear. +Every [instruction handler](https://solana.com/docs/terminology) is +walked through with the exact token movements it causes. The +background sections below define the financial concepts (collateral, +maintenance margin, oracles) and the onchain wiring before any code +walks happen. --- @@ -47,36 +38,37 @@ appear. ## 1. What does this program do? -A **lessor (lender)** offers some quantity of one fungible token — -mint **A**, the "leased mint" — for a fixed term. A **lessee -(borrower / short seller)** posts collateral in a different mint -**B** — the "collateral mint" — to take delivery. The borrower will -typically sell the A tokens immediately on a market like Jupiter, then -re-acquire equivalent A tokens later to close out. Because mint A is -fungible, the borrower only has to return the same *quantity*, not the -exact units they received. +A **holder** offers some quantity of one fungible token — mint **A**, +the "leased mint" — for a fixed term. A **short seller** posts +collateral in a different mint **B** — the "collateral mint" — to +take delivery. The short seller will typically sell the A tokens +immediately on a market like Jupiter, then re-acquire equivalent A +tokens later to close out. Because mint A is fungible, the short +seller only has to return the same *quantity*, not the exact units +they received. The program acts as a non-custodial escrow. It: -1. Takes the lender's A tokens and locks them in a program-owned vault - until a borrower shows up. -2. When a borrower calls `take_lease`, the program locks the - borrower's B tokens as collateral and hands the A tokens to the - borrower. +1. Takes the holder's A tokens and locks them in a program-owned + vault until a short seller shows up. +2. When a short seller calls `take_lease`, the program locks the + short seller's B tokens as collateral and hands the A tokens to + the short seller. 3. While the loan is live, a second-by-second **lending fee stream** - pays the lender out of the collateral vault. + pays the holder out of the collateral vault. 4. If the price of A (measured in B) rises far enough that the locked collateral is no longer enough to cover the cost of re-acquiring the borrowed tokens, anyone can call `liquidate` — the collateral - is seized, most of it goes to the lender, and a small percentage - (the **liquidation bounty**) goes to whoever called it. Such a - caller is known as a **keeper** — a bot or anyone else who watches - the chain for positions that have gone underwater and earns the - bounty by cleaning them up. -5. If the borrower returns the full A amount before the deadline, they - get back whatever collateral is left after lending fees. -6. If the borrower ghosts past the deadline without returning - anything, the lender calls `close_expired` and sweeps the + is seized, most of it goes to the holder, and a small percentage + (the **liquidation bounty**) goes to whoever called `liquidate`. + Such a caller is known as a **keeper** — a bot or anyone else who + watches the chain for positions that have gone underwater and + earns the bounty by cleaning them up. +5. If the short seller returns the full A amount before the deadline, + the short seller gets back whatever collateral is left after + lending fees. +6. If the short seller ghosts past the deadline without returning + anything, the holder calls `close_expired` and sweeps the collateral as compensation. The trigger for step 4 is the **maintenance margin**: a ratio, @@ -91,14 +83,14 @@ how much has been paid, and an oracle check. ### Roles -- **Lessor / lender.** Long the asset, willing to part with it +- **Holder.** Long the asset, willing to part with the asset temporarily to earn the lending fee. The economic match for this - role is a passive holder — someone who would hold the asset anyway - and is happy to earn yield on idle inventory. -- **Lessee / borrower / short seller.** Pays the lending fee for the - right to sell the borrowed tokens now and buy them back later. The - payoff shape is the same as a short: profit if the borrowed asset - falls, loss (and possible liquidation) if it rises. + role is a passive allocator — someone who would hold the asset + anyway and is happy to earn yield on idle inventory. +- **Short seller.** Pays the lending fee for the right to sell the + borrowed tokens now and buy them back later. The payoff shape is + the same as a short: profit if the borrowed asset falls, loss (and + possible liquidation) if the asset rises. - **Keeper / liquidator.** Standard role — watches for undercollateralised positions and takes the bounty for closing them. @@ -128,7 +120,7 @@ Alice lists the lease (assume USDC is 6-decimal, xNVDA is also | `feed_id` | Pyth xNVDA/USD feed id | ([Pyth feed registry](https://www.pyth.network/price-feeds)) | Bob calls `take_lease`, posts 22 000 USDC, receives 100 xNVDA, and -sells them on Jupiter for ~18 000 USDC at the spot price. +sells the 100 xNVDA on Jupiter for ~18 000 USDC at the spot price. #### If NVIDIA rallies to $200 @@ -140,7 +132,7 @@ sells them on Jupiter for ~18 000 USDC at the spot price. already streamed out as lease fees (Bob's incentive to keep paying was to keep the position alive); of what's left, 1% goes to the keeper as the bounty (~220 USDC), the rest to Alice. -- Bob can avoid this by: +- Bob can avoid liquidation by: - Calling `top_up_collateral` to push the ratio back above 110%, or - Buying 100 xNVDA on the open market and calling `return_lease` to close out cleanly. @@ -159,8 +151,8 @@ sells them on Jupiter for ~18 000 USDC at the spot price. The asymmetry: liquidation only ever fires when the *borrowed* asset rallies against the collateral. A drop in the borrowed asset price is -purely beneficial to the borrower. The streaming lending fee is the -position's only ongoing cost in either direction. +purely beneficial to the short seller. The streaming lending fee is +the position's only ongoing cost in either direction. §4 walks the onchain token flows for each path with abstract numbers that match the LiteSVM tests; the example above is the same machinery @@ -180,34 +172,35 @@ applied to a real asset pair. ## 2. Accounts and program-derived addresses Every call to the program touches some subset of these accounts. The -three program-derived addresses are created on `create_lease` and destroyed on `return_lease` -/ `liquidate` / `close_expired`. +three [program-derived addresses](https://solana.com/docs/terminology) +are created on `create_lease` and destroyed on `return_lease` / +`liquidate` / `close_expired`. ### State / data accounts | Account | program-derived address? | Seeds | Kind | Authority | Holds | |---|---|---|---|---|---| -| `Lease` | yes | `["lease", lessor, lease_id]` | data | program | all the lease parameters and current lifecycle state (see below) | +| `Lease` | yes | `["lease", holder, lease_id]` | data | program | all the lease parameters and current lifecycle state (see below) | ### Token vaults | Account | program-derived address? | Seeds | Kind | Authority | Holds | |---|---|---|---|---|---| -| `leased_vault` | yes | `["leased_vault", lease]` | token account | itself (program-derived address-signed) | `leased_amount` while `Listed`; 0 while `Active` (lessee has the tokens); full amount again briefly inside `return_lease` | +| `leased_vault` | yes | `["leased_vault", lease]` | token account | itself (program-derived address-signed) | `leased_amount` while `Listed`; 0 while `Active` (short seller has the tokens); full amount again briefly inside `return_lease` | | `collateral_vault` | yes | `["collateral_vault", lease]` | token account | itself (program-derived address-signed) | 0 while `Listed`; `collateral_amount` while `Active`, decreasing as lease fee streams out and increasing on `top_up_collateral` | ### User accounts passed in | Account | Owner | Purpose | |---|---|---| -| `lessor` wallet | user | `create_lease` signer, receives the lease fee and final recovery | -| `lessee` wallet | user | `take_lease` / `top_up_collateral` / `return_lease` signer | +| `holder` wallet | user | `create_lease` signer, receives the lease fee and final recovery | +| `short_seller` wallet | user | `take_lease` / `top_up_collateral` / `return_lease` signer | | `keeper` wallet | user | `liquidate` signer, receives the bounty | -| `payer` wallet | user | `pay_lease_fee` signer (can be anyone, not just the lessee) | -| `lessor_leased_account` | token account | lessor's associated token account for the leased mint; source on `create_lease`, destination on `return_lease` / `close_expired` | -| `lessor_collateral_account` | token account | lessor's associated token account for the collateral mint; destination for the lease fee and liquidation proceeds | -| `lessee_leased_account` | token account | lessee's associated token account for the leased mint; destination on `take_lease`, source on `return_lease` | -| `lessee_collateral_account` | token account | lessee's associated token account for the collateral mint; source on `take_lease` / `top_up_collateral`, destination for collateral refund on `return_lease` | +| `payer` wallet | user | `pay_lease_fee` signer (can be anyone, not just the short seller) | +| `holder_leased_account` | token account | holder's [associated token account](https://solana.com/docs/terminology) for the leased mint; source on `create_lease`, destination on `return_lease` / `close_expired` | +| `holder_collateral_account` | token account | holder's associated token account for the collateral mint; destination for the lease fee and liquidation proceeds | +| `short_seller_leased_account` | token account | short seller's associated token account for the leased mint; destination on `take_lease`, source on `return_lease` | +| `short_seller_collateral_account` | token account | short seller's associated token account for the collateral mint; source on `take_lease` / `top_up_collateral`, destination for collateral refund on `return_lease` | | `keeper_collateral_account` | token account | keeper's associated token account for the collateral mint; receives the liquidation bounty | | `price_update` | Pyth Receiver program | `PriceUpdateV2` account for the feed the lease is pinned to | @@ -217,16 +210,16 @@ From [`state/lease.rs`](programs/asset-leasing/src/state/lease.rs): ```rust pub struct Lease { - pub lease_id: u64, // caller-supplied id so one lessor can run many leases - pub lessor: Pubkey, // who listed it, gets paid the lease fee - pub lessee: Pubkey, // who took it; Pubkey::default() while Listed + pub lease_id: u64, // caller-supplied id so one holder can run many leases + pub holder: Pubkey, // who listed it, gets paid the lease fee + pub short_seller: Pubkey, // who took the lease; Pubkey::default() while Listed pub leased_mint: Pubkey, pub leased_amount: u64, // locked at creation, unchanging pub collateral_mint: Pubkey, pub collateral_amount: u64, // increases on top_up, decreases as lease fees pay out - pub required_collateral_amount: u64, // what the lessee must post on take_lease + pub required_collateral_amount: u64, // what the short seller must post on take_lease pub lease_fee_per_second: u64, // denominated in collateral units pub duration_seconds: i64, @@ -255,7 +248,7 @@ pub struct Lease { (no lease) -> | Listed | +---------------+ | | - take_lease | | close_expired (lessor cancels) + take_lease | | close_expired (holder cancels) v v +---------------+ +--------+ | Active | ----> | Closed | @@ -271,8 +264,8 @@ pub struct Lease { The `Closed` and `Liquidated` states are not directly observable onchain: all three of `return_lease`, `liquidate` and `close_expired` -close the `Lease` account in the same transaction (`close = lessor`), -returning the rent-exempt lamports to the lessor. The in-memory +close the `Lease` account in the same transaction (`close = holder`), +returning the rent-exempt lamports to the holder. The in-memory `status` field is set *before* the close so the transaction logs record the terminal state, but the account disappears at the end. @@ -280,23 +273,23 @@ record the terminal state, but the account disappears at the end. ## 3. Instruction handler lifecycle walkthrough -An *instruction* on Solana is the input sent in a transaction — a +An instruction on Solana is the input sent in a transaction — a program id, a list of accounts, and a byte payload. The Rust function -that runs when one arrives is the *instruction handler*. This program -has seven instruction handlers. The natural order a user encounters -them — the order below — is: +that runs when an instruction arrives is the *instruction handler*. +This program has seven instruction handlers. The natural order a +user encounters them — the order below — is: -1. `create_lease` (lessor) -2. `take_lease` (lessee) +1. `create_lease` (holder) +2. `take_lease` (short seller) 3. `pay_lease_fee` (anyone) -4. `top_up_collateral` (lessee) -5. `return_lease` (lessee) — **happy path** +4. `top_up_collateral` (short seller) +5. `return_lease` (short seller) — **happy path** 6. `liquidate` (keeper) — **adversarial path** -7. `close_expired` (lessor) — **default / cancel path** +7. `close_expired` (holder) — **default / cancel path** -For each, the shape is the same: who signs, what accounts go in, which -program-derived addresses get created or closed, which tokens move, what state changes, what -checks the program runs. +For each, the shape is the same: who signs, what accounts go in, +which program-derived addresses get created or closed, which tokens +move, what state changes, what checks the program runs. Token-flow diagrams use the following shorthand: @@ -306,10 +299,10 @@ Token-flow diagrams use the following shorthand: ### 3.1 `create_lease` -**Who calls it:** the lessor. They want to offer some number of leased -tokens for a fixed term against collateral of a different mint. +**Who calls it:** the holder. The holder wants to offer some number of +leased tokens for a fixed term against collateral of a different mint. -**Signers:** `lessor`. +**Signers:** `holder`. **Parameters:** @@ -329,9 +322,9 @@ pub fn create_lease( **Accounts in:** -- `lessor` (signer, mut — pays account rent) +- `holder` (signer, mut — pays account rent) - `leased_mint`, `collateral_mint` (read-only) -- `lessor_leased_account` (mut, lessor's associated token account for the leased mint — source) +- `holder_leased_account` (mut, holder's associated token account for the leased mint — source) - `lease` (program-derived address, **init**) — created here - `leased_vault` (program-derived address, **init**, token account) — created here - `collateral_vault` (program-derived address, **init**, token account) — created here @@ -339,7 +332,7 @@ pub fn create_lease( **program-derived addresses created:** -- `lease` with seeds `[b"lease", lessor, lease_id.to_le_bytes()]` +- `lease` with seeds `[b"lease", holder, lease_id.to_le_bytes()]` - `leased_vault` with seeds `[b"leased_vault", lease]`, authority = itself - `collateral_vault` with seeds `[b"collateral_vault", lease]`, authority = itself @@ -356,63 +349,64 @@ pub fn create_lease( **Token movements:** ``` - lessor_leased_account --[leased_amount of leased_mint]--> leased_vault program-derived address + holder_leased_account --[leased_amount of leased_mint]--> leased_vault program-derived address ``` **State changes:** -- New `Lease` account written with `status = Listed`, `lessee = +- New `Lease` account written with `status = Listed`, `short_seller = Pubkey::default()`, `collateral_amount = 0`, `start_timestamp = 0`, - `end_timestamp = 0`, `last_paid_timestamp = 0`, and the given parameters - including `feed_id`. All three bumps stored. + `end_timestamp = 0`, `last_paid_timestamp = 0`, and the given + parameters including `feed_id`. All three bumps stored. -**Why lock the leased tokens up-front rather than on `take_lease`?** So a -lessee who calls `take_lease` cannot possibly fail because the lessor -doesn't have the tokens any more — the atomicity guarantee is -transferred to the program-derived address the moment the lease is listed. +**Why lock the leased tokens up-front rather than on `take_lease`?** +So a short seller who calls `take_lease` cannot possibly fail because +the holder doesn't have the tokens any more — the atomicity guarantee +is transferred to the program-derived address the moment the lease is +listed. ### 3.2 `take_lease` -**Who calls it:** the lessee. They have seen the `Lease` account on -chain (somehow — an indexer, a direct lookup, whatever) and want to -take delivery. +**Who calls it:** the short seller. The short seller has seen the +`Lease` account onchain (somehow — an indexer, a direct lookup, +whatever) and wants to take delivery. -**Signers:** `lessee`. +**Signers:** `short_seller`. **Accounts in:** -- `lessee` (signer, mut) -- `lessor` (UncheckedAccount — read for program-derived address seed derivation only, no - signature required) -- `lease` (mut, `has_one = lessor`, `has_one = leased_mint`, +- `short_seller` (signer, mut) +- `holder` (UncheckedAccount — read for program-derived address seed + derivation only, no signature required) +- `lease` (mut, `has_one = holder`, `has_one = leased_mint`, `has_one = collateral_mint`, must be `Listed`) - `leased_mint`, `collateral_mint` - `leased_vault`, `collateral_vault` (both mut, both program-derived address-derived) -- `lessee_collateral_account` (mut, lessee's associated token account — source) -- `lessee_leased_account` (mut, **init_if_needed** — destination) +- `short_seller_collateral_account` (mut, short seller's associated token account — source) +- `short_seller_leased_account` (mut, **init_if_needed** — destination) - `token_program`, `associated_token_program`, `system_program` **Checks:** - `lease.status == Listed` → `InvalidLeaseStatus` -- `lease.lessor == lessor.key()` (Anchor `has_one`) +- `lease.holder == holder.key()` (Anchor `has_one`) - `lease.leased_mint == leased_mint.key()` (Anchor `has_one`) - `lease.collateral_mint == collateral_mint.key()` (Anchor `has_one`) **Token movements (in order):** ``` - lessee_collateral_account --[required_collateral_amount of collateral_mint]--> collateral_vault program-derived address - leased_vault program-derived address --[leased_amount of leased_mint]-----------------> lessee_leased_account + short_seller_collateral_account --[required_collateral_amount of collateral_mint]--> collateral_vault program-derived address + leased_vault program-derived address --[leased_amount of leased_mint]----------> short_seller_leased_account ``` Collateral is deposited *first* so if the leased-token transfer fails -for any reason the whole transaction reverts and the lessee gets their -collateral back. +for any reason the whole transaction reverts and the short seller +gets their collateral back. **State changes:** -- `lease.lessee = lessee.key()` +- `lease.short_seller = short_seller.key()` - `lease.collateral_amount = required_collateral_amount` - `lease.start_timestamp = now` - `lease.end_timestamp = now + duration_seconds` (checked add, errors on overflow) @@ -421,19 +415,20 @@ collateral back. ### 3.3 `pay_lease_fee` -**Who calls it:** anyone. The lessee's incentive is obvious (keep the -lease from going underwater); a keeper bot may also push a lease fee payment before a -liquidation check so healthy leases stay healthy. +**Who calls it:** anyone. The short seller's incentive is obvious +(keep the lease from going underwater); a keeper bot may also push a +lease fee payment before a liquidation check so healthy leases stay +healthy. **Signers:** `payer` (any signer). **Accounts in:** -- `payer` (signer, mut — pays for `init_if_needed` of the lessor associated token account) -- `lessor` (UncheckedAccount, read-only — used for `has_one` check) +- `payer` (signer, mut — pays for `init_if_needed` of the holder associated token account) +- `holder` (UncheckedAccount, read-only — used for `has_one` check) - `lease` (mut, must be `Active`) - `collateral_mint`, `collateral_vault` -- `lessor_collateral_account` (mut, **init_if_needed**) +- `holder_collateral_account` (mut, **init_if_needed**) - `token_program`, `associated_token_program`, `system_program` **Lease fee math:** @@ -450,14 +445,14 @@ pub fn compute_lease_fee_due(lease: &Lease, now: i64) -> Result { } ``` -Lease fees do not accrue past `end_timestamp`. Past the deadline the lessee is -either returning the tokens (via `return_lease`), being liquidated, or -defaulting — no more lease fees are owed. +Lease fees do not accrue past `end_timestamp`. Past the deadline the +short seller is either returning the tokens (via `return_lease`), +being liquidated, or defaulting — no more lease fees are owed. **Token movements:** ``` - collateral_vault program-derived address --[min(lease_fee_due, collateral_amount) of collateral_mint]--> lessor_collateral_account + collateral_vault program-derived address --[min(lease_fee_due, collateral_amount) of collateral_mint]--> holder_collateral_account ``` If the vault does not have enough collateral to cover the full @@ -472,19 +467,19 @@ clean up. ### 3.4 `top_up_collateral` -**Who calls it:** the lessee — to defend against a looming liquidation -by adding more of the collateral mint to the vault. +**Who calls it:** the short seller — to defend against a looming +liquidation by adding more of the collateral mint to the vault. -**Signers:** `lessee`. +**Signers:** `short_seller`. **Accounts in:** -- `lessee` (signer) -- `lessor` (UncheckedAccount, read-only) -- `lease` (mut, `has_one = lessor`, `has_one = collateral_mint`, - `constraint lease.lessee == lessee.key()`, must be `Active`) +- `short_seller` (signer) +- `holder` (UncheckedAccount, read-only) +- `lease` (mut, `has_one = holder`, `has_one = collateral_mint`, + `constraint lease.short_seller == short_seller.key()`, must be `Active`) - `collateral_mint`, `collateral_vault` -- `lessee_collateral_account` (mut, source) +- `short_seller_collateral_account` (mut, source) - `token_program` **Parameter:** `amount: u64` — how much to add. @@ -492,13 +487,13 @@ by adding more of the collateral mint to the vault. **Checks:** - `amount > 0` → `InvalidCollateralAmount` -- `lease.lessee == lessee.key()` → `Unauthorised` +- `lease.short_seller == short_seller.key()` → `Unauthorised` - `lease.status == Active` → `InvalidLeaseStatus` **Token movements:** ``` - lessee_collateral_account --[amount of collateral_mint]--> collateral_vault program-derived address + short_seller_collateral_account --[amount of collateral_mint]--> collateral_vault program-derived address ``` **State changes:** @@ -507,52 +502,53 @@ by adding more of the collateral mint to the vault. ### 3.5 `return_lease` -**Who calls it:** the lessee, while the lease is still `Active` and -before or after `end_timestamp` (the only timing rule is that `status == -Active`; Lease fees only accrue up to `end_timestamp` so returning after the -deadline does not pile on extra charges). +**Who calls it:** the short seller, while the lease is still `Active` +and before or after `end_timestamp` (the only timing rule is that +`status == Active`; lease fees only accrue up to `end_timestamp` so +returning after the deadline does not pile on extra charges). -**Signers:** `lessee`. +**Signers:** `short_seller`. **Accounts in:** -- `lessee` (signer, mut) -- `lessor` (UncheckedAccount, mut — receives Lease and vault rent-exempt - lamports via `close = lessor`) -- `lease` (mut, `close = lessor`, must be `Active`, `lessee == lessee.key()`) +- `short_seller` (signer, mut) +- `holder` (UncheckedAccount, mut — receives Lease and vault rent-exempt + lamports via `close = holder`) +- `lease` (mut, `close = holder`, must be `Active`, `short_seller == short_seller.key()`) - `leased_mint`, `collateral_mint` - `leased_vault`, `collateral_vault` (both mut) -- `lessee_leased_account` (mut, source for the return) -- `lessee_collateral_account` (mut, destination for the refund) -- `lessor_leased_account` (mut, **init_if_needed**) -- `lessor_collateral_account` (mut, **init_if_needed**) +- `short_seller_leased_account` (mut, source for the return) +- `short_seller_collateral_account` (mut, destination for the refund) +- `holder_leased_account` (mut, **init_if_needed**) +- `holder_collateral_account` (mut, **init_if_needed**) - `token_program`, `associated_token_program`, `system_program` **Checks:** - `lease.status == Active` → `InvalidLeaseStatus` -- `lease.lessee == lessee.key()` → `Unauthorised` +- `lease.short_seller == short_seller.key()` → `Unauthorised` **Token movements (in order):** ``` - lessee_leased_account --[leased_amount of leased_mint]----------> leased_vault program-derived address - leased_vault program-derived address --[leased_amount of leased_mint]----------> lessor_leased_account - collateral_vault program-derived address --[lease_fee_payable of collateral_mint]-------> lessor_collateral_account - collateral_vault program-derived address --[collateral_after_lease_fees of collateral_mint]--> lessee_collateral_account + short_seller_leased_account --[leased_amount of leased_mint]--------------> leased_vault program-derived address + leased_vault program-derived address --[leased_amount of leased_mint]--------------> holder_leased_account + collateral_vault program-derived address --[lease_fee_payable of collateral_mint]------> holder_collateral_account + collateral_vault program-derived address --[collateral_after_lease_fees of collateral_mint]--> short_seller_collateral_account ``` The leased tokens hop through the vault rather than going direct -lessee→lessor because the vault's token account is already set up and -the program can reuse its program-derived address signing path. The atomic round-trip keeps -the vault's post-instruction balance at 0 so it can be closed. +short-seller → holder because the vault's token account is already +set up and the program can reuse its program-derived address signing +path. The atomic round-trip keeps the vault's post-instruction balance +at 0 so the vault can be closed. After the transfers: -- Both vaults are closed via `close_account` cross-program invocations; their rent-exempt - lamports go to the lessor. -- The `Lease` account is closed via Anchor's `close = lessor` - constraint; its rent-exempt lamports go to the lessor too. +- Both vaults are closed via `close_account` [cross-program invocations](https://solana.com/docs/terminology); their rent-exempt + lamports go to the holder. +- The `Lease` account is closed via Anchor's `close = holder` + constraint; the `Lease` rent-exempt lamports go to the holder too. **State changes before close:** @@ -562,7 +558,7 @@ After the transfers: ### 3.6 `liquidate` -**Who calls it:** a keeper, when they can prove the position is +**Who calls it:** a keeper, when the keeper can prove the position is underwater. **Signers:** `keeper`. @@ -570,12 +566,12 @@ underwater. **Accounts in:** - `keeper` (signer, mut — pays `init_if_needed` cost for both associated token accounts) -- `lessor` (UncheckedAccount, mut — receives the lease fee + lessor_share + the +- `holder` (UncheckedAccount, mut — receives the lease fee + holder_share + the `Lease` and vault rent-exempt lamports) -- `lease` (mut, `close = lessor`, must be `Active`) +- `lease` (mut, `close = holder`, must be `Active`) - `leased_mint`, `collateral_mint` - `leased_vault`, `collateral_vault` (both mut) -- `lessor_collateral_account` (mut, **init_if_needed**) +- `holder_collateral_account` (mut, **init_if_needed**) - `keeper_collateral_account` (mut, **init_if_needed**) - `price_update` (UncheckedAccount, constrained to `owner = PYTH_RECEIVER_PROGRAM_ID`) @@ -597,8 +593,8 @@ underwater. The underwater check, in integers: ``` - collateral_value_in_colla_units * 10_000 - < debt_value_in_colla_units * maintenance_margin_basis_points + collateral_value_in_collateral_units * 10_000 + < debt_value_in_collateral_units * maintenance_margin_basis_points ``` where `debt_value = leased_amount * price * 10^exponent` (with the @@ -608,16 +604,16 @@ exponent folded into whichever side keeps the math non-negative, see **Token movements:** ``` - collateral_vault program-derived address --[lease_fee_payable of collateral_mint]---------------------> lessor_collateral_account - collateral_vault program-derived address --[bounty = remaining * bounty_basis_points / 10_000]-----------> keeper_collateral_account - collateral_vault program-derived address --[remaining - bounty of collateral_mint]--------------> lessor_collateral_account - leased_vault program-derived address --[0 of leased_mint] (empty — lessee kept the tokens) close only + collateral_vault program-derived address --[lease_fee_payable of collateral_mint]------------------> holder_collateral_account + collateral_vault program-derived address --[bounty = remaining * bounty_basis_points / 10_000]------> keeper_collateral_account + collateral_vault program-derived address --[remaining - bounty of collateral_mint]------------------> holder_collateral_account + leased_vault program-derived address --[0 of leased_mint] (empty — short seller kept the tokens) close only ``` -After the three outbound collateral transfers (lease fee, bounty, lessor -share) the collateral_vault is empty. Both vaults are then closed — -their rent-exempt lamports go to the lessor. The `Lease` account is -closed the same way (Anchor `close = lessor`). +After the three outbound collateral transfers (lease fee, bounty, +holder share) the collateral_vault is empty. Both vaults are then +closed — their rent-exempt lamports go to the holder. The `Lease` +account is closed the same way (Anchor `close = holder`). **State changes before close:** @@ -627,25 +623,25 @@ closed the same way (Anchor `close = lessor`). ### 3.7 `close_expired` -**Who calls it:** the lessor. Two very different situations collapse +**Who calls it:** the holder. Two very different situations collapse into this single handler: -- **Cancel a `Listed` lease** — the lessor changes their mind, no-one +- **Cancel a `Listed` lease** — the holder changes their mind, no-one has taken the lease yet. Allowed any time. - **Reclaim collateral after default** — the lease is `Active`, `now >= - end_timestamp`, the lessee has not called `return_lease`. The lessor takes - the whole collateral vault as compensation. + end_timestamp`, the short seller has not called `return_lease`. The + holder takes the whole collateral vault as compensation. -**Signers:** `lessor`. +**Signers:** `holder`. **Accounts in:** -- `lessor` (signer, mut — also the rent destination for all three closes) -- `lease` (mut, `close = lessor`, status ∈ `{Listed, Active}`) +- `holder` (signer, mut — also the rent destination for all three closes) +- `lease` (mut, `close = holder`, status ∈ `{Listed, Active}`) - `leased_mint`, `collateral_mint` - `leased_vault`, `collateral_vault` (both mut) -- `lessor_leased_account` (mut, **init_if_needed**) -- `lessor_collateral_account` (mut, **init_if_needed**) +- `holder_leased_account` (mut, **init_if_needed**) +- `holder_collateral_account` (mut, **init_if_needed**) - `token_program`, `associated_token_program`, `system_program` **Checks:** @@ -658,18 +654,18 @@ into this single handler: For a `Listed` cancel: ``` - leased_vault program-derived address --[leased_amount of leased_mint]--> lessor_leased_account + leased_vault program-derived address --[leased_amount of leased_mint]--> holder_leased_account collateral_vault program-derived address is empty (0 transferred) ``` For an `Active` default: ``` - leased_vault program-derived address is empty (lessee kept the tokens) - collateral_vault program-derived address --[collateral_amount of collateral_mint]--> lessor_collateral_account + leased_vault program-derived address is empty (short seller kept the tokens) + collateral_vault program-derived address --[collateral_amount of collateral_mint]--> holder_collateral_account ``` In both cases both vaults are then closed and the `Lease` account is -closed; all three rent-exempt lamport refunds go to the lessor. +closed; all three rent-exempt lamport refunds go to the holder. **State changes before close:** @@ -703,41 +699,41 @@ The diagrams use the same convention: `[ leased]` and - `liquidation_bounty_basis_points = 500` (5% of post-lease-fee collateral). - `feed_id = [0xAB; 32]` (arbitrary, consistent across all calls). -Lessor starts with 1 000 000 000 leased units in -their associated token account. Lessee starts with 1 000 000 000 +The holder starts with 1 000 000 000 leased units in their +associated token account. The short seller starts with 1 000 000 000 collateral units in theirs. -### 4.1 Happy path — lessee returns on time +### 4.1 Happy path — short seller returns on time Calls, in order: -1. **`create_lease`** — lessor posts 100 leased tokens into +1. **`create_lease`** — holder posts 100 leased tokens into `leased_vault`, parameters written to `lease`. ``` - lessor_leased_account --[100_000_000 leased]--> leased_vault program-derived address + holder_leased_account --[100_000_000 leased]--> leased_vault program-derived address ``` - Balances after: lessor has 900 000 000 leased units, `leased_vault` has + Balances after: holder has 900 000 000 leased units, `leased_vault` has 100 000 000 leased units, `collateral_vault` has 0. -2. **`take_lease`** — lessee posts 200 collateral tokens, receives +2. **`take_lease`** — short seller posts 200 collateral tokens, receives 100 leased tokens. ``` - lessee_collateral_account --[200_000_000 collateral]--> collateral_vault program-derived address - leased_vault program-derived address --[100_000_000 leased]--> lessee_leased_account + short_seller_collateral_account --[200_000_000 collateral]--> collateral_vault program-derived address + leased_vault program-derived address --[100_000_000 leased]--> short_seller_leased_account ``` `lease.status = Active`, `start_timestamp = T`, `end_timestamp = T + 86_400`. 3. **`pay_lease_fee`** called at `T + 120` seconds. Lease fee due = 120 × 10 = 1 200 collateral units. ``` - collateral_vault program-derived address --[1_200 collateral]--> lessor_collateral_account + collateral_vault program-derived address --[1_200 collateral]--> holder_collateral_account ``` `collateral_amount = 200_000_000 − 1_200 = 199_998_800`. -4. **`top_up_collateral(amount = 50_000_000)`** at `T + 600`. Lessee - decides to add a cushion. +4. **`top_up_collateral(amount = 50_000_000)`** at `T + 600`. The + short seller decides to add a cushion. ``` - lessee_collateral_account --[50_000_000 collateral]--> collateral_vault program-derived address + short_seller_collateral_account --[50_000_000 collateral]--> collateral_vault program-derived address ``` `collateral_amount = 199_998_800 + 50_000_000 = 249_998_800`. @@ -745,21 +741,21 @@ Calls, in order: from `start_timestamp` to `now` is 3 600 × 10 = 36 000 collateral units; 1 200 of that was paid in step 3. Residual lease fees = 36 000 − 1 200 = 34 800 collateral units. ``` - lessee_leased_account --[100_000_000 leased]--> leased_vault program-derived address - leased_vault program-derived address --[100_000_000 leased]--> lessor_leased_account - collateral_vault program-derived address --[34_800 collateral]--------> lessor_collateral_account - collateral_vault program-derived address --[249_964_000 collateral]---> lessee_collateral_account + short_seller_leased_account --[100_000_000 leased]--> leased_vault program-derived address + leased_vault program-derived address --[100_000_000 leased]--> holder_leased_account + collateral_vault program-derived address --[34_800 collateral]------> holder_collateral_account + collateral_vault program-derived address --[249_964_000 collateral]--> short_seller_collateral_account ``` Where `249_964_000 = 249_998_800 − 34_800`. - Both vaults close, their rent-exempt lamports go to the lessor. The - `Lease` account closes via `close = lessor`. + Both vaults close, their rent-exempt lamports go to the holder. + The `Lease` account closes via `close = holder`. **Final balances:** -- Lessor: 1 000 000 000 leased units (full return), 36 000 collateral units (total lease fees +- Holder: 1 000 000 000 leased units (full return), 36 000 collateral units (total lease fees received in steps 3 + 5), plus the lamports from three account closes. -- Lessee: 100 000 000 leased units → 0 (all returned), collateral: started with +- Short seller: 100 000 000 leased units → 0 (all returned), collateral: started with 1 000 000 000, spent 200 000 000 on initial deposit + 50 000 000 on top-up, got back 249 964 000, so holds 999 964 000 collateral units (net cost of 36 000 — exactly the total lease fees paid). @@ -775,47 +771,48 @@ Same setup. Steps 1 and 2 run identically. pot is still ~`200_000_000` (minus some streamed lease fees). Maintenance ratio = `200/400 = 50%`, well below the required 120%. - The keeper calls `pay_lease_fee` first is *not* required — `liquidate` - settles accrued lease fees itself. It goes straight to `liquidate`. + Calling `pay_lease_fee` first is *not* required — `liquidate` + settles accrued lease fees itself. The keeper goes straight to + `liquidate`. 4. **`liquidate`** at `T + 300`: - Lease fee due = 300 × 10 = 3 000 collateral units; collateral_amount = 200 000 000 so `lease_fee_payable = 3 000`. ``` - collateral_vault program-derived address --[3_000 collateral]--> lessor_collateral_account + collateral_vault program-derived address --[3_000 collateral]--> holder_collateral_account ``` - Remaining = 200 000 000 − 3 000 = 199 997 000 collateral units. - Bounty = 199 997 000 × 500 / 10 000 = 9 999 850 collateral units. ``` collateral_vault program-derived address --[9_999_850 collateral]--> keeper_collateral_account ``` - - Lessor share = 199 997 000 − 9 999 850 = 189 997 150 collateral units. + - Holder share = 199 997 000 − 9 999 850 = 189 997 150 collateral units. ``` - collateral_vault program-derived address --[189_997_150 collateral]--> lessor_collateral_account + collateral_vault program-derived address --[189_997_150 collateral]--> holder_collateral_account ``` - Both vaults close; Lease closes. Status recorded as `Liquidated`. **Final balances:** -- Lessor: 900 000 000 leased units (never got the 100 back — the - lessee kept them), `3 000 + 189 997 150 = 190 000 150` collateral - units, plus rent-exempt lamports from three closes. -- Lessee: *still* has 100 000 000 leased units. Spent 200 000 000 collateral units on - deposit, got nothing back. Net: they walk away with the leased tokens +- Holder: 900 000 000 leased units (never got the 100 back — the + short seller kept them), `3 000 + 189 997 150 = 190 000 150` + collateral units, plus rent-exempt lamports from three closes. +- Short seller: *still* has 100 000 000 leased units. Spent 200 000 000 collateral units on + deposit, got nothing back. Net: the short seller walks away with the leased tokens but forfeited the entire collateral minus the keeper's cut. -- Keeper: 9 999 850 collateral units for their trouble. +- Keeper: 9 999 850 collateral units for the keeper's trouble. (This is the key asymmetry: liquidation does *not* reclaim the leased -tokens. The collateral pays the lessor for the lost asset. The lessee -has effectively bought the leased tokens at the forfeit price.) +tokens. The collateral pays the holder for the lost asset. The short +seller has effectively bought the leased tokens at the forfeit price.) -### 4.3 Falling-price path — borrower profits +### 4.3 Falling-price path — short seller profits -Liquidation is a one-sided risk: it only ever fires when the leased -asset *appreciates* against the collateral. If the leased asset -depreciates, the collateral ratio rises and the borrower's position -gets safer. The streaming lending fee is the position's only ongoing -cost. +Liquidation is a one-sided risk: liquidation only ever fires when the +leased asset *appreciates* against the collateral. If the leased asset +depreciates, the collateral ratio rises and the short seller's +position gets safer. The streaming lending fee is the position's only +ongoing cost. Same setup. Steps 1 and 2 run identically. @@ -829,10 +826,10 @@ Same setup. Steps 1 and 2 run identically. A keeper calling `liquidate` here would fail with `PositionHealthy` — the program refuses to seize a healthy - position. The lessee is in the clear. + position. The short seller is in the clear. -4. **`return_lease`** called at `T + 600` (10 minutes in). The - lessee buys 100 leased tokens on the open market at the new price +4. **`return_lease`** called at `T + 600` (10 minutes in). The short + seller buys 100 leased tokens on the open market at the new price (about 50 collateral tokens total — far less than the 200 collateral tokens they posted), then returns those tokens to close out the lease. @@ -840,70 +837,69 @@ Same setup. Steps 1 and 2 run identically. Lease fees accrued: 600 × 10 = 6 000 collateral units. ``` - lessee_leased_account --[100_000_000 leased]--> leased_vault program-derived address - leased_vault program-derived address --[100_000_000 leased]--> lessor_leased_account - collateral_vault program-derived address --[6_000 collateral]---------> lessor_collateral_account - collateral_vault program-derived address --[199_994_000 collateral]---> lessee_collateral_account + short_seller_leased_account --[100_000_000 leased]--> leased_vault program-derived address + leased_vault program-derived address --[100_000_000 leased]--> holder_leased_account + collateral_vault program-derived address --[6_000 collateral]--------> holder_collateral_account + collateral_vault program-derived address --[199_994_000 collateral]--> short_seller_collateral_account ``` **Final balances:** -- Lessor: 1 000 000 000 leased units (full return), 6 000 collateral units in lease +- Holder: 1 000 000 000 leased units (full return), 6 000 collateral units in lease fees. -- Lessee: received 100 000 000 leased units, sold them at the +- Short seller: received 100 000 000 leased units, sold them at the original price, bought 100 leased tokens back at the lower price, returned them. Net cost is the lending fee (6 000 collateral units) - plus whatever they paid on the open market for the replacement - tokens; gain is the difference between the original sale price and - the buy-back price. The standard short payoff. + plus whatever the short seller paid on the open market for the + replacement tokens; gain is the difference between the original + sale price and the buy-back price. The standard short payoff. -The borrower can defend a borderline position with +The short seller can defend a borderline position with `top_up_collateral` or close it early via `return_lease`. Only adverse price moves trigger liquidation. ### 4.4 Default / expiry path — `close_expired` on an `Active` lease -Same setup. Steps 1 and 2 run as usual. The lessee takes the tokens, -posts collateral, then disappears. +Same setup. Steps 1 and 2 run as usual. The short seller takes the +tokens, posts collateral, then disappears. 3. `pay_lease_fee` is never called. Clock advances all the way past `end_timestamp = T + 86_400`. -4. **`close_expired`** called by the lessor at `T + 100_000`: +4. **`close_expired`** called by the holder at `T + 100_000`: - `status == Active` and `now >= end_timestamp` → the default branch runs. - - `leased_vault` is empty (lessee kept the tokens). No transfer. - - `collateral_vault` has 200 000 000 collateral units. All of it goes to the - lessor: + - `leased_vault` is empty (short seller kept the tokens). No transfer. + - `collateral_vault` has 200 000 000 collateral units. All of it + goes to the holder: ``` - collateral_vault program-derived address --[200_000_000 collateral]--> lessor_collateral_account + collateral_vault program-derived address --[200_000_000 collateral]--> holder_collateral_account ``` - Both vaults close; Lease closes. - - `last_paid_timestamp = min(now, end_timestamp) = end_timestamp` (step added in - Fix 5). + - `last_paid_timestamp = min(now, end_timestamp) = end_timestamp`. **Final balances:** -- Lessor: 900 000 000 leased units, 200 000 000 collateral units (the entire +- Holder: 900 000 000 leased units, 200 000 000 collateral units (the entire collateral pot as compensation), plus three account-close refunds. -- Lessee: 100 000 000 leased units, −200 000 000 collateral units. They paid the - full collateral and kept the leased tokens. +- Short seller: 100 000 000 leased units, −200 000 000 collateral + units. The short seller paid the full collateral and kept the leased tokens. ### 4.5 Default / expiry path — `close_expired` on a `Listed` lease -This is the cheap cancel path. No lessee ever showed up. +This is the cheap cancel path. No short seller ever showed up. 1. `create_lease` as above. -2. `close_expired` called by the lessor immediately. +2. `close_expired` called by the holder immediately. - `status == Listed` → no expiry check. - `leased_vault` holds 100 000 000 leased units. Drain back: ``` - leased_vault program-derived address --[100_000_000 leased]--> lessor_leased_account + leased_vault program-derived address --[100_000_000 leased]--> holder_leased_account ``` - `collateral_vault` is empty. No transfer. - Both vaults close; Lease closes. -**Final balances:** lessor is back to 1 000 000 000 leased units; nothing -else moved. +**Final balances:** holder is back to 1 000 000 000 leased units; +nothing else moved. --- @@ -924,22 +920,22 @@ handler: | `InvalidLeaseFeePerSecond` | `lease_fee_per_second == 0` on `create_lease` | | `InvalidMaintenanceMargin` | `maintenance_margin_basis_points == 0` or `> 50_000` on `create_lease` | | `InvalidLiquidationBounty` | `liquidation_bounty_basis_points > 2_000` on `create_lease` | -| `LeaseExpired` | Reserved; not currently used (Lease fee accrual naturally caps at `end_timestamp`) | +| `LeaseExpired` | Reserved; not currently used (lease fee accrual naturally caps at `end_timestamp`) | | `LeaseNotExpired` | `close_expired` called on an `Active` lease before `end_timestamp` | | `PositionHealthy` | `liquidate` called on a lease that passes the maintenance-margin check | | `StalePrice` | Pyth price update older than 60 s, or has a future `publish_time`, or fails discriminator / length check | | `NonPositivePrice` | Pyth price is `<= 0` | | `MathOverflow` | Any of the `checked_*` arithmetic returned `None` | -| `Unauthorised` | Lease-modifying handler called by someone who is not the registered lessee (`top_up_collateral`, `return_lease`) | +| `Unauthorised` | Lease-modifying handler called by someone who is not the registered short seller (`top_up_collateral`, `return_lease`) | | `LeasedMintEqualsCollateralMint` | `create_lease` called with the same mint for both sides | | `PriceFeedMismatch` | `liquidate` called with a Pyth update whose `feed_id` does not match `lease.feed_id` | ### 5.2 Guarded design choices worth knowing - **Leased tokens are locked up-front.** `create_lease` moves the tokens - into the `leased_vault` immediately, so a lessee calling `take_lease` - cannot fail because the lessor spent the funds elsewhere in the - meantime. + into the `leased_vault` immediately, so a short seller calling + `take_lease` cannot fail because the holder spent the funds + elsewhere in the meantime. - **Leased mint ≠ collateral mint.** If both sides used the same mint, the two vaults would hold the same asset and the @@ -957,7 +953,7 @@ handler: - **Integer-only math.** Every percentage and price calculation folds into a `checked_mul` / `checked_div` of `u128` — no floats, no - surprising NaN. `BPS_DENOMINATOR = 10 000` is the only + surprising NaN. `BASIS_POINTS_DENOMINATOR = 10 000` is the only "percentage denominator" anywhere; cross-check against `constants.rs` if you're porting the math. @@ -965,17 +961,18 @@ handler: leased_vault.key()` (and likewise for `collateral_vault`). The program signs as the vault using its own seeds, which means the `Lease` account is not involved in signing any of the token moves. - This keeps the signer-seed array small (one seed list, not two). + Authority-is-self keeps the signer-seed array small (one seed list, + not two). -- **Max maintenance margin = 500%.** Without an upper bound a lessor +- **Max maintenance margin = 500%.** Without an upper bound a holder could set a margin that is unreachable on day one and liquidate the - lessee instantly. 50 000 basis points is generous — enough for truly - speculative leases — while still blocking the pathological 10 000× - trap. + short seller instantly. 50 000 basis points is generous — enough + for truly speculative leases — while still blocking the pathological + 10 000× trap. - **Max liquidation bounty = 20%.** Higher than 20% and the keeper's - cut would dwarf the lessor's recovery on default. The cap keeps - liquidation economics roughly in line with lender-first semantics. + cut would dwarf the holder's recovery on default. The cap keeps + liquidation economics roughly in line with holder-first semantics. ### 5.3 Things the program does *not* guard against @@ -983,36 +980,39 @@ A production protocol would want more: - **Price feed correctness.** The program verifies the owner (`PYTH_RECEIVER_PROGRAM_ID`), the discriminator, the layout and the - feed id, but it cannot know whether the feed the lessor pinned - quotes the right pair. Supplying the wrong feed at creation is the - lessor's problem — it won't cause a liquidation to succeed against a - truly healthy position (the feed id check would fail), but it will - mean *no* liquidation can succeed, so a lessee could drain the - collateral via lease fees and walk away. A production version would cross- - check the price feed's `feed_id` against a protocol registry. + feed id, but the program cannot know whether the feed the holder + pinned quotes the right pair. Supplying the wrong feed at creation + is the holder's problem — the wrong feed won't cause a liquidation + to succeed against a truly healthy position (the feed id check + would fail), but it will mean *no* liquidation can succeed, so a + short seller could drain the collateral via lease fees and walk + away. A production version would cross-check the price feed's + `feed_id` against a protocol registry. - **Lease-fee dust accumulation.** Lease fees are paid in whole base units per second of `lease_fee_per_second`. Choose a small `lease_fee_per_second` and short-lived leases can settle 0 lease fees if no-one calls `pay_lease_fee` for a very short period. Not a - security issue — the accrual timestamp only moves forward when the lease - fee is actually settled — but worth knowing. + security issue — the accrual timestamp only moves forward when the + lease fee is actually settled — but worth knowing. - **Griefing on `init_if_needed`.** `take_lease`, `pay_lease_fee`, `liquidate`, `return_lease` and `close_expired` all do - `init_if_needed` on one or more associated token accounts. If the caller does not fund - the rent-exempt reserve for those accounts, the transaction fails. - This is the intended behaviour (the caller pays for the state they - require) but can surprise a lessee on a tight SOL budget. - -- **No partial lease-fee refund on default.** When `close_expired` runs on - an `Active` lease, the lessor takes the entire collateral regardless - of how many lease fees had actually accrued by then. This is a deliberate - simplification — the `last_paid_timestamp` bookkeeping in Fix 5 is in - place precisely so a future version can split the pot correctly. + `init_if_needed` on one or more associated token accounts. If the + caller does not fund the rent-exempt reserve for those accounts, + the transaction fails. This is the intended behaviour (the caller + pays for the state they require) but can surprise a short seller + on a tight SOL budget. + +- **No partial lease-fee refund on default.** When `close_expired` + runs on an `Active` lease, the holder takes the entire collateral + regardless of how many lease fees had actually accrued by then. + This is a deliberate simplification — the `last_paid_timestamp` + bookkeeping is in place precisely so a future version can split the + pot correctly. - **No pause / upgrade authority.** The program has no admin and no - upgrade authority-bound feature flags. It runs or it doesn't. + upgrade-authority-bound feature flags. The program runs or it doesn't. --- @@ -1067,17 +1067,17 @@ test top_up_collateral_increases_vault_balance ... ok | Test | Exercises | |---|---| -| `create_lease_locks_tokens_and_lists` | Lessor funds vault, `Lease` created, collateral vault empty | +| `create_lease_locks_tokens_and_lists` | Holder funds vault, `Lease` created, collateral vault empty | | `create_lease_rejects_same_mint_for_leased_and_collateral` | Guard against `leased_mint == collateral_mint` | | `take_lease_posts_collateral_and_delivers_tokens` | Collateral deposit + leased-token payout in one instruction | -| `pay_lease_fee_streams_collateral_by_elapsed_time` | Lease fee math: `elapsed * lease_fee_per_second`, lease fee transferred to lessor | +| `pay_lease_fee_streams_collateral_by_elapsed_time` | Lease fee math: `elapsed * lease_fee_per_second`, lease fee transferred to holder | | `top_up_collateral_increases_vault_balance` | Collateral balance after `top_up` equals deposit + top-up | | `return_lease_refunds_unused_collateral` | Happy path round-trip — leased tokens returned, residual collateral refunded, accounts closed | -| `liquidate_seizes_collateral_on_price_drop` | Price-induced underwater position → lease fee + bounty + lessor share paid, accounts closed | +| `liquidate_seizes_collateral_on_price_drop` | Price-induced underwater position → lease fee + bounty + holder share paid, accounts closed | | `liquidate_rejects_healthy_position` | Program refuses to liquidate a position that passes the margin check | | `liquidate_rejects_mismatched_price_feed` | Program refuses a `PriceUpdateV2` whose `feed_id` ≠ `lease.feed_id` | -| `close_expired_reclaims_collateral_after_end_timestamp` | Default path — lessor seizes the collateral | -| `close_expired_cancels_listed_lease` | Lessor-initiated cancel of an unrented lease | +| `close_expired_reclaims_collateral_after_end_timestamp` | Default path — holder seizes the collateral | +| `close_expired_cancels_listed_lease` | Holder-initiated cancel of an unrented lease | ### Note on CI @@ -1099,9 +1099,9 @@ Anchor that compiles to bare Solana program binaries without pulling in size, or simply want fewer layers between your code and the runtime. The port implements the same seven instruction handlers, the same -`Lease` state account, the same program-derived address seed conventions, and produces the -same onchain behaviour for every happy-path and adversarial test in -this README. +`Lease` state account, the same program-derived address seed +conventions, and produces the same onchain behaviour for every +happy-path and adversarial test in this README. ### Building and testing @@ -1145,14 +1145,15 @@ The Quasar example in this repo's CI workflow means the setup code is more explicit. - **No cross-program-invocation into an associated-token-account - program for associated token account creation.** The Anchor version uses `init_if_needed` - + `associated_token::...` to let callers pass in a lessor/lessee - wallet and get the token account created on demand. The Quasar port - accepts pre-created token accounts for the user side of every flow, - since doing `init_if_needed` correctly for associated token accounts in Quasar requires - wiring in the associated token account program manually and adds noise that distracts - from the lease mechanics. Production code would want the associated token account - convenience back. + program for associated token account creation.** The Anchor version + uses `init_if_needed` + `associated_token::...` to let callers pass + in a holder/short-seller wallet and get the token account created + on demand. The Quasar port accepts pre-created token accounts for + the user side of every flow, since doing `init_if_needed` correctly + for associated token accounts in Quasar requires wiring in the + associated token account program manually and adds noise that + distracts from the lease mechanics. Production code would want the + associated token account convenience back. - **Classic Token only, not Token-2022.** The Anchor version declares its token accounts as `InterfaceAccount` + `token_program: @@ -1167,19 +1168,20 @@ The Quasar example in this repo's CI workflow that already decodes Anchor `Lease` accounts would also decode the Quasar ones after adjusting for the one-byte discriminator. -- **One lease per lessor at a time.** The Anchor version keys its - `Lease` program-derived address on `[LEASE_SEED, lessor, lease_id]` so one lessor can - run many leases in parallel. Quasar's `seeds = [...]` macro embeds - raw references into generated code and does not (yet) have a - borrow-safe way to splice instruction args like +- **One lease per holder at a time.** The Anchor version keys its + `Lease` program-derived address on `[LEASE_SEED, holder, lease_id]` + so one holder can run many leases in parallel. Quasar's `seeds = [...]` + macro embeds raw references into generated code and does not (yet) + have a borrow-safe way to splice instruction args like `lease_id.to_le_bytes()` into the seed list, so the Quasar port - keys its program-derived address on `[LEASE_SEED, lessor]` alone — one active lease per - lessor. The `lease_id` is still stored on the `Lease` account for - book-keeping and is a caller-supplied u64 in `create_lease`; the - off-chain client just has to ensure the previous lease from the same - lessor is `Closed` or `Liquidated` (i.e. its program-derived address account is gone) - before creating a new one. Swapping in a multi-lease seed is a - mechanical change once Quasar grows support for dynamic-byte seeds. + keys its program-derived address on `[LEASE_SEED, holder]` alone — + one active lease per holder. The `lease_id` is still stored on the + `Lease` account for book-keeping and is a caller-supplied u64 in + `create_lease`; the off-chain client just has to ensure the previous + lease from the same holder is `Closed` or `Liquidated` (i.e. its + program-derived address account is gone) before creating a new one. + Swapping in a multi-lease seed is a mechanical change once Quasar + grows support for dynamic-byte seeds. The code layout mirrors this directory: `src/lib.rs` registers the entrypoint and re-exports handlers, `src/state.rs` defines `Lease` and @@ -1199,24 +1201,24 @@ Directions a real protocol would consider, grouped by effort: is_underwater }` given the same inputs `is_underwater` uses. Useful for UIs that want to show "you are 15% away from liquidation". -- **Cap lease fees at collateral.** Currently `pay_lease_fee` pays `min(lease_fee_due, - collateral_amount)` and silently leaves a debt. Add an explicit - `LeaseFeeDebtOutstanding` error so the caller is warned when the stream - has stalled, rather than inferring it from a non-zero `lease_fee_due` - after settlement. +- **Cap lease fees at collateral.** Currently `pay_lease_fee` pays + `min(lease_fee_due, collateral_amount)` and silently leaves a debt. + Add an explicit `LeaseFeeDebtOutstanding` error so the caller is + warned when the stream has stalled, rather than inferring it from a + non-zero `lease_fee_due` after settlement. ### Moderate - **Partial-refund default.** In `close_expired` on `Active`, instead - of giving the lessor the entire collateral, split it: - `lease_fee_due` to the lessor, the rest stays with the lessee up to some - `default_haircut_basis_points`. `last_paid_timestamp` is already bumped by - Fix 5, so the timestamp invariants are ready. + of giving the holder the entire collateral, split it: `lease_fee_due` + to the holder, the rest stays with the short seller up to some + `default_haircut_basis_points`. `last_paid_timestamp` is already + bumped after a default close, so the timestamp invariants are ready. -- **Multiple outstanding leases per `(lessor, lessee)` pair with the - same mint pair.** Already supported via `lease_id`, but add an +- **Multiple outstanding leases per `(holder, short_seller)` pair with + the same mint pair.** Already supported via `lease_id`, but add an instruction-level index account that lists open lease ids for a - given lessor so off-chain tools don't have to `getProgramAccounts` + given holder so off-chain tools don't have to `getProgramAccounts` scan. - **Quote asset ≠ collateral mint.** Rent and liquidation math assume @@ -1230,12 +1232,12 @@ Directions a real protocol would consider, grouped by effort: - **Keeper auction.** Replace the fixed `liquidation_bounty_basis_points` with a Dutch auction that grows the bounty linearly over some window after the position first becomes underwater. Keeps liquidators honest on - tight feeds and gives lessees a chance to `top_up_collateral` before - a keeper has an economic reason to move. + tight feeds and gives short sellers a chance to `top_up_collateral` + before a keeper has an economic reason to move. - **Flash liquidation.** Let the keeper settle the debt in the same transaction as the liquidation — borrow the leased amount from a - separate liquidity pool, hand it to the lessor, take the full + separate liquidity pool, hand it to the holder, take the full collateral, repay the pool, keep the spread. Requires integrating a second program. diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml b/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml index 61c6b4293..465fd8ed2 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml +++ b/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml @@ -21,7 +21,7 @@ custom-panic = [] [dependencies] # `init-if-needed` is required because several instructions lazily create the -# counterparty's associated token accounts (keeper's collateral associated token account on first liquidation, lessor's +# counterparty's associated token accounts (keeper's collateral associated token account on first liquidation, holder's # leased associated token account on first return, etc.). Anchor forces an opt-in to make us # re-affirm that we verify ownership on every touch — which we do via the # `associated_token::authority = ...` constraints. diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs index a4bc88d1c..6d8f54e60 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs @@ -1,12 +1,12 @@ -/// program-derived address seed for the `Lease` account. Combined with the lessor pubkey and a -/// u64 `lease_id` so one lessor can run many leases in parallel. +/// program-derived address seed for the `Lease` account. Combined with the holder pubkey and a +/// u64 `lease_id` so one holder can run many leases in parallel. pub const LEASE_SEED: &[u8] = b"lease"; /// program-derived address seed for the token vault that holds the leased tokens while the lease /// is `Listed` and that accepts returned tokens on settlement. pub const LEASED_VAULT_SEED: &[u8] = b"leased_vault"; -/// program-derived address seed for the token vault that escrows the lessee's collateral for the +/// program-derived address seed for the token vault that escrows the short_seller's collateral for the /// life of the lease. pub const COLLATERAL_VAULT_SEED: &[u8] = b"collateral_vault"; @@ -14,12 +14,12 @@ pub const COLLATERAL_VAULT_SEED: &[u8] = b"collateral_vault"; /// and the liquidation bounty. 10_000 basis points = 100%. pub const BASIS_POINTS_DENOMINATOR: u64 = 10_000; -/// Maximum allowed maintenance margin: 50_000 basis points = 500%. Prevents the lessor +/// Maximum allowed maintenance margin: 50_000 basis points = 500%. Prevents the holder /// setting an impossible margin that would let them liquidate on day one. pub const MAX_MAINTENANCE_MARGIN_BASIS_POINTS: u16 = 50_000; /// Maximum liquidation bounty the keeper can claim: 2_000 basis points = 20%. Keeps -/// most of the collateral flowing to the lessor on default. +/// most of the collateral flowing to the holder on default. pub const MAX_LIQUIDATION_BOUNTY_BASIS_POINTS: u16 = 2_000; /// A Pyth price update is considered stale if its `publish_time` is older diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs index 08582c8c9..954cdbee9 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs @@ -14,27 +14,27 @@ use crate::{ state::{Lease, LeaseStatus}, }; -/// Lessor-only recovery path. Two real-world situations collapse here: +/// Holder-only recovery path. Two real-world situations collapse here: /// -/// - The lease sat in `Listed` and the lessor wants to cancel it, recovering +/// - The lease sat in `Listed` and the holder wants to cancel it, recovering /// the leased tokens they pre-funded. Allowed any time. -/// - The lease was `Active` but the lessee ghosted past `end_timestamp`. The lessor +/// - The lease was `Active` but the short_seller ghosted past `end_timestamp`. The holder /// takes the collateral as compensation and closes the books. #[derive(Accounts)] pub struct CloseExpired<'info> { #[account(mut)] - pub lessor: Signer<'info>, + pub holder: Signer<'info>, #[account( mut, - seeds = [LEASE_SEED, lessor.key().as_ref(), &lease.lease_id.to_le_bytes()], + seeds = [LEASE_SEED, holder.key().as_ref(), &lease.lease_id.to_le_bytes()], bump = lease.bump, - has_one = lessor, + has_one = holder, has_one = leased_mint, has_one = collateral_mint, constraint = matches!(lease.status, LeaseStatus::Listed | LeaseStatus::Active) @ AssetLeasingError::InvalidLeaseStatus, - close = lessor, + close = holder, )] pub lease: Account<'info, Lease>, @@ -63,21 +63,21 @@ pub struct CloseExpired<'info> { #[account( init_if_needed, - payer = lessor, + payer = holder, associated_token::mint = leased_mint, - associated_token::authority = lessor, + associated_token::authority = holder, associated_token::token_program = token_program, )] - pub lessor_leased_account: Box>, + pub holder_leased_account: Box>, #[account( init_if_needed, - payer = lessor, + payer = holder, associated_token::mint = collateral_mint, - associated_token::authority = lessor, + associated_token::authority = holder, associated_token::token_program = token_program, )] - pub lessor_collateral_account: Box>, + pub holder_collateral_account: Box>, pub token_program: Interface<'info, TokenInterface>, pub associated_token_program: Program<'info, AssociatedToken>, @@ -111,14 +111,14 @@ pub fn handle_close_expired(context: Context) -> Result<()> { core::slice::from_ref(&collateral_vault_bump), ]; - // Drain whatever is in the leased vault back to the lessor. For a Listed + // Drain whatever is in the leased vault back to the holder. For a Listed // lease this is the full leased_amount; for a defaulted Active lease the - // vault is empty (the lessee never returned) and this is a no-op. + // vault is empty (the short_seller never returned) and this is a no-op. let leased_vault_balance = context.accounts.leased_vault.amount; if leased_vault_balance > 0 { transfer_tokens_from_vault( &context.accounts.leased_vault, - &context.accounts.lessor_leased_account, + &context.accounts.holder_leased_account, leased_vault_balance, &context.accounts.leased_mint, &context.accounts.leased_vault.to_account_info(), @@ -127,13 +127,13 @@ pub fn handle_close_expired(context: Context) -> Result<()> { )?; } - // Drain the collateral vault to the lessor. For a Listed lease this is 0. - // For a defaulted Active lease this is the lessee's forfeited collateral. + // Drain the collateral vault to the holder. For a Listed lease this is 0. + // For a defaulted Active lease this is the short_seller's forfeited collateral. let collateral_vault_balance = context.accounts.collateral_vault.amount; if collateral_vault_balance > 0 { transfer_tokens_from_vault( &context.accounts.collateral_vault, - &context.accounts.lessor_collateral_account, + &context.accounts.holder_collateral_account, collateral_vault_balance, &context.accounts.collateral_mint, &context.accounts.collateral_vault.to_account_info(), @@ -144,26 +144,26 @@ pub fn handle_close_expired(context: Context) -> Result<()> { close_vault( &context.accounts.leased_vault, - &context.accounts.lessor.to_account_info(), + &context.accounts.holder.to_account_info(), &context.accounts.token_program, &[leased_vault_seeds], )?; close_vault( &context.accounts.collateral_vault, - &context.accounts.lessor.to_account_info(), + &context.accounts.holder.to_account_info(), &context.accounts.token_program, &[collateral_vault_seeds], )?; // Settle lease-fee accounting on the default path. // - // We are not forwarding any accrued lease fees to the lessor here — on default - // the lessor takes the whole collateral vault as compensation — but we + // We are not forwarding any accrued lease fees to the holder here — on default + // the holder takes the whole collateral vault as compensation — but we // still bump \`last_paid_timestamp\` so the invariant // \`last_paid_timestamp <= now.min(end_timestamp)\` stays intact. That matters for // any future version of the program that wants to split the collateral // differently (pro-rata lease fees, partial refund on default, haircut to the - // lessee for unused time): such a version can read + // short_seller for unused time): such a version can read // \`last_paid_timestamp\` and trust that everything up to \`now\` is already // settled, rather than having to reason about whether this branch ever // bumped the timestamp. diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs index e88621931..3ba7947e0 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs @@ -15,7 +15,7 @@ use crate::{ #[instruction(lease_id: u64)] pub struct CreateLease<'info> { #[account(mut)] - pub lessor: Signer<'info>, + pub holder: Signer<'info>, #[account(mint::token_program = token_program)] pub leased_mint: InterfaceAccount<'info, Mint>, @@ -26,16 +26,16 @@ pub struct CreateLease<'info> { #[account( mut, associated_token::mint = leased_mint, - associated_token::authority = lessor, + associated_token::authority = holder, associated_token::token_program = token_program, )] - pub lessor_leased_account: Box>, + pub holder_leased_account: Box>, #[account( init, - payer = lessor, + payer = holder, space = Lease::DISCRIMINATOR.len() + Lease::INIT_SPACE, - seeds = [LEASE_SEED, lessor.key().as_ref(), &lease_id.to_le_bytes()], + seeds = [LEASE_SEED, holder.key().as_ref(), &lease_id.to_le_bytes()], bump, )] pub lease: Account<'info, Lease>, @@ -45,7 +45,7 @@ pub struct CreateLease<'info> { /// returns / liquidation; any handler just signs with the vault seeds. #[account( init, - payer = lessor, + payer = holder, seeds = [LEASED_VAULT_SEED, lease.key().as_ref()], bump, token::mint = leased_mint, @@ -56,7 +56,7 @@ pub struct CreateLease<'info> { #[account( init, - payer = lessor, + payer = holder, seeds = [COLLATERAL_VAULT_SEED, lease.key().as_ref()], bump, token::mint = collateral_mint, @@ -84,7 +84,7 @@ pub fn handle_create_lease( // Reject leased_mint == collateral_mint. Allowing both to be the same // mint would collapse the two vaults' seed derivations into one shared // token-balance pool, making lease-fee-vs-collateral accounting ambiguous and - // enabling griefing paths where the lessee's "collateral" is the same + // enabling griefing paths where the short_seller's "collateral" is the same // asset they already hold as the lease principal. require!( context.accounts.leased_mint.key() != context.accounts.collateral_mint.key(), @@ -108,23 +108,23 @@ pub fn handle_create_lease( ); // Lock the leased tokens into the program-owned vault up-front. Doing this - // here (not on take_lease) guarantees a lessee can never accept a lease - // the lessor no longer has the funds to deliver. + // here (not on take_lease) guarantees a short_seller can never accept a lease + // the holder no longer has the funds to deliver. transfer_tokens_from_user( - &context.accounts.lessor_leased_account, + &context.accounts.holder_leased_account, &context.accounts.leased_vault, leased_amount, &context.accounts.leased_mint, - &context.accounts.lessor, + &context.accounts.holder, &context.accounts.token_program, )?; let lease = &mut context.accounts.lease; lease.set_inner(Lease { lease_id, - lessor: context.accounts.lessor.key(), - // No lessee yet — will be populated by take_lease. - lessee: Pubkey::default(), + holder: context.accounts.holder.key(), + // No short_seller yet — will be populated by take_lease. + short_seller: Pubkey::default(), leased_mint: context.accounts.leased_mint.key(), leased_amount, collateral_mint: context.accounts.collateral_mint.key(), diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs index 25f9d0ff3..1a06259db 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs @@ -36,17 +36,17 @@ pub struct Liquidate<'info> { /// CHECK: program-derived address seed + lease-fee / collateral destination. #[account(mut)] - pub lessor: UncheckedAccount<'info>, + pub holder: UncheckedAccount<'info>, #[account( mut, - seeds = [LEASE_SEED, lessor.key().as_ref(), &lease.lease_id.to_le_bytes()], + seeds = [LEASE_SEED, holder.key().as_ref(), &lease.lease_id.to_le_bytes()], bump = lease.bump, - has_one = lessor, + has_one = holder, has_one = leased_mint, has_one = collateral_mint, constraint = lease.status == LeaseStatus::Active @ AssetLeasingError::InvalidLeaseStatus, - close = lessor, + close = holder, )] pub lease: Account<'info, Lease>, @@ -77,10 +77,10 @@ pub struct Liquidate<'info> { init_if_needed, payer = keeper, associated_token::mint = collateral_mint, - associated_token::authority = lessor, + associated_token::authority = holder, associated_token::token_program = token_program, )] - pub lessor_collateral_account: Box>, + pub holder_collateral_account: Box>, #[account( init_if_needed, @@ -160,7 +160,7 @@ pub fn handle_liquidate(context: Context) -> Result<()> { drop(price_data); // Feed pinning: reject any `PriceUpdateV2` whose feed_id does not match - // the one the lessor committed to at `create_lease`. Without this guard, + // the one the holder committed to at `create_lease`. Without this guard, // a keeper could pass in any feed the Pyth Receiver program owns — e.g. // a wildly volatile pair that dips enough to flag the position as // underwater — and trigger a spurious liquidation. @@ -174,8 +174,8 @@ pub fn handle_liquidate(context: Context) -> Result<()> { AssetLeasingError::PositionHealthy ); - // Settle accrued lease fees first (up to end_timestamp) so the lessor is paid for the - // time the lessee actually used. Only then slice off bounty + remainder. + // Settle accrued lease fees first (up to end_timestamp) so the holder is paid for the + // time the short_seller actually used. Only then slice off bounty + remainder. let lease_fee_due = compute_lease_fee_due(&context.accounts.lease, now)?; let lease_fee_payable = lease_fee_due.min(context.accounts.lease.collateral_amount); @@ -196,7 +196,7 @@ pub fn handle_liquidate(context: Context) -> Result<()> { if lease_fee_payable > 0 { transfer_tokens_from_vault( &context.accounts.collateral_vault, - &context.accounts.lessor_collateral_account, + &context.accounts.holder_collateral_account, lease_fee_payable, &context.accounts.collateral_mint, &context.accounts.collateral_vault.to_account_info(), @@ -232,14 +232,14 @@ pub fn handle_liquidate(context: Context) -> Result<()> { )?; } - let lessor_share = remaining + let holder_share = remaining .checked_sub(bounty) .ok_or(AssetLeasingError::MathOverflow)?; - if lessor_share > 0 { + if holder_share > 0 { transfer_tokens_from_vault( &context.accounts.collateral_vault, - &context.accounts.lessor_collateral_account, - lessor_share, + &context.accounts.holder_collateral_account, + holder_share, &context.accounts.collateral_mint, &context.accounts.collateral_vault.to_account_info(), &context.accounts.token_program, @@ -247,18 +247,18 @@ pub fn handle_liquidate(context: Context) -> Result<()> { )?; } - // The leased vault is empty (lessee kept the tokens on default) but was - // rent-exempt funded at creation. Close both vaults so the lessor recoups + // The leased vault is empty (short_seller kept the tokens on default) but was + // rent-exempt funded at creation. Close both vaults so the holder recoups // the rent-exempt lamports. close_vault( &context.accounts.leased_vault, - &context.accounts.lessor.to_account_info(), + &context.accounts.holder.to_account_info(), &context.accounts.token_program, &[leased_vault_seeds], )?; close_vault( &context.accounts.collateral_vault, - &context.accounts.lessor.to_account_info(), + &context.accounts.holder.to_account_info(), &context.accounts.token_program, &[collateral_vault_seeds], )?; @@ -286,7 +286,7 @@ pub fn is_underwater(lease: &Lease, price: &DecodedPriceUpdate, now: i64) -> Res let leased_amount = lease.leased_amount as u128; let collateral_amount = lease.collateral_amount as u128; let margin_basis_points = lease.maintenance_margin_basis_points as u128; - let denom = BASIS_POINTS_DENOMINATOR as u128; + let denominator = BASIS_POINTS_DENOMINATOR as u128; let (collateral_scaled, debt_scaled) = if price.exponent >= 0 { let scale = ten_pow(price.exponent as u32)?; @@ -307,7 +307,7 @@ pub fn is_underwater(lease: &Lease, price: &DecodedPriceUpdate, now: i64) -> Res }; let lhs = collateral_scaled - .checked_mul(denom) + .checked_mul(denominator) .ok_or(AssetLeasingError::MathOverflow)?; let rhs = debt_scaled .checked_mul(margin_basis_points) diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_lease_fee.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_lease_fee.rs index 6e18b1169..4c576b56f 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_lease_fee.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_lease_fee.rs @@ -13,20 +13,20 @@ use crate::{ #[derive(Accounts)] pub struct PayLeaseFee<'info> { - /// Anyone may settle the lease fee — the lessee has every incentive to keep the + /// Anyone may settle the lease fee — the short_seller has every incentive to keep the /// lease current, but a keeper bot could also push a lease fee payment before a /// liquidation check so healthy leases stay healthy. #[account(mut)] pub payer: Signer<'info>, /// CHECK: Referenced only for program-derived address derivation + has_one check on `lease`. - pub lessor: UncheckedAccount<'info>, + pub holder: UncheckedAccount<'info>, #[account( mut, - seeds = [LEASE_SEED, lessor.key().as_ref(), &lease.lease_id.to_le_bytes()], + seeds = [LEASE_SEED, holder.key().as_ref(), &lease.lease_id.to_le_bytes()], bump = lease.bump, - has_one = lessor, + has_one = holder, has_one = collateral_mint, constraint = lease.status == LeaseStatus::Active @ AssetLeasingError::InvalidLeaseStatus, )] @@ -44,16 +44,16 @@ pub struct PayLeaseFee<'info> { )] pub collateral_vault: Box>, - /// Lessor's collateral-mint associated token account, created on demand so the lessor does not + /// Holder's collateral-mint associated token account, created on demand so the holder does not /// need to pre-fund it with the lease fee. #[account( init_if_needed, payer = payer, associated_token::mint = collateral_mint, - associated_token::authority = lessor, + associated_token::authority = holder, associated_token::token_program = token_program, )] - pub lessor_collateral_account: Box>, + pub holder_collateral_account: Box>, pub token_program: Interface<'info, TokenInterface>, pub associated_token_program: Program<'info, AssociatedToken>, @@ -72,7 +72,7 @@ pub fn handle_pay_lease_fee(context: Context) -> Result<()> { } // Cap lease fees at whatever collateral actually sits in the vault. If the - // lessee under-collateralised we cannot magically create funds; the + // short_seller under-collateralised we cannot magically create funds; the // remainder is their debt and can trigger liquidation. let payable = lease_fee_amount.min(context.accounts.collateral_amount_available()); @@ -88,7 +88,7 @@ pub fn handle_pay_lease_fee(context: Context) -> Result<()> { transfer_tokens_from_vault( &context.accounts.collateral_vault, - &context.accounts.lessor_collateral_account, + &context.accounts.holder_collateral_account, payable, &context.accounts.collateral_mint, &context.accounts.collateral_vault.to_account_info(), diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs index db2cd80aa..736397c4d 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs @@ -17,30 +17,30 @@ use crate::{ #[derive(Accounts)] pub struct ReturnLease<'info> { #[account(mut)] - pub lessee: Signer<'info>, + pub short_seller: Signer<'info>, /// CHECK: Reference only — receives the lease fee + closed-vault rent-exempt-lamport refund. #[account(mut)] - pub lessor: UncheckedAccount<'info>, + pub holder: UncheckedAccount<'info>, #[account( mut, - seeds = [LEASE_SEED, lessor.key().as_ref(), &lease.lease_id.to_le_bytes()], + seeds = [LEASE_SEED, holder.key().as_ref(), &lease.lease_id.to_le_bytes()], bump = lease.bump, - has_one = lessor, + has_one = holder, has_one = leased_mint, has_one = collateral_mint, - constraint = lease.lessee == lessee.key() @ AssetLeasingError::Unauthorised, + constraint = lease.short_seller == short_seller.key() @ AssetLeasingError::Unauthorised, constraint = lease.status == LeaseStatus::Active @ AssetLeasingError::InvalidLeaseStatus, - close = lessor, + close = holder, )] pub lease: Account<'info, Lease>, pub leased_mint: Box>, pub collateral_mint: Box>, - /// Leased tokens flow back into this vault from the lessee, then out to - /// the lessor in the same instruction. Closed at the end to reclaim rent-exempt lamports. + /// Leased tokens flow back into this vault from the short_seller, then out to + /// the holder in the same instruction. Closed at the end to reclaim rent-exempt lamports. #[account( mut, seeds = [LEASED_VAULT_SEED, lease.key().as_ref()], @@ -64,38 +64,38 @@ pub struct ReturnLease<'info> { #[account( mut, associated_token::mint = leased_mint, - associated_token::authority = lessee, + associated_token::authority = short_seller, associated_token::token_program = token_program, )] - pub lessee_leased_account: Box>, + pub short_seller_leased_account: Box>, #[account( mut, associated_token::mint = collateral_mint, - associated_token::authority = lessee, + associated_token::authority = short_seller, associated_token::token_program = token_program, )] - pub lessee_collateral_account: Box>, + pub short_seller_collateral_account: Box>, - /// Lessor's leased-mint associated token account, created on demand. They may have sent the + /// Holder's leased-mint associated token account, created on demand. They may have sent the /// original tokens from a different account. #[account( init_if_needed, - payer = lessee, + payer = short_seller, associated_token::mint = leased_mint, - associated_token::authority = lessor, + associated_token::authority = holder, associated_token::token_program = token_program, )] - pub lessor_leased_account: Box>, + pub holder_leased_account: Box>, #[account( init_if_needed, - payer = lessee, + payer = short_seller, associated_token::mint = collateral_mint, - associated_token::authority = lessor, + associated_token::authority = holder, associated_token::token_program = token_program, )] - pub lessor_collateral_account: Box>, + pub holder_collateral_account: Box>, pub token_program: Interface<'info, TokenInterface>, pub associated_token_program: Program<'info, AssociatedToken>, @@ -106,18 +106,18 @@ pub fn handle_return_lease(context: Context) -> Result<()> { let now = Clock::get()?.unix_timestamp; let lease_key = context.accounts.lease.key(); - // 1. Lessee returns leased tokens to the leased vault (full amount). + // 1. ShortSeller returns leased tokens to the leased vault (full amount). let leased_amount = context.accounts.lease.leased_amount; transfer_tokens_from_user( - &context.accounts.lessee_leased_account, + &context.accounts.short_seller_leased_account, &context.accounts.leased_vault, leased_amount, &context.accounts.leased_mint, - &context.accounts.lessee, + &context.accounts.short_seller, &context.accounts.token_program, )?; - // 2. Forward leased tokens from the vault to the lessor. + // 2. Forward leased tokens from the vault to the holder. let leased_vault_bump = context.accounts.lease.leased_vault_bump; let leased_vault_seeds: &[&[u8]] = &[ LEASED_VAULT_SEED, @@ -126,7 +126,7 @@ pub fn handle_return_lease(context: Context) -> Result<()> { ]; transfer_tokens_from_vault( &context.accounts.leased_vault, - &context.accounts.lessor_leased_account, + &context.accounts.holder_leased_account, leased_amount, &context.accounts.leased_mint, &context.accounts.leased_vault.to_account_info(), @@ -134,7 +134,7 @@ pub fn handle_return_lease(context: Context) -> Result<()> { &[leased_vault_seeds], )?; - // 3. Settle accrued lease fees: collateral vault -> lessor. + // 3. Settle accrued lease fees: collateral vault -> holder. let lease_fee_due = compute_lease_fee_due(&context.accounts.lease, now)?; let lease_fee_payable = lease_fee_due.min(context.accounts.lease.collateral_amount); @@ -148,7 +148,7 @@ pub fn handle_return_lease(context: Context) -> Result<()> { if lease_fee_payable > 0 { transfer_tokens_from_vault( &context.accounts.collateral_vault, - &context.accounts.lessor_collateral_account, + &context.accounts.holder_collateral_account, lease_fee_payable, &context.accounts.collateral_mint, &context.accounts.collateral_vault.to_account_info(), @@ -157,8 +157,8 @@ pub fn handle_return_lease(context: Context) -> Result<()> { )?; } - // 4. Refund remaining collateral to the lessee. Returning early does not - // entitle the lessee to a future-lease-fee refund — Lease fees only accrue for time + // 4. Refund remaining collateral to the short_seller. Returning early does not + // entitle the short_seller to a future-lease-fee refund — Lease fees only accrue for time // actually used, so `compute_lease_fee_due` already excludes the unused tail. let collateral_after_lease_fees = context .accounts @@ -170,7 +170,7 @@ pub fn handle_return_lease(context: Context) -> Result<()> { if collateral_after_lease_fees > 0 { transfer_tokens_from_vault( &context.accounts.collateral_vault, - &context.accounts.lessee_collateral_account, + &context.accounts.short_seller_collateral_account, collateral_after_lease_fees, &context.accounts.collateral_mint, &context.accounts.collateral_vault.to_account_info(), @@ -180,16 +180,16 @@ pub fn handle_return_lease(context: Context) -> Result<()> { } // 5. Close both vaults so the rent-exempt lamports come back to the - // lessor — the lessee only pays for the temporary state they held. + // holder — the short_seller only pays for the temporary state they held. close_vault( &context.accounts.leased_vault, - &context.accounts.lessor.to_account_info(), + &context.accounts.holder.to_account_info(), &context.accounts.token_program, &[leased_vault_seeds], )?; close_vault( &context.accounts.collateral_vault, - &context.accounts.lessor.to_account_info(), + &context.accounts.holder.to_account_info(), &context.accounts.token_program, &[collateral_vault_seeds], )?; diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs index 8d9c4e597..d604327fd 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs @@ -14,16 +14,16 @@ use crate::{ #[derive(Accounts)] pub struct TakeLease<'info> { #[account(mut)] - pub lessee: Signer<'info>, + pub short_seller: Signer<'info>, /// CHECK: Only used as a reference for the program-derived address seeds; no data accessed. - pub lessor: UncheckedAccount<'info>, + pub holder: UncheckedAccount<'info>, #[account( mut, - seeds = [LEASE_SEED, lessor.key().as_ref(), &lease.lease_id.to_le_bytes()], + seeds = [LEASE_SEED, holder.key().as_ref(), &lease.lease_id.to_le_bytes()], bump = lease.bump, - has_one = lessor, + has_one = holder, has_one = leased_mint, has_one = collateral_mint, constraint = lease.status == LeaseStatus::Listed @ AssetLeasingError::InvalidLeaseStatus, @@ -53,26 +53,26 @@ pub struct TakeLease<'info> { )] pub collateral_vault: Box>, - /// Lessee's existing collateral account — they must already hold the + /// ShortSeller's existing collateral account — they must already hold the /// required collateral before calling. #[account( mut, associated_token::mint = collateral_mint, - associated_token::authority = lessee, + associated_token::authority = short_seller, associated_token::token_program = token_program, )] - pub lessee_collateral_account: Box>, + pub short_seller_collateral_account: Box>, - /// Lessee's associated token account for the leased mint. Created on-demand if missing so the - /// UI only has to hand over a lessee keypair plus the two mints. + /// ShortSeller's associated token account for the leased mint. Created on-demand if missing so the + /// UI only has to hand over a short_seller keypair plus the two mints. #[account( init_if_needed, - payer = lessee, + payer = short_seller, associated_token::mint = leased_mint, - associated_token::authority = lessee, + associated_token::authority = short_seller, associated_token::token_program = token_program, )] - pub lessee_leased_account: Box>, + pub short_seller_leased_account: Box>, pub token_program: Interface<'info, TokenInterface>, pub associated_token_program: Program<'info, AssociatedToken>, @@ -87,14 +87,14 @@ pub fn handle_take_lease(context: Context) -> Result<()> { let leased_amount = context.accounts.lease.leased_amount; let duration_seconds = context.accounts.lease.duration_seconds; - // Lessee deposits collateral first so a failed leased-token transfer + // ShortSeller deposits collateral first so a failed leased-token transfer // rolls back their deposit atomically. transfer_tokens_from_user( - &context.accounts.lessee_collateral_account, + &context.accounts.short_seller_collateral_account, &context.accounts.collateral_vault, required_collateral_amount, &context.accounts.collateral_mint, - &context.accounts.lessee, + &context.accounts.short_seller, &context.accounts.token_program, )?; @@ -110,7 +110,7 @@ pub fn handle_take_lease(context: Context) -> Result<()> { transfer_tokens_from_vault( &context.accounts.leased_vault, - &context.accounts.lessee_leased_account, + &context.accounts.short_seller_leased_account, leased_amount, &context.accounts.leased_mint, &context.accounts.leased_vault.to_account_info(), @@ -123,7 +123,7 @@ pub fn handle_take_lease(context: Context) -> Result<()> { .ok_or(AssetLeasingError::MathOverflow)?; let lease = &mut context.accounts.lease; - lease.lessee = context.accounts.lessee.key(); + lease.short_seller = context.accounts.short_seller.key(); lease.collateral_amount = required_collateral_amount; lease.start_timestamp = now; lease.end_timestamp = end_timestamp; diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/top_up_collateral.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/top_up_collateral.rs index e6a90b5a9..86eb4af33 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/top_up_collateral.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/top_up_collateral.rs @@ -11,18 +11,18 @@ use crate::{ #[derive(Accounts)] pub struct TopUpCollateral<'info> { #[account(mut)] - pub lessee: Signer<'info>, + pub short_seller: Signer<'info>, /// CHECK: program-derived address seed reference; no reads. - pub lessor: UncheckedAccount<'info>, + pub holder: UncheckedAccount<'info>, #[account( mut, - seeds = [LEASE_SEED, lessor.key().as_ref(), &lease.lease_id.to_le_bytes()], + seeds = [LEASE_SEED, holder.key().as_ref(), &lease.lease_id.to_le_bytes()], bump = lease.bump, - has_one = lessor, + has_one = holder, has_one = collateral_mint, - constraint = lease.lessee == lessee.key() @ AssetLeasingError::Unauthorised, + constraint = lease.short_seller == short_seller.key() @ AssetLeasingError::Unauthorised, constraint = lease.status == LeaseStatus::Active @ AssetLeasingError::InvalidLeaseStatus, )] pub lease: Account<'info, Lease>, @@ -42,10 +42,10 @@ pub struct TopUpCollateral<'info> { #[account( mut, associated_token::mint = collateral_mint, - associated_token::authority = lessee, + associated_token::authority = short_seller, associated_token::token_program = token_program, )] - pub lessee_collateral_account: Box>, + pub short_seller_collateral_account: Box>, pub token_program: Interface<'info, TokenInterface>, } @@ -54,11 +54,11 @@ pub fn handle_top_up_collateral(context: Context, amount: u64) require!(amount > 0, AssetLeasingError::InvalidCollateralAmount); transfer_tokens_from_user( - &context.accounts.lessee_collateral_account, + &context.accounts.short_seller_collateral_account, &context.accounts.collateral_vault, amount, &context.accounts.collateral_mint, - &context.accounts.lessee, + &context.accounts.short_seller, &context.accounts.token_program, )?; diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs index dad279635..7aa24fe40 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs @@ -15,8 +15,8 @@ declare_id!("HHKEhLk6dyzG4mK1isPyZiHcEMW4J1CRKryzyQ3JFtnF"); pub mod asset_leasing { use super::*; - /// Lessor lists a lease: deposits leased tokens into the leased vault and - /// publishes the rental terms. The lease sits in `Listed` until a lessee + /// Holder lists a lease: deposits leased tokens into the leased vault and + /// publishes the rental terms. The lease sits in `Listed` until a short_seller /// takes it. pub fn create_lease( context: Context, @@ -42,38 +42,38 @@ pub mod asset_leasing { ) } - /// Lessee takes the lease: posts collateral into the collateral vault and + /// ShortSeller takes the lease: posts collateral into the collateral vault and /// receives the leased tokens. Lease transitions to `Active`. pub fn take_lease(context: Context) -> Result<()> { instructions::take_lease::handle_take_lease(context) } - /// Stream the lease fee from the collateral vault to the lessor, up to `end_timestamp`. + /// Stream the lease fee from the collateral vault to the holder, up to `end_timestamp`. /// Anyone may call this to keep the lease current. pub fn pay_lease_fee(context: Context) -> Result<()> { instructions::pay_lease_fee::handle_pay_lease_fee(context) } - /// Lessee adds more collateral to stay above the maintenance margin. + /// ShortSeller adds more collateral to stay above the maintenance margin. pub fn top_up_collateral(context: Context, amount: u64) -> Result<()> { instructions::top_up_collateral::handle_top_up_collateral(context, amount) } - /// Lessee returns the leased tokens (at or before `end_timestamp`). Accrued lease fees + /// ShortSeller returns the leased tokens (at or before `end_timestamp`). Accrued lease fees /// is settled and the remaining collateral is refunded. pub fn return_lease(context: Context) -> Result<()> { instructions::return_lease::handle_return_lease(context) } /// Keeper liquidates an undercollateralised lease using a Pyth price - /// update. Collateral goes to the lessor, minus the keeper bounty. + /// update. Collateral goes to the holder, minus the keeper bounty. pub fn liquidate(context: Context) -> Result<()> { instructions::liquidate::handle_liquidate(context) } - /// After `end_timestamp`, if the lessee never returned the tokens, the lessor + /// After `end_timestamp`, if the short_seller never returned the tokens, the holder /// reclaims the collateral as compensation and closes the lease. Also - /// used by the lessor to cancel an unrented (`Listed`) lease. + /// used by the holder to cancel an unrented (`Listed`) lease. pub fn close_expired(context: Context) -> Result<()> { instructions::close_expired::handle_close_expired(context) } diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs index 6276165ff..1409de744 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs @@ -4,8 +4,8 @@ use anchor_lang::prelude::*; /// Listed --take_lease--> Active /// Active --return_lease--> Closed /// Active --liquidate--> Liquidated -/// Listed --close_expired--> Closed (lessor cancels unrented lease) -/// Active --close_expired--> Closed (after end_timestamp, defaulted lessee) +/// Listed --close_expired--> Closed (holder cancels unrented lease) +/// Active --close_expired--> Closed (after end_timestamp, defaulted short_seller) #[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, Debug, InitSpace)] pub enum LeaseStatus { Listed, @@ -17,29 +17,29 @@ pub enum LeaseStatus { #[account] #[derive(InitSpace)] pub struct Lease { - /// Caller-supplied id so one lessor can run many leases in parallel. The - /// program-derived address is seeded by (LEASE_SEED, lessor, lease_id). + /// Caller-supplied id so one holder can run many leases in parallel. The + /// program-derived address is seeded by (LEASE_SEED, holder, lease_id). pub lease_id: u64, /// Account that listed the lease and receives the lease fee. Always set. - pub lessor: Pubkey, + pub holder: Pubkey, /// Account that took the lease. `Pubkey::default()` while `Listed`. - pub lessee: Pubkey, + pub short_seller: Pubkey, /// Mint of the tokens being leased out. pub leased_mint: Pubkey, /// Amount of leased tokens locked at creation. Used for repayment checks. pub leased_amount: u64, - /// Mint of the collateral posted by the lessee. + /// Mint of the collateral posted by the short_seller. pub collateral_mint: Pubkey, - /// Collateral the lessee posted (increases on top-up). Decreases as the lease fee + /// Collateral the short_seller posted (increases on top-up). Decreases as the lease fee /// is streamed out of the collateral vault. pub collateral_amount: u64, - /// Collateral the lessee must deposit up-front when taking the lease. + /// Collateral the short_seller must deposit up-front when taking the lease. pub required_collateral_amount: u64, /// Lease fee charged per second, denominated in collateral tokens and paid - /// from the collateral vault to the lessor on each `pay_lease_fee`. + /// from the collateral vault to the holder on each `pay_lease_fee`. pub lease_fee_per_second: u64, /// Length of the lease, in seconds. Set at creation, used to compute /// `end_timestamp` when the lease activates. @@ -62,7 +62,7 @@ pub struct Lease { /// liquidation handler refuses price updates whose on-account `feed_id` /// does not match this value, so a keeper cannot swap in an unrelated /// feed (e.g. a cheaper or more volatile pair) to force a liquidation. - /// Chosen by the lessor at `create_lease`. + /// Chosen by the holder at `create_lease`. pub feed_id: [u8; 32], /// Current lifecycle state. diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs b/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs index 49995a2c1..aab2aaa02 100644 --- a/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs +++ b/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs @@ -2,7 +2,7 @@ //! //! Covers the full lifecycle: listing, taking, lease fee streaming, top-ups, //! early return, keeper liquidation via a mocked Pyth `PriceUpdateV2` -//! account, and lessor-initiated default recovery after expiry. +//! account, and holder-initiated default recovery after expiry. use { anchor_lang::{ @@ -55,9 +55,9 @@ fn derive_associated_token_account(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { associated_token_account } -fn lease_program_derived_addresses(program_id: &Pubkey, lessor: &Pubkey, lease_id: u64) -> (Pubkey, Pubkey, Pubkey) { +fn lease_program_derived_addresses(program_id: &Pubkey, holder: &Pubkey, lease_id: u64) -> (Pubkey, Pubkey, Pubkey) { let (lease, _) = Pubkey::find_program_address( - &[LEASE_SEED, lessor.as_ref(), &lease_id.to_le_bytes()], + &[LEASE_SEED, holder.as_ref(), &lease_id.to_le_bytes()], program_id, ); let (leased_vault, _) = @@ -74,13 +74,13 @@ struct Scenario { // not used directly by the tests afterwards. #[allow(dead_code)] payer: Keypair, - lessor: Keypair, - lessee: Keypair, + holder: Keypair, + short_seller: Keypair, keeper: Keypair, leased_mint: Pubkey, collateral_mint: Pubkey, - lessor_leased_associated_token_account: Pubkey, - lessee_collateral_associated_token_account: Pubkey, + holder_leased_associated_token_account: Pubkey, + short_seller_collateral_associated_token_account: Pubkey, } fn full_setup() -> Scenario { @@ -90,8 +90,8 @@ fn full_setup() -> Scenario { svm.add_program(program_id, program_bytes).unwrap(); let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); - let lessor = create_wallet(&mut svm, 10_000_000_000).unwrap(); - let lessee = create_wallet(&mut svm, 10_000_000_000).unwrap(); + let holder = create_wallet(&mut svm, 10_000_000_000).unwrap(); + let short_seller = create_wallet(&mut svm, 10_000_000_000).unwrap(); let keeper = create_wallet(&mut svm, 10_000_000_000).unwrap(); // 6 decimals matches USDC and keeps test arithmetic readable. @@ -99,24 +99,24 @@ fn full_setup() -> Scenario { let leased_mint = create_token_mint(&mut svm, &payer, decimals, None).unwrap(); let collateral_mint = create_token_mint(&mut svm, &payer, decimals, None).unwrap(); - let lessor_leased_associated_token_account = - create_associated_token_account(&mut svm, &lessor.pubkey(), &leased_mint, &payer).unwrap(); + let holder_leased_associated_token_account = + create_associated_token_account(&mut svm, &holder.pubkey(), &leased_mint, &payer).unwrap(); mint_tokens_to_token_account( &mut svm, &leased_mint, - &lessor_leased_associated_token_account, + &holder_leased_associated_token_account, 1_000_000_000, &payer, ) .unwrap(); - let lessee_collateral_associated_token_account = - create_associated_token_account(&mut svm, &lessee.pubkey(), &collateral_mint, &payer) + let short_seller_collateral_associated_token_account = + create_associated_token_account(&mut svm, &short_seller.pubkey(), &collateral_mint, &payer) .unwrap(); mint_tokens_to_token_account( &mut svm, &collateral_mint, - &lessee_collateral_associated_token_account, + &short_seller_collateral_associated_token_account, 1_000_000_000, &payer, ) @@ -131,13 +131,13 @@ fn full_setup() -> Scenario { svm, program_id, payer, - lessor, - lessee, + holder, + short_seller, keeper, leased_mint, collateral_mint, - lessor_leased_associated_token_account, - lessee_collateral_associated_token_account, + holder_leased_associated_token_account, + short_seller_collateral_associated_token_account, } } @@ -174,7 +174,7 @@ fn build_create_lease_instruction( feed_id: [u8; 32], ) -> Instruction { let (lease, leased_vault, collateral_vault) = - lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); Instruction::new_with_bytes( scenario.program_id, &asset_leasing::instruction::CreateLease { @@ -189,10 +189,10 @@ fn build_create_lease_instruction( } .data(), asset_leasing::accounts::CreateLease { - lessor: scenario.lessor.pubkey(), + holder: scenario.holder.pubkey(), leased_mint: scenario.leased_mint, collateral_mint: scenario.collateral_mint, - lessor_leased_account: scenario.lessor_leased_associated_token_account, + holder_leased_account: scenario.holder_leased_associated_token_account, lease, leased_vault, collateral_vault, @@ -205,21 +205,21 @@ fn build_create_lease_instruction( fn build_take_lease_instruction(scenario: &Scenario, lease_id: u64) -> Instruction { let (lease, leased_vault, collateral_vault) = - lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); - let lessee_leased_associated_token_account = derive_associated_token_account(&scenario.lessee.pubkey(), &scenario.leased_mint); + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + let short_seller_leased_associated_token_account = derive_associated_token_account(&scenario.short_seller.pubkey(), &scenario.leased_mint); Instruction::new_with_bytes( scenario.program_id, &asset_leasing::instruction::TakeLease {}.data(), asset_leasing::accounts::TakeLease { - lessee: scenario.lessee.pubkey(), - lessor: scenario.lessor.pubkey(), + short_seller: scenario.short_seller.pubkey(), + holder: scenario.holder.pubkey(), lease, leased_mint: scenario.leased_mint, collateral_mint: scenario.collateral_mint, leased_vault, collateral_vault, - lessee_collateral_account: scenario.lessee_collateral_associated_token_account, - lessee_leased_account: lessee_leased_associated_token_account, + short_seller_collateral_account: scenario.short_seller_collateral_associated_token_account, + short_seller_leased_account: short_seller_leased_associated_token_account, token_program: token_program_id(), associated_token_program: associated_token_account_program_id(), system_program: system_program::id(), @@ -230,18 +230,18 @@ fn build_take_lease_instruction(scenario: &Scenario, lease_id: u64) -> Instructi fn build_pay_lease_fee_instruction(scenario: &Scenario, lease_id: u64) -> Instruction { let (lease, _leased_vault, collateral_vault) = - lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); - let lessor_collateral_associated_token_account = derive_associated_token_account(&scenario.lessor.pubkey(), &scenario.collateral_mint); + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + let holder_collateral_associated_token_account = derive_associated_token_account(&scenario.holder.pubkey(), &scenario.collateral_mint); Instruction::new_with_bytes( scenario.program_id, &asset_leasing::instruction::PayLeaseFee {}.data(), asset_leasing::accounts::PayLeaseFee { - payer: scenario.lessee.pubkey(), - lessor: scenario.lessor.pubkey(), + payer: scenario.short_seller.pubkey(), + holder: scenario.holder.pubkey(), lease, collateral_mint: scenario.collateral_mint, collateral_vault, - lessor_collateral_account: lessor_collateral_associated_token_account, + holder_collateral_account: holder_collateral_associated_token_account, token_program: token_program_id(), associated_token_program: associated_token_account_program_id(), system_program: system_program::id(), @@ -252,17 +252,17 @@ fn build_pay_lease_fee_instruction(scenario: &Scenario, lease_id: u64) -> Instru fn build_top_up_instruction(scenario: &Scenario, lease_id: u64, amount: u64) -> Instruction { let (lease, _leased_vault, collateral_vault) = - lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); Instruction::new_with_bytes( scenario.program_id, &asset_leasing::instruction::TopUpCollateral { amount }.data(), asset_leasing::accounts::TopUpCollateral { - lessee: scenario.lessee.pubkey(), - lessor: scenario.lessor.pubkey(), + short_seller: scenario.short_seller.pubkey(), + holder: scenario.holder.pubkey(), lease, collateral_mint: scenario.collateral_mint, collateral_vault, - lessee_collateral_account: scenario.lessee_collateral_associated_token_account, + short_seller_collateral_account: scenario.short_seller_collateral_associated_token_account, token_program: token_program_id(), } .to_account_metas(None), @@ -271,24 +271,24 @@ fn build_top_up_instruction(scenario: &Scenario, lease_id: u64, amount: u64) -> fn build_return_lease_instruction(scenario: &Scenario, lease_id: u64) -> Instruction { let (lease, leased_vault, collateral_vault) = - lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); - let lessee_leased_associated_token_account = derive_associated_token_account(&scenario.lessee.pubkey(), &scenario.leased_mint); - let lessor_collateral_associated_token_account = derive_associated_token_account(&scenario.lessor.pubkey(), &scenario.collateral_mint); + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + let short_seller_leased_associated_token_account = derive_associated_token_account(&scenario.short_seller.pubkey(), &scenario.leased_mint); + let holder_collateral_associated_token_account = derive_associated_token_account(&scenario.holder.pubkey(), &scenario.collateral_mint); Instruction::new_with_bytes( scenario.program_id, &asset_leasing::instruction::ReturnLease {}.data(), asset_leasing::accounts::ReturnLease { - lessee: scenario.lessee.pubkey(), - lessor: scenario.lessor.pubkey(), + short_seller: scenario.short_seller.pubkey(), + holder: scenario.holder.pubkey(), lease, leased_mint: scenario.leased_mint, collateral_mint: scenario.collateral_mint, leased_vault, collateral_vault, - lessee_leased_account: lessee_leased_associated_token_account, - lessee_collateral_account: scenario.lessee_collateral_associated_token_account, - lessor_leased_account: scenario.lessor_leased_associated_token_account, - lessor_collateral_account: lessor_collateral_associated_token_account, + short_seller_leased_account: short_seller_leased_associated_token_account, + short_seller_collateral_account: scenario.short_seller_collateral_associated_token_account, + holder_leased_account: scenario.holder_leased_associated_token_account, + holder_collateral_account: holder_collateral_associated_token_account, token_program: token_program_id(), associated_token_program: associated_token_account_program_id(), system_program: system_program::id(), @@ -299,21 +299,21 @@ fn build_return_lease_instruction(scenario: &Scenario, lease_id: u64) -> Instruc fn build_liquidate_instruction(scenario: &Scenario, lease_id: u64, price_update: Pubkey) -> Instruction { let (lease, leased_vault, collateral_vault) = - lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); - let lessor_collateral_associated_token_account = derive_associated_token_account(&scenario.lessor.pubkey(), &scenario.collateral_mint); + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + let holder_collateral_associated_token_account = derive_associated_token_account(&scenario.holder.pubkey(), &scenario.collateral_mint); let keeper_collateral_associated_token_account = derive_associated_token_account(&scenario.keeper.pubkey(), &scenario.collateral_mint); Instruction::new_with_bytes( scenario.program_id, &asset_leasing::instruction::Liquidate {}.data(), asset_leasing::accounts::Liquidate { keeper: scenario.keeper.pubkey(), - lessor: scenario.lessor.pubkey(), + holder: scenario.holder.pubkey(), lease, leased_mint: scenario.leased_mint, collateral_mint: scenario.collateral_mint, leased_vault, collateral_vault, - lessor_collateral_account: lessor_collateral_associated_token_account, + holder_collateral_account: holder_collateral_associated_token_account, keeper_collateral_account: keeper_collateral_associated_token_account, price_update, token_program: token_program_id(), @@ -326,20 +326,20 @@ fn build_liquidate_instruction(scenario: &Scenario, lease_id: u64, price_update: fn build_close_expired_instruction(scenario: &Scenario, lease_id: u64) -> Instruction { let (lease, leased_vault, collateral_vault) = - lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); - let lessor_collateral_associated_token_account = derive_associated_token_account(&scenario.lessor.pubkey(), &scenario.collateral_mint); + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + let holder_collateral_associated_token_account = derive_associated_token_account(&scenario.holder.pubkey(), &scenario.collateral_mint); Instruction::new_with_bytes( scenario.program_id, &asset_leasing::instruction::CloseExpired {}.data(), asset_leasing::accounts::CloseExpired { - lessor: scenario.lessor.pubkey(), + holder: scenario.holder.pubkey(), lease, leased_mint: scenario.leased_mint, collateral_mint: scenario.collateral_mint, leased_vault, collateral_vault, - lessor_leased_account: scenario.lessor_leased_associated_token_account, - lessor_collateral_account: lessor_collateral_associated_token_account, + holder_leased_account: scenario.holder_leased_associated_token_account, + holder_collateral_account: holder_collateral_associated_token_account, token_program: token_program_id(), associated_token_program: associated_token_account_program_id(), system_program: system_program::id(), @@ -358,7 +358,7 @@ fn build_price_update_data( publish_time: i64, ) -> Vec { // Size layout: - // 8 disc + 32 write_authority + 1 verification_level + 32 feed_id + + // 8 discriminator + 32 write_authority + 1 verification_level + 32 feed_id + // 8 price + 8 conf + 4 exponent + 8 publish_time + 8 prev_publish_time + // 8 ema_price + 8 ema_conf + 8 posted_slot = 141 bytes. const TOTAL_LEN: usize = 141; @@ -438,11 +438,11 @@ fn create_lease_locks_tokens_and_lists() { LIQUIDATION_BOUNTY_BASIS_POINTS, FEED_ID, ); - send_transaction_from_instructions(&mut scenario.svm, vec![instruction], &[&scenario.lessor], &scenario.lessor.pubkey()) + send_transaction_from_instructions(&mut scenario.svm, vec![instruction], &[&scenario.holder], &scenario.holder.pubkey()) .unwrap(); let (lease_program_derived_address, leased_vault, collateral_vault) = - lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); // Leased tokens escrowed. assert_eq!( @@ -454,9 +454,9 @@ fn create_lease_locks_tokens_and_lists() { get_token_account_balance(&scenario.svm, &collateral_vault).unwrap(), 0 ); - // Lessor's leased balance dropped by the escrowed amount. + // Holder's leased balance dropped by the escrowed amount. assert_eq!( - get_token_account_balance(&scenario.svm, &scenario.lessor_leased_associated_token_account).unwrap(), + get_token_account_balance(&scenario.svm, &scenario.holder_leased_associated_token_account).unwrap(), 1_000_000_000 - LEASED_AMOUNT ); @@ -485,8 +485,8 @@ fn take_lease_posts_collateral_and_delivers_tokens() { send_transaction_from_instructions( &mut scenario.svm, vec![create_instruction], - &[&scenario.lessor], - &scenario.lessor.pubkey(), + &[&scenario.holder], + &scenario.holder.pubkey(), ) .unwrap(); @@ -494,28 +494,28 @@ fn take_lease_posts_collateral_and_delivers_tokens() { send_transaction_from_instructions( &mut scenario.svm, vec![take_instruction], - &[&scenario.lessee], - &scenario.lessee.pubkey(), + &[&scenario.short_seller], + &scenario.short_seller.pubkey(), ) .unwrap(); let (_, leased_vault, collateral_vault) = - lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); - let lessee_leased_associated_token_account = derive_associated_token_account(&scenario.lessee.pubkey(), &scenario.leased_mint); + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + let short_seller_leased_associated_token_account = derive_associated_token_account(&scenario.short_seller.pubkey(), &scenario.leased_mint); - // Leased vault drained into the lessee. + // Leased vault drained into the short_seller. assert_eq!(get_token_account_balance(&scenario.svm, &leased_vault).unwrap(), 0); assert_eq!( - get_token_account_balance(&scenario.svm, &lessee_leased_associated_token_account).unwrap(), + get_token_account_balance(&scenario.svm, &short_seller_leased_associated_token_account).unwrap(), LEASED_AMOUNT ); - // Collateral moved from the lessee into the collateral vault. + // Collateral moved from the short_seller into the collateral vault. assert_eq!( get_token_account_balance(&scenario.svm, &collateral_vault).unwrap(), REQUIRED_COLLATERAL ); assert_eq!( - get_token_account_balance(&scenario.svm, &scenario.lessee_collateral_associated_token_account).unwrap(), + get_token_account_balance(&scenario.svm, &scenario.short_seller_collateral_associated_token_account).unwrap(), 1_000_000_000 - REQUIRED_COLLATERAL ); } @@ -540,8 +540,8 @@ fn pay_lease_fee_streams_collateral_by_elapsed_time() { send_transaction_from_instructions( &mut scenario.svm, vec![create_instruction, take_instruction], - &[&scenario.lessor, &scenario.lessee], - &scenario.lessor.pubkey(), + &[&scenario.holder, &scenario.short_seller], + &scenario.holder.pubkey(), ) .unwrap(); @@ -552,18 +552,18 @@ fn pay_lease_fee_streams_collateral_by_elapsed_time() { send_transaction_from_instructions( &mut scenario.svm, vec![pay_instruction], - &[&scenario.lessee], - &scenario.lessee.pubkey(), + &[&scenario.short_seller], + &scenario.short_seller.pubkey(), ) .unwrap(); let expected_lease_fees = (elapsed as u64) * LEASE_FEE_PER_SECOND; - let lessor_collateral_associated_token_account = derive_associated_token_account(&scenario.lessor.pubkey(), &scenario.collateral_mint); + let holder_collateral_associated_token_account = derive_associated_token_account(&scenario.holder.pubkey(), &scenario.collateral_mint); assert_eq!( - get_token_account_balance(&scenario.svm, &lessor_collateral_associated_token_account).unwrap(), + get_token_account_balance(&scenario.svm, &holder_collateral_associated_token_account).unwrap(), expected_lease_fees ); - let (_, _, collateral_vault) = lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + let (_, _, collateral_vault) = lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); assert_eq!( get_token_account_balance(&scenario.svm, &collateral_vault).unwrap(), REQUIRED_COLLATERAL - expected_lease_fees @@ -590,8 +590,8 @@ fn top_up_collateral_increases_vault_balance() { send_transaction_from_instructions( &mut scenario.svm, vec![create_instruction, take_instruction], - &[&scenario.lessor, &scenario.lessee], - &scenario.lessor.pubkey(), + &[&scenario.holder, &scenario.short_seller], + &scenario.holder.pubkey(), ) .unwrap(); @@ -600,12 +600,12 @@ fn top_up_collateral_increases_vault_balance() { send_transaction_from_instructions( &mut scenario.svm, vec![top_up_instruction], - &[&scenario.lessee], - &scenario.lessee.pubkey(), + &[&scenario.short_seller], + &scenario.short_seller.pubkey(), ) .unwrap(); - let (_, _, collateral_vault) = lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + let (_, _, collateral_vault) = lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); assert_eq!( get_token_account_balance(&scenario.svm, &collateral_vault).unwrap(), REQUIRED_COLLATERAL + top_up_amount @@ -632,12 +632,12 @@ fn return_lease_refunds_unused_collateral() { send_transaction_from_instructions( &mut scenario.svm, vec![create_instruction, take_instruction], - &[&scenario.lessor, &scenario.lessee], - &scenario.lessor.pubkey(), + &[&scenario.holder, &scenario.short_seller], + &scenario.holder.pubkey(), ) .unwrap(); - // Lessee returns early — 10 minutes in, for a 24h lease. + // ShortSeller returns early — 10 minutes in, for a 24h lease. let elapsed: i64 = 600; advance_clock_by(&mut scenario.svm, elapsed); @@ -645,34 +645,34 @@ fn return_lease_refunds_unused_collateral() { send_transaction_from_instructions( &mut scenario.svm, vec![return_instruction], - &[&scenario.lessee], - &scenario.lessee.pubkey(), + &[&scenario.short_seller], + &scenario.short_seller.pubkey(), ) .unwrap(); let lease_fee_paid = (elapsed as u64) * LEASE_FEE_PER_SECOND; let refund_expected = REQUIRED_COLLATERAL - lease_fee_paid; - // Lessor got their leased tokens back. + // Holder got their leased tokens back. assert_eq!( - get_token_account_balance(&scenario.svm, &scenario.lessor_leased_associated_token_account).unwrap(), + get_token_account_balance(&scenario.svm, &scenario.holder_leased_associated_token_account).unwrap(), 1_000_000_000 ); - // Lessor also received the accrued lease fees. - let lessor_collateral_associated_token_account = derive_associated_token_account(&scenario.lessor.pubkey(), &scenario.collateral_mint); + // Holder also received the accrued lease fees. + let holder_collateral_associated_token_account = derive_associated_token_account(&scenario.holder.pubkey(), &scenario.collateral_mint); assert_eq!( - get_token_account_balance(&scenario.svm, &lessor_collateral_associated_token_account).unwrap(), + get_token_account_balance(&scenario.svm, &holder_collateral_associated_token_account).unwrap(), lease_fee_paid ); - // Lessee got the unused-time portion of their collateral back. + // ShortSeller got the unused-time portion of their collateral back. assert_eq!( - get_token_account_balance(&scenario.svm, &scenario.lessee_collateral_associated_token_account).unwrap(), + get_token_account_balance(&scenario.svm, &scenario.short_seller_collateral_associated_token_account).unwrap(), 1_000_000_000 - REQUIRED_COLLATERAL + refund_expected ); // Lease + vault program-derived addresses are closed. let (lease_program_derived_address, leased_vault, collateral_vault) = - lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); assert!(scenario.svm.get_account(&lease_program_derived_address).is_none()); assert!(scenario.svm.get_account(&leased_vault).is_none()); assert!(scenario.svm.get_account(&collateral_vault).is_none()); @@ -698,8 +698,8 @@ fn liquidate_seizes_collateral_on_price_drop() { send_transaction_from_instructions( &mut scenario.svm, vec![create_instruction, take_instruction], - &[&scenario.lessor, &scenario.lessee], - &scenario.lessor.pubkey(), + &[&scenario.holder, &scenario.short_seller], + &scenario.holder.pubkey(), ) .unwrap(); @@ -722,10 +722,10 @@ fn liquidate_seizes_collateral_on_price_drop() { now, // fresh publish_time ); - let liq_ix = build_liquidate_instruction(&scenario, lease_id, price_update_key.pubkey()); + let liquidate_instruction = build_liquidate_instruction(&scenario, lease_id, price_update_key.pubkey()); send_transaction_from_instructions( &mut scenario.svm, - vec![liq_ix], + vec![liquidate_instruction], &[&scenario.keeper], &scenario.keeper.pubkey(), ) @@ -734,14 +734,14 @@ fn liquidate_seizes_collateral_on_price_drop() { let lease_fee_paid = (elapsed as u64) * LEASE_FEE_PER_SECOND; let remaining_after_lease_fees = REQUIRED_COLLATERAL - lease_fee_paid; let bounty = remaining_after_lease_fees * (LIQUIDATION_BOUNTY_BASIS_POINTS as u64) / 10_000; - let lessor_share = remaining_after_lease_fees - bounty; + let holder_share = remaining_after_lease_fees - bounty; - let lessor_collateral_associated_token_account = derive_associated_token_account(&scenario.lessor.pubkey(), &scenario.collateral_mint); + let holder_collateral_associated_token_account = derive_associated_token_account(&scenario.holder.pubkey(), &scenario.collateral_mint); let keeper_collateral_associated_token_account = derive_associated_token_account(&scenario.keeper.pubkey(), &scenario.collateral_mint); assert_eq!( - get_token_account_balance(&scenario.svm, &lessor_collateral_associated_token_account).unwrap(), - lease_fee_paid + lessor_share + get_token_account_balance(&scenario.svm, &holder_collateral_associated_token_account).unwrap(), + lease_fee_paid + holder_share ); assert_eq!( get_token_account_balance(&scenario.svm, &keeper_collateral_associated_token_account).unwrap(), @@ -750,7 +750,7 @@ fn liquidate_seizes_collateral_on_price_drop() { // Vaults and lease account closed. let (lease_program_derived_address, leased_vault, collateral_vault) = - lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); assert!(scenario.svm.get_account(&lease_program_derived_address).is_none()); assert!(scenario.svm.get_account(&leased_vault).is_none()); assert!(scenario.svm.get_account(&collateral_vault).is_none()); @@ -776,8 +776,8 @@ fn liquidate_rejects_healthy_position() { send_transaction_from_instructions( &mut scenario.svm, vec![create_instruction, take_instruction], - &[&scenario.lessor, &scenario.lessee], - &scenario.lessor.pubkey(), + &[&scenario.holder, &scenario.short_seller], + &scenario.holder.pubkey(), ) .unwrap(); @@ -788,10 +788,10 @@ fn liquidate_rejects_healthy_position() { let now = current_clock(&scenario.svm); mock_price_update(&mut scenario.svm, price_update_key.pubkey(), FEED_ID, 1, 0, now); - let liq_ix = build_liquidate_instruction(&scenario, lease_id, price_update_key.pubkey()); + let liquidate_instruction = build_liquidate_instruction(&scenario, lease_id, price_update_key.pubkey()); let result = send_transaction_from_instructions( &mut scenario.svm, - vec![liq_ix], + vec![liquidate_instruction], &[&scenario.keeper], &scenario.keeper.pubkey(), ); @@ -800,7 +800,7 @@ fn liquidate_rejects_healthy_position() { #[test] fn liquidate_rejects_mismatched_price_feed() { - // The lessor pinned FEED_ID; we hand the handler a price update whose + // The holder pinned FEED_ID; we hand the handler a price update whose // internal feed_id is different. Even when the price would push the // position underwater, the liquidate call must bail with // `PriceFeedMismatch` before running the undercollateralisation check. @@ -822,8 +822,8 @@ fn liquidate_rejects_mismatched_price_feed() { send_transaction_from_instructions( &mut scenario.svm, vec![create_instruction, take_instruction], - &[&scenario.lessor, &scenario.lessee], - &scenario.lessor.pubkey(), + &[&scenario.holder, &scenario.short_seller], + &scenario.holder.pubkey(), ) .unwrap(); @@ -844,10 +844,10 @@ fn liquidate_rejects_mismatched_price_feed() { now, ); - let liq_ix = build_liquidate_instruction(&scenario, lease_id, price_update_key.pubkey()); + let liquidate_instruction = build_liquidate_instruction(&scenario, lease_id, price_update_key.pubkey()); let result = send_transaction_from_instructions( &mut scenario.svm, - vec![liq_ix], + vec![liquidate_instruction], &[&scenario.keeper], &scenario.keeper.pubkey(), ); @@ -880,8 +880,8 @@ fn close_expired_reclaims_collateral_after_end_timestamp() { send_transaction_from_instructions( &mut scenario.svm, vec![create_instruction, take_instruction], - &[&scenario.lessor, &scenario.lessee], - &scenario.lessor.pubkey(), + &[&scenario.holder, &scenario.short_seller], + &scenario.holder.pubkey(), ) .unwrap(); @@ -892,26 +892,26 @@ fn close_expired_reclaims_collateral_after_end_timestamp() { send_transaction_from_instructions( &mut scenario.svm, vec![close_instruction], - &[&scenario.lessor], - &scenario.lessor.pubkey(), + &[&scenario.holder], + &scenario.holder.pubkey(), ) .unwrap(); - // Full collateral forfeited to the lessor. Leased tokens are gone (the - // lessee kept them on default) so the lessor's leased balance is only + // Full collateral forfeited to the holder. Leased tokens are gone (the + // short_seller kept them on default) so the holder's leased balance is only // what they had after the initial escrow minus the leased amount. - let lessor_collateral_associated_token_account = derive_associated_token_account(&scenario.lessor.pubkey(), &scenario.collateral_mint); + let holder_collateral_associated_token_account = derive_associated_token_account(&scenario.holder.pubkey(), &scenario.collateral_mint); assert_eq!( - get_token_account_balance(&scenario.svm, &lessor_collateral_associated_token_account).unwrap(), + get_token_account_balance(&scenario.svm, &holder_collateral_associated_token_account).unwrap(), REQUIRED_COLLATERAL ); assert_eq!( - get_token_account_balance(&scenario.svm, &scenario.lessor_leased_associated_token_account).unwrap(), + get_token_account_balance(&scenario.svm, &scenario.holder_leased_associated_token_account).unwrap(), 1_000_000_000 - LEASED_AMOUNT ); let (lease_program_derived_address, leased_vault, collateral_vault) = - lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); assert!(scenario.svm.get_account(&lease_program_derived_address).is_none()); assert!(scenario.svm.get_account(&leased_vault).is_none()); assert!(scenario.svm.get_account(&collateral_vault).is_none()); @@ -936,28 +936,28 @@ fn close_expired_cancels_listed_lease() { send_transaction_from_instructions( &mut scenario.svm, vec![create_instruction], - &[&scenario.lessor], - &scenario.lessor.pubkey(), + &[&scenario.holder], + &scenario.holder.pubkey(), ) .unwrap(); - // Lessor bails before anyone takes the lease — allowed immediately. + // Holder bails before anyone takes the lease — allowed immediately. let close_instruction = build_close_expired_instruction(&scenario, lease_id); send_transaction_from_instructions( &mut scenario.svm, vec![close_instruction], - &[&scenario.lessor], - &scenario.lessor.pubkey(), + &[&scenario.holder], + &scenario.holder.pubkey(), ) .unwrap(); - // Lessor recovered the full leased amount. No collateral was ever posted. + // Holder recovered the full leased amount. No collateral was ever posted. assert_eq!( - get_token_account_balance(&scenario.svm, &scenario.lessor_leased_associated_token_account).unwrap(), + get_token_account_balance(&scenario.svm, &scenario.holder_leased_associated_token_account).unwrap(), 1_000_000_000 ); let (lease_program_derived_address, leased_vault, collateral_vault) = - lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); assert!(scenario.svm.get_account(&lease_program_derived_address).is_none()); assert!(scenario.svm.get_account(&leased_vault).is_none()); assert!(scenario.svm.get_account(&collateral_vault).is_none()); @@ -977,7 +977,7 @@ fn create_lease_rejects_same_mint_for_leased_and_collateral() { // carries the same mint as leased_mint. We bypass `build_create_lease_instruction` // because that helper always wires the two mints from the scenario. let (lease, leased_vault, collateral_vault) = - lease_program_derived_addresses(&scenario.program_id, &scenario.lessor.pubkey(), lease_id); + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); let instruction = Instruction::new_with_bytes( scenario.program_id, &asset_leasing::instruction::CreateLease { @@ -992,11 +992,11 @@ fn create_lease_rejects_same_mint_for_leased_and_collateral() { } .data(), asset_leasing::accounts::CreateLease { - lessor: scenario.lessor.pubkey(), + holder: scenario.holder.pubkey(), leased_mint: scenario.leased_mint, // Same mint on both sides — should be rejected. collateral_mint: scenario.leased_mint, - lessor_leased_account: scenario.lessor_leased_associated_token_account, + holder_leased_account: scenario.holder_leased_associated_token_account, lease, leased_vault, collateral_vault, @@ -1009,8 +1009,8 @@ fn create_lease_rejects_same_mint_for_leased_and_collateral() { let result = send_transaction_from_instructions( &mut scenario.svm, vec![instruction], - &[&scenario.lessor], - &scenario.lessor.pubkey(), + &[&scenario.holder], + &scenario.holder.pubkey(), ); let err = result.expect_err("create_lease must reject identical leased/collateral mints"); From 487b3602cf7d14c0f76fb3cd9aa9b977cfa9ae7a Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 20:11:19 +0000 Subject: [PATCH 17/41] docs(asset-leasing): emphasize key terms on first use; fix ambiguous 'it' in maintenance-margin sentence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bold 'Holders', 'rent out', 'fungible token', 'short sellers' on first occurrence so the canonical terminology stands out to the reader. - The previous sentence said 'if the borrowed asset rallies past the maintenance margin' — the asset's price doesn't cross the maintenance margin (it's a collateral ratio, not a price level). Rewritten to: the asset rallies far enough that the short seller's collateral falls below the maintenance margin. --- defi/asset-leasing/anchor/README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index b62b7a20e..e49f291c6 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -1,11 +1,12 @@ # Asset Leasing -**Directional token lending.** Holders rent out fungible token -inventory to short sellers. Short sellers post collateral, pay a -second-by-second lending fee, and return equivalent tokens before -expiry. If the borrowed asset rallies past the maintenance margin, -keepers liquidate the position; if the asset falls, the short seller -profits and returns equivalent tokens cheaply. +**Directional token lending.** **Holders** **rent out** **fungible +token** inventory to **short sellers**. Short sellers post +collateral, pay a second-by-second lending fee, and return equivalent +tokens before expiry. If the asset's price rallies far enough that +the short seller's collateral falls below the maintenance margin, +keepers liquidate the position; if the asset's price falls, the +short seller profits and returns equivalent tokens cheaply. This is the same primitive that underpins traditional securities lending in TradFi: holders earn yield on inventory they would hold From 255da13354787b0497188e0cbe8b75b3faec1f55 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 20:13:45 +0000 Subject: [PATCH 18/41] docs(asset-leasing): scrub 'fungible token' and 'borrow' as a noun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'Token' already implies fungibility; the qualifier is noise. Bolded only the canonical party names (holder, short seller), not plain-English verbs/nouns. - 'Get the borrow they need' uses 'borrow' as a noun (trader jargon) — confusing for general readers, especially since the lede frames Side B as a 'short seller', not a 'borrower'. Replaced with 'get the tokens they need to sell short'. --- defi/asset-leasing/anchor/README.md | 33 ++++++++++++++--------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index e49f291c6..3c818c718 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -1,18 +1,18 @@ # Asset Leasing -**Directional token lending.** **Holders** **rent out** **fungible -token** inventory to **short sellers**. Short sellers post -collateral, pay a second-by-second lending fee, and return equivalent -tokens before expiry. If the asset's price rallies far enough that -the short seller's collateral falls below the maintenance margin, -keepers liquidate the position; if the asset's price falls, the -short seller profits and returns equivalent tokens cheaply. +**Directional token lending.** **Holders** rent out token inventory +to **short sellers**. Short sellers post collateral, pay a +second-by-second lending fee, and return equivalent tokens before +expiry. If the asset's price rallies far enough that the short +seller's collateral falls below the maintenance margin, keepers +liquidate the position; if the asset's price falls, the short seller +profits and returns equivalent tokens cheaply. This is the same primitive that underpins traditional securities lending in TradFi: holders earn yield on inventory they would hold anyway (think exchange-traded funds, pension funds, or any passive -allocator), and short sellers and arbitrageurs get the borrow they -need. The program is written in +allocator), and short sellers and arbitrageurs get the tokens they +need to sell short. The program is written in [Anchor](https://solana.com/docs/terminology); a parallel [Quasar port](#7-quasar-port) implements the same onchain behaviour. @@ -39,14 +39,13 @@ walks happen. ## 1. What does this program do? -A **holder** offers some quantity of one fungible token — mint **A**, -the "leased mint" — for a fixed term. A **short seller** posts -collateral in a different mint **B** — the "collateral mint" — to -take delivery. The short seller will typically sell the A tokens -immediately on a market like Jupiter, then re-acquire equivalent A -tokens later to close out. Because mint A is fungible, the short -seller only has to return the same *quantity*, not the exact units -they received. +A **holder** offers some quantity of a token — mint **A**, the +"leased mint" — for a fixed term. A **short seller** posts collateral +in a different mint **B** — the "collateral mint" — to take delivery. +The short seller will typically sell the A tokens immediately on a +market like Jupiter, then re-acquire equivalent A tokens later to +close out. The short seller only has to return the same *quantity* +of A, not the exact units they received. The program acts as a non-custodial escrow. It: From 5491142a10e00f1ce09d5f0910496ef61cafc840 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 20:15:05 +0000 Subject: [PATCH 19/41] docs(asset-leasing): drop fungibility explainer Tokens are fungible by default; the reader doesn't need a sentence explaining that 'the same quantity, not the exact units' is fine. Same as not explaining what a string is. --- defi/asset-leasing/anchor/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 3c818c718..7fd2784de 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -44,8 +44,7 @@ A **holder** offers some quantity of a token — mint **A**, the in a different mint **B** — the "collateral mint" — to take delivery. The short seller will typically sell the A tokens immediately on a market like Jupiter, then re-acquire equivalent A tokens later to -close out. The short seller only has to return the same *quantity* -of A, not the exact units they received. +close out. The program acts as a non-custodial escrow. It: From 5141f600344c8b4ba4de5019949555e8df3912a1 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 20:16:39 +0000 Subject: [PATCH 20/41] docs(asset-leasing): make the short seller's full lifecycle explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version mentioned the sell-and-rebuy mechanic in a single passing clause, which buried the whole point of the protocol. Restructured section 1 to walk through the short seller's four-step lifecycle: open, sell A, wait, close — and explained where profit comes from in plain English. --- defi/asset-leasing/anchor/README.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 7fd2784de..0206ff9d5 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -41,10 +41,26 @@ walks happen. A **holder** offers some quantity of a token — mint **A**, the "leased mint" — for a fixed term. A **short seller** posts collateral -in a different mint **B** — the "collateral mint" — to take delivery. -The short seller will typically sell the A tokens immediately on a -market like Jupiter, then re-acquire equivalent A tokens later to -close out. +in a different mint **B** — the "collateral mint" — to take delivery +of the A tokens. + +The short seller's full lifecycle is: + +1. **Open the position.** Borrow A from the holder by posting B as + collateral. Pay a per-second lending fee out of the collateral. +2. **Sell A immediately** on a market like Jupiter, receiving more B + in return. The short seller now has more B and owes A. +3. **Wait.** They are betting A's price (denominated in B) will fall. +4. **Close the position.** Buy A back on the open market — hopefully + at a lower price than they sold it for — and return the same + quantity of A to the holder. The B they paid to re-acquire A is + less than the B they got for selling it, and the difference is + the short seller's profit. + +If A's price *rises* instead, buying it back costs more B than they +got for selling it — that's a loss. If it rises far enough that their +locked collateral is no longer worth more than the A they owe, they +get liquidated (see below). The program acts as a non-custodial escrow. It: From 94b58f344d30fd3257834e6b36481e05721b2fde Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 20:19:56 +0000 Subject: [PATCH 21/41] docs(asset-leasing): include the sell-and-rebuy step in the intro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lede skipped from 'post collateral' to 'return tokens' without explaining the trade in between. That trade is the whole point of the protocol — short sellers sell the borrowed tokens immediately, wait for the price to drop, then re-buy and return. Now front-loaded in the intro. --- defi/asset-leasing/anchor/README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 0206ff9d5..6ec9f870e 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -1,12 +1,14 @@ # Asset Leasing **Directional token lending.** **Holders** rent out token inventory -to **short sellers**. Short sellers post collateral, pay a -second-by-second lending fee, and return equivalent tokens before -expiry. If the asset's price rallies far enough that the short -seller's collateral falls below the maintenance margin, keepers -liquidate the position; if the asset's price falls, the short seller -profits and returns equivalent tokens cheaply. +to **short sellers**. The short seller posts collateral, takes +delivery of the borrowed tokens, and immediately sells them on the +open market. They pay a second-by-second lending fee while the +position is open, then buy equivalent tokens back later and return +them to the holder. If the price falls between the sell and the +re-buy, the short seller profits on the difference; if the price +rallies far enough that their collateral falls below the maintenance +margin, keepers liquidate the position. This is the same primitive that underpins traditional securities lending in TradFi: holders earn yield on inventory they would hold From 35afc7db5bc85d59ae1b44a54a1590646ac30b74 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 20:30:09 +0000 Subject: [PATCH 22/41] docs(asset-leasing): make the two-mint asymmetry obvious in the intro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous wording — 'posts collateral, takes delivery of the borrowed tokens, and immediately sells them' — sounded circular. A reader could parse it as 'deposit X, get X back, sell X' which is pointless. The actual flow uses TWO different mints: post stable collateral (USDC), borrow the asset being shorted (xNVDA), sell xNVDA for more USDC, buy xNVDA back later. Spelled this out explicitly with a concrete example so the asymmetry is clear from the first paragraph. --- defi/asset-leasing/anchor/README.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 6ec9f870e..8c0d74aef 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -1,14 +1,15 @@ # Asset Leasing **Directional token lending.** **Holders** rent out token inventory -to **short sellers**. The short seller posts collateral, takes -delivery of the borrowed tokens, and immediately sells them on the -open market. They pay a second-by-second lending fee while the -position is open, then buy equivalent tokens back later and return -them to the holder. If the price falls between the sell and the -re-buy, the short seller profits on the difference; if the price -rallies far enough that their collateral falls below the maintenance -margin, keepers liquidate the position. +to **short sellers**. The short seller posts collateral in a stable +asset (e.g. USDC) and borrows the asset they want to short (e.g. +xNVDA). They immediately sell the borrowed xNVDA on the open market +for more USDC, pay a second-by-second lending fee while the position +is open, and later buy equivalent xNVDA back to return to the +holder. If xNVDA's price falls between the sell and the re-buy, the +short seller pockets the difference in USDC; if xNVDA rallies far +enough that their collateral no longer covers the cost of buying it +back, keepers liquidate the position. This is the same primitive that underpins traditional securities lending in TradFi: holders earn yield on inventory they would hold From 114eb7a9408ed87f3528109a27d1d2596555c498 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 20:34:26 +0000 Subject: [PATCH 23/41] docs(asset-leasing): drop the README-previews-itself paragraph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'every instruction handler is walked through... background sections below define X before code walks happen' paragraph is redundant filler — the table of contents and section headlines already tell the reader what's coming. Deleted. Moved the inline solana.com/docs/terminology link for 'instruction handler' to its first occurrence in section 3, where the term is also explained in prose. --- defi/asset-leasing/anchor/README.md | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 8c0d74aef..8498fb684 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -19,12 +19,6 @@ need to sell short. The program is written in [Anchor](https://solana.com/docs/terminology); a parallel [Quasar port](#7-quasar-port) implements the same onchain behaviour. -Every [instruction handler](https://solana.com/docs/terminology) is -walked through with the exact token movements it causes. The -background sections below define the financial concepts (collateral, -maintenance margin, oracles) and the onchain wiring before any code -walks happen. - --- ## Table of contents @@ -293,9 +287,10 @@ record the terminal state, but the account disappears at the end. An instruction on Solana is the input sent in a transaction — a program id, a list of accounts, and a byte payload. The Rust function -that runs when an instruction arrives is the *instruction handler*. -This program has seven instruction handlers. The natural order a -user encounters them — the order below — is: +that runs when an instruction arrives is the +[instruction handler](https://solana.com/docs/terminology). This +program has seven instruction handlers. The natural order a user +encounters them — the order below — is: 1. `create_lease` (holder) 2. `take_lease` (short seller) From 25f5f8b1b0e5ef588f093ec088ec8aad3b9fbf7b Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 20:37:08 +0000 Subject: [PATCH 24/41] docs(asset-leasing): remove ASCII-art lifecycle diagram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ASCII art renders inconsistently across viewers (different fonts, different terminal widths) and looks bad even when it works. Kept the prose paragraph that follows it explaining the Closed/Liquidated states aren't directly observable onchain — that's the useful bit. State transitions are already covered narratively in section 3 (per-instruction walkthrough) and section 4 (full-lifecycle worked examples). A diagram on top is redundant. --- defi/asset-leasing/anchor/README.md | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 8498fb684..de4571f2e 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -252,28 +252,6 @@ pub struct Lease { } ``` -### Lifecycle diagram - -``` - create_lease - +---------------+ - (no lease) -> | Listed | - +---------------+ - | | - take_lease | | close_expired (holder cancels) - v v - +---------------+ +--------+ - | Active | ----> | Closed | - +---------------+ +--------+ - | | | - return_lease| | | close_expired (after end_timestamp) - | | liquidate - v v v - +--------+ +-----------+ - | Closed | | Liquidated| - +--------+ +-----------+ -``` - The `Closed` and `Liquidated` states are not directly observable onchain: all three of `return_lease`, `liquidate` and `close_expired` close the `Lease` account in the same transaction (`close = holder`), From a593158e660259f70930bb848a9d394e7e962977 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 21:14:43 +0000 Subject: [PATCH 25/41] asset-leasing: merge per-instruction reference and worked-examples sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README had two parallel structures covering the same ground: - §3 "Instruction handler lifecycle walkthrough" listed each of the seven instruction handlers in isolation (signers, accounts, what happens, errors). - §4 "Full-lifecycle worked examples" walked through five scenarios that called sequences of those same handlers. A reader had to flip between §3 (reference) and §4 (narrative) to follow what any given scenario actually did, and the happy-path scenario in §4.1 simply re-traced the §3 reference with concrete numbers attached. Replace both with a single integrated "3. Lifecycle" section: - 3.1–3.7 walk the program in narrative order — holder lists tokens, short seller takes the offer, lease fee streams, short seller defends or closes, and the two failure-mode branches (liquidate, close_expired). Each handler's full mechanics (signers, accounts, what happens, errors) are listed inline as bullet lists at first appearance. - 3.8 keeps the four pedagogically-valuable branch scenarios from old §4 (liquidation, falling-price profit, default on Active, cancel on Listed) with their concrete numbers, but rewrites them as concise branches that reference the integrated §3 prose rather than re-explaining the mechanics. The old §4.1 happy-path is dropped because §3.5 now covers it. Renumber later sections (Safety §5→§4, Tests §6→§5, Quasar §7→§6, Extending §8→§7), update the table of contents, and fix all cross-references including the §1 link to Quasar port and the §1 "see §X" pointers. Drop all ASCII flow-arrow diagrams in favour of bullet-list prose, per house style. No Mermaid added. Other style fixes applied while rewriting: replace remaining uses of "protocol" with "program" (Solana onchain code is a program; "protocol" is EVM jargon), use "instruction handler" not "instruction" when referring to the function/code, and link program-derived address / associated token account / cross-program invocation / instruction handler inline to solana.com/docs/terminology on first occurrence. --- defi/asset-leasing/anchor/README.md | 1122 ++++++++++++--------------- 1 file changed, 484 insertions(+), 638 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index de4571f2e..35449a1b1 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -17,7 +17,7 @@ anyway (think exchange-traded funds, pension funds, or any passive allocator), and short sellers and arbitrageurs get the tokens they need to sell short. The program is written in [Anchor](https://solana.com/docs/terminology); a parallel -[Quasar port](#7-quasar-port) implements the same onchain behaviour. +[Quasar port](#6-quasar-port) implements the same onchain behaviour. --- @@ -25,12 +25,11 @@ need to sell short. The program is written in 1. [What does this program do?](#1-what-does-this-program-do) 2. [Accounts and program-derived addresses](#2-accounts-and-program-derived-addresses) -3. [Instruction handler lifecycle walkthrough](#3-instruction-handler-lifecycle-walkthrough) -4. [Full-lifecycle worked examples](#4-full-lifecycle-worked-examples) -5. [Safety and edge cases](#5-safety-and-edge-cases) -6. [Running the tests](#6-running-the-tests) -7. [Quasar port](#7-quasar-port) -8. [Extending the program](#8-extending-the-program) +3. [Lifecycle](#3-lifecycle) +4. [Safety and edge cases](#4-safety-and-edge-cases) +5. [Running the tests](#5-running-the-tests) +6. [Quasar port](#6-quasar-port) +7. [Extending the program](#7-extending-the-program) --- @@ -166,8 +165,8 @@ rallies against the collateral. A drop in the borrowed asset price is purely beneficial to the short seller. The streaming lending fee is the position's only ongoing cost in either direction. -§4 walks the onchain token flows for each path with abstract numbers -that match the LiteSVM tests; the example above is the same machinery +§3 walks each instruction handler with concrete numbers that match +the LiteSVM tests; the xNVDA example above is the same machinery applied to a real asset pair. ### Production deviations to know @@ -177,7 +176,7 @@ applied to a real asset pair. inline in `liquidate.rs`. Production code would depend on the `pyth-solana-receiver-sdk` crate so layout changes are caught at compile time. -- See §5 for the rest of the deliberate simplifications. +- See §4 for the rest of the deliberate simplifications. --- @@ -261,642 +260,488 @@ record the terminal state, but the account disappears at the end. --- -## 3. Instruction handler lifecycle walkthrough - -An instruction on Solana is the input sent in a transaction — a -program id, a list of accounts, and a byte payload. The Rust function -that runs when an instruction arrives is the -[instruction handler](https://solana.com/docs/terminology). This -program has seven instruction handlers. The natural order a user -encounters them — the order below — is: - -1. `create_lease` (holder) -2. `take_lease` (short seller) -3. `pay_lease_fee` (anyone) -4. `top_up_collateral` (short seller) -5. `return_lease` (short seller) — **happy path** -6. `liquidate` (keeper) — **adversarial path** -7. `close_expired` (holder) — **default / cancel path** - -For each, the shape is the same: who signs, what accounts go in, -which program-derived addresses get created or closed, which tokens -move, what state changes, what checks the program runs. - -Token-flow diagrams use the following shorthand: - -``` - --[amount of ]--> -``` - -### 3.1 `create_lease` - -**Who calls it:** the holder. The holder wants to offer some number of -leased tokens for a fixed term against collateral of a different mint. - -**Signers:** `holder`. - -**Parameters:** - -```rust -pub fn create_lease( - context: Context, - lease_id: u64, - leased_amount: u64, - required_collateral_amount: u64, - lease_fee_per_second: u64, - duration_seconds: i64, - maintenance_margin_basis_points: u16, - liquidation_bounty_basis_points: u16, - feed_id: [u8; 32], -) -> Result<()> -``` - -**Accounts in:** - -- `holder` (signer, mut — pays account rent) -- `leased_mint`, `collateral_mint` (read-only) -- `holder_leased_account` (mut, holder's associated token account for the leased mint — source) -- `lease` (program-derived address, **init**) — created here -- `leased_vault` (program-derived address, **init**, token account) — created here -- `collateral_vault` (program-derived address, **init**, token account) — created here -- `token_program`, `system_program` - -**program-derived addresses created:** - -- `lease` with seeds `[b"lease", holder, lease_id.to_le_bytes()]` -- `leased_vault` with seeds `[b"leased_vault", lease]`, authority = itself -- `collateral_vault` with seeds `[b"collateral_vault", lease]`, authority = itself - -**Checks (from `handle_create_lease`):** - -- `leased_mint != collateral_mint` → `LeasedMintEqualsCollateralMint` -- `leased_amount > 0` → `InvalidLeasedAmount` -- `required_collateral_amount > 0` → `InvalidCollateralAmount` -- `lease_fee_per_second > 0` → `InvalidLeaseFeePerSecond` -- `duration_seconds > 0` → `InvalidDuration` -- `0 < maintenance_margin_basis_points <= 50_000` → `InvalidMaintenanceMargin` -- `liquidation_bounty_basis_points <= 2_000` → `InvalidLiquidationBounty` - -**Token movements:** - -``` - holder_leased_account --[leased_amount of leased_mint]--> leased_vault program-derived address -``` - -**State changes:** - -- New `Lease` account written with `status = Listed`, `short_seller = - Pubkey::default()`, `collateral_amount = 0`, `start_timestamp = 0`, - `end_timestamp = 0`, `last_paid_timestamp = 0`, and the given - parameters including `feed_id`. All three bumps stored. - -**Why lock the leased tokens up-front rather than on `take_lease`?** -So a short seller who calls `take_lease` cannot possibly fail because -the holder doesn't have the tokens any more — the atomicity guarantee -is transferred to the program-derived address the moment the lease is -listed. - -### 3.2 `take_lease` - -**Who calls it:** the short seller. The short seller has seen the -`Lease` account onchain (somehow — an indexer, a direct lookup, -whatever) and wants to take delivery. - -**Signers:** `short_seller`. - -**Accounts in:** - -- `short_seller` (signer, mut) -- `holder` (UncheckedAccount — read for program-derived address seed - derivation only, no signature required) -- `lease` (mut, `has_one = holder`, `has_one = leased_mint`, - `has_one = collateral_mint`, must be `Listed`) -- `leased_mint`, `collateral_mint` -- `leased_vault`, `collateral_vault` (both mut, both program-derived address-derived) -- `short_seller_collateral_account` (mut, short seller's associated token account — source) -- `short_seller_leased_account` (mut, **init_if_needed** — destination) -- `token_program`, `associated_token_program`, `system_program` - -**Checks:** - -- `lease.status == Listed` → `InvalidLeaseStatus` -- `lease.holder == holder.key()` (Anchor `has_one`) -- `lease.leased_mint == leased_mint.key()` (Anchor `has_one`) -- `lease.collateral_mint == collateral_mint.key()` (Anchor `has_one`) - -**Token movements (in order):** - -``` - short_seller_collateral_account --[required_collateral_amount of collateral_mint]--> collateral_vault program-derived address - leased_vault program-derived address --[leased_amount of leased_mint]----------> short_seller_leased_account -``` - -Collateral is deposited *first* so if the leased-token transfer fails -for any reason the whole transaction reverts and the short seller -gets their collateral back. - -**State changes:** - -- `lease.short_seller = short_seller.key()` -- `lease.collateral_amount = required_collateral_amount` -- `lease.start_timestamp = now` -- `lease.end_timestamp = now + duration_seconds` (checked add, errors on overflow) -- `lease.last_paid_timestamp = now` (nothing has accrued yet) -- `lease.status = Active` - -### 3.3 `pay_lease_fee` - -**Who calls it:** anyone. The short seller's incentive is obvious -(keep the lease from going underwater); a keeper bot may also push a -lease fee payment before a liquidation check so healthy leases stay -healthy. - -**Signers:** `payer` (any signer). - -**Accounts in:** - -- `payer` (signer, mut — pays for `init_if_needed` of the holder associated token account) -- `holder` (UncheckedAccount, read-only — used for `has_one` check) -- `lease` (mut, must be `Active`) -- `collateral_mint`, `collateral_vault` -- `holder_collateral_account` (mut, **init_if_needed**) -- `token_program`, `associated_token_program`, `system_program` - -**Lease fee math:** - -```rust -pub fn compute_lease_fee_due(lease: &Lease, now: i64) -> Result { - let cutoff = now.min(lease.end_timestamp); - if cutoff <= lease.last_paid_timestamp { - return Ok(0); - } - let elapsed = (cutoff - lease.last_paid_timestamp) as u64; - elapsed.checked_mul(lease.lease_fee_per_second) - .ok_or(AssetLeasingError::MathOverflow.into()) -} -``` - -Lease fees do not accrue past `end_timestamp`. Past the deadline the -short seller is either returning the tokens (via `return_lease`), -being liquidated, or defaulting — no more lease fees are owed. - -**Token movements:** - -``` - collateral_vault program-derived address --[min(lease_fee_due, collateral_amount) of collateral_mint]--> holder_collateral_account -``` - -If the vault does not have enough collateral to cover the full -`lease_fee_due`, the handler pays out whatever is there and leaves the -residual as a debt the next liquidation (or `close_expired`) will -clean up. - -**State changes:** - -- `lease.collateral_amount -= payable` -- `lease.last_paid_timestamp = now.min(end_timestamp)` - -### 3.4 `top_up_collateral` - -**Who calls it:** the short seller — to defend against a looming -liquidation by adding more of the collateral mint to the vault. - -**Signers:** `short_seller`. - -**Accounts in:** - -- `short_seller` (signer) -- `holder` (UncheckedAccount, read-only) -- `lease` (mut, `has_one = holder`, `has_one = collateral_mint`, - `constraint lease.short_seller == short_seller.key()`, must be `Active`) -- `collateral_mint`, `collateral_vault` -- `short_seller_collateral_account` (mut, source) -- `token_program` - -**Parameter:** `amount: u64` — how much to add. - -**Checks:** - -- `amount > 0` → `InvalidCollateralAmount` -- `lease.short_seller == short_seller.key()` → `Unauthorised` -- `lease.status == Active` → `InvalidLeaseStatus` - -**Token movements:** - -``` - short_seller_collateral_account --[amount of collateral_mint]--> collateral_vault program-derived address -``` - -**State changes:** - -- `lease.collateral_amount += amount` (checked add) - -### 3.5 `return_lease` - -**Who calls it:** the short seller, while the lease is still `Active` -and before or after `end_timestamp` (the only timing rule is that -`status == Active`; lease fees only accrue up to `end_timestamp` so -returning after the deadline does not pile on extra charges). - -**Signers:** `short_seller`. - -**Accounts in:** - -- `short_seller` (signer, mut) -- `holder` (UncheckedAccount, mut — receives Lease and vault rent-exempt - lamports via `close = holder`) -- `lease` (mut, `close = holder`, must be `Active`, `short_seller == short_seller.key()`) -- `leased_mint`, `collateral_mint` -- `leased_vault`, `collateral_vault` (both mut) -- `short_seller_leased_account` (mut, source for the return) -- `short_seller_collateral_account` (mut, destination for the refund) -- `holder_leased_account` (mut, **init_if_needed**) -- `holder_collateral_account` (mut, **init_if_needed**) -- `token_program`, `associated_token_program`, `system_program` - -**Checks:** - -- `lease.status == Active` → `InvalidLeaseStatus` -- `lease.short_seller == short_seller.key()` → `Unauthorised` - -**Token movements (in order):** - -``` - short_seller_leased_account --[leased_amount of leased_mint]--------------> leased_vault program-derived address - leased_vault program-derived address --[leased_amount of leased_mint]--------------> holder_leased_account - collateral_vault program-derived address --[lease_fee_payable of collateral_mint]------> holder_collateral_account - collateral_vault program-derived address --[collateral_after_lease_fees of collateral_mint]--> short_seller_collateral_account -``` - -The leased tokens hop through the vault rather than going direct -short-seller → holder because the vault's token account is already -set up and the program can reuse its program-derived address signing -path. The atomic round-trip keeps the vault's post-instruction balance -at 0 so the vault can be closed. - -After the transfers: - -- Both vaults are closed via `close_account` [cross-program invocations](https://solana.com/docs/terminology); their rent-exempt - lamports go to the holder. -- The `Lease` account is closed via Anchor's `close = holder` - constraint; the `Lease` rent-exempt lamports go to the holder too. - -**State changes before close:** - -- `lease.last_paid_timestamp = now.min(end_timestamp)` -- `lease.collateral_amount = 0` -- `lease.status = Closed` - -### 3.6 `liquidate` - -**Who calls it:** a keeper, when the keeper can prove the position is -underwater. - -**Signers:** `keeper`. - -**Accounts in:** - -- `keeper` (signer, mut — pays `init_if_needed` cost for both associated token accounts) -- `holder` (UncheckedAccount, mut — receives the lease fee + holder_share + the - `Lease` and vault rent-exempt lamports) -- `lease` (mut, `close = holder`, must be `Active`) -- `leased_mint`, `collateral_mint` -- `leased_vault`, `collateral_vault` (both mut) -- `holder_collateral_account` (mut, **init_if_needed**) -- `keeper_collateral_account` (mut, **init_if_needed**) -- `price_update` (UncheckedAccount, constrained to `owner = - PYTH_RECEIVER_PROGRAM_ID`) -- `token_program`, `associated_token_program`, `system_program` - -**Checks (in order, early-out on failure):** - -1. `price_update.owner == Pyth Receiver program id` (Anchor `owner =`) -2. Account data decodes as `PriceUpdateV2` (first 8 bytes match - `PRICE_UPDATE_V2_DISCRIMINATOR`; length ≥ 89 bytes) — else - `StalePrice` -3. `decoded.feed_id == lease.feed_id` → `PriceFeedMismatch` -4. `publish_time <= now` (no future stamps) and - `now - publish_time <= 60 seconds` → `StalePrice` -5. `price > 0` → `NonPositivePrice` -6. `is_underwater(lease, price, now) == true` → `PositionHealthy` -7. `lease.status == Active` (Anchor constraint on the `lease` field) - -The underwater check, in integers: - -``` - collateral_value_in_collateral_units * 10_000 - < debt_value_in_collateral_units * maintenance_margin_basis_points -``` - -where `debt_value = leased_amount * price * 10^exponent` (with the -exponent folded into whichever side keeps the math non-negative, see -[`is_underwater`](programs/asset-leasing/src/instructions/liquidate.rs)). - -**Token movements:** - -``` - collateral_vault program-derived address --[lease_fee_payable of collateral_mint]------------------> holder_collateral_account - collateral_vault program-derived address --[bounty = remaining * bounty_basis_points / 10_000]------> keeper_collateral_account - collateral_vault program-derived address --[remaining - bounty of collateral_mint]------------------> holder_collateral_account - leased_vault program-derived address --[0 of leased_mint] (empty — short seller kept the tokens) close only -``` - -After the three outbound collateral transfers (lease fee, bounty, -holder share) the collateral_vault is empty. Both vaults are then -closed — their rent-exempt lamports go to the holder. The `Lease` -account is closed the same way (Anchor `close = holder`). - -**State changes before close:** - -- `lease.collateral_amount = 0` -- `lease.last_paid_timestamp = now.min(end_timestamp)` -- `lease.status = Liquidated` - -### 3.7 `close_expired` - -**Who calls it:** the holder. Two very different situations collapse -into this single handler: - -- **Cancel a `Listed` lease** — the holder changes their mind, no-one - has taken the lease yet. Allowed any time. -- **Reclaim collateral after default** — the lease is `Active`, `now >= - end_timestamp`, the short seller has not called `return_lease`. The - holder takes the whole collateral vault as compensation. - -**Signers:** `holder`. +## 3. Lifecycle + +This section walks the program from listing to close. Each +[instruction handler](https://solana.com/docs/terminology) is +introduced in narrative order; the first time a handler appears, its +full mechanics are listed inline (signers, accounts, what happens, +errors). After the happy path is covered, two branch handlers — +`liquidate` and `close_expired` — handle the failure modes. The +section ends with four worked branch scenarios using concrete numbers +that match the LiteSVM tests one-to-one. + +The program has seven instruction handlers in total: + +- `create_lease` — holder lists tokens +- `take_lease` — short seller takes the offer +- `pay_lease_fee` — settle accrued lease fee +- `top_up_collateral` — short seller adds collateral +- `return_lease` — short seller closes cleanly +- `liquidate` — keeper closes an underwater position +- `close_expired` — holder closes a stale or defaulted lease + +### 3.1 The holder lists the tokens — `create_lease` + +The holder calls `create_lease`, naming the leased mint, the +collateral mint, the amount of leased tokens to offer, the +collateral the short seller will have to post, the per-second lease +fee, the duration, the maintenance-margin and liquidation-bounty +ratios, and the Pyth `feed_id` the lease will be priced against. The +program creates the `Lease` account, creates two empty token vault +[program-derived addresses](https://solana.com/docs/terminology) (one for each +mint), and moves the leased tokens out of the holder's wallet into +the leased vault. Locking the leased tokens up front means a short +seller calling `take_lease` later cannot fail because the holder +spent the inventory in the meantime — the atomicity guarantee +transfers to the program the moment the lease is listed. + +- **Signers:** `holder`. +- **Accounts:** + - `holder` (signer, mut — pays account rent) + - `leased_mint`, `collateral_mint` (read-only) + - `holder_leased_account` (mut, holder's [associated token account](https://solana.com/docs/terminology) for the leased mint — source) + - `lease` (program-derived address, **init**) — created here, seeds `[b"lease", holder, lease_id.to_le_bytes()]` + - `leased_vault` (program-derived address, **init**, token account) — created here, seeds `[b"leased_vault", lease]`, authority = itself + - `collateral_vault` (program-derived address, **init**, token account) — created here, seeds `[b"collateral_vault", lease]`, authority = itself + - `token_program`, `system_program` +- **What happens:** + - Single token movement: `leased_amount` of the leased mint + transfers from `holder_leased_account` to `leased_vault`. + - The `Lease` account is written with `status = Listed`, + `short_seller = Pubkey::default()`, `collateral_amount = 0`, + `start_timestamp = 0`, `end_timestamp = 0`, + `last_paid_timestamp = 0`, and the supplied parameters including + `feed_id`. All three bumps are stored. +- **Errors:** + - `LeasedMintEqualsCollateralMint` if `leased_mint == collateral_mint` + - `InvalidLeasedAmount` if `leased_amount == 0` + - `InvalidCollateralAmount` if `required_collateral_amount == 0` + - `InvalidLeaseFeePerSecond` if `lease_fee_per_second == 0` + - `InvalidDuration` if `duration_seconds <= 0` + - `InvalidMaintenanceMargin` if `maintenance_margin_basis_points` is `0` or `> 50_000` + - `InvalidLiquidationBounty` if `liquidation_bounty_basis_points > 2_000` + +### 3.2 The short seller takes the offer — `take_lease` + +A short seller who has spotted the `Lease` account onchain (via an +indexer or a direct lookup) calls `take_lease` to take delivery. The +program deposits the short seller's collateral first, then hands over +the leased tokens — depositing collateral first means that if the +leased-token payout fails for any reason the whole transaction +reverts and the short seller gets their collateral back. The lease +moves from `Listed` to `Active`. + +- **Signers:** `short_seller`. +- **Accounts:** + - `short_seller` (signer, mut) + - `holder` (UncheckedAccount — read for program-derived address seed derivation only, no signature required) + - `lease` (mut, `has_one = holder`, `has_one = leased_mint`, `has_one = collateral_mint`, must be `Listed`) + - `leased_mint`, `collateral_mint` + - `leased_vault`, `collateral_vault` (both mut, both program-derived addresses) + - `short_seller_collateral_account` (mut, short seller's associated token account — source) + - `short_seller_leased_account` (mut, **init_if_needed** — destination) + - `token_program`, `associated_token_program`, `system_program` +- **What happens:** + - Two token movements, in order: + 1. `required_collateral_amount` of the collateral mint moves + from `short_seller_collateral_account` into `collateral_vault`. + 2. `leased_amount` of the leased mint moves from `leased_vault` + to `short_seller_leased_account`. + - State changes on `lease`: + - `short_seller = short_seller.key()` + - `collateral_amount = required_collateral_amount` + - `start_timestamp = now` + - `end_timestamp = now + duration_seconds` (checked add) + - `last_paid_timestamp = now` (nothing has accrued yet) + - `status = Active` +- **Errors:** + - `InvalidLeaseStatus` if the lease is not `Listed` + - Anchor `has_one` mismatch errors if `holder`, `leased_mint`, or + `collateral_mint` do not match the values stored on the lease + - `MathOverflow` if `now + duration_seconds` overflows `i64` + +### 3.3 The lease fee streams — `pay_lease_fee` + +The lease fee accrues second by second out of the collateral vault. +Anyone can call `pay_lease_fee` to settle whatever has accrued since +the last settlement: the short seller has the obvious incentive (keep +the position out of liquidation), and a keeper bot may push a payment +before checking margins so healthy leases stay healthy. The fee +formula is `(min(now, end_timestamp) - last_paid_timestamp) * +lease_fee_per_second`, capped at the collateral actually sitting in +the vault. Fees do not accrue past `end_timestamp` — once the +deadline hits, the short seller is either returning the tokens, +being liquidated, or defaulting; no further lease fees are owed. + +- **Signers:** `payer` (anyone). +- **Accounts:** + - `payer` (signer, mut — pays for `init_if_needed` of the holder associated token account) + - `holder` (UncheckedAccount, read-only — used for `has_one` check) + - `lease` (mut, must be `Active`) + - `collateral_mint`, `collateral_vault` + - `holder_collateral_account` (mut, **init_if_needed**) + - `token_program`, `associated_token_program`, `system_program` +- **What happens:** + - Compute `lease_fee_due = (min(now, end_timestamp) - last_paid_timestamp) * lease_fee_per_second`. + - Compute `payable = min(lease_fee_due, lease.collateral_amount)`. + - If `payable > 0`, transfer `payable` of the collateral mint from + `collateral_vault` to `holder_collateral_account`. + - State changes: `lease.collateral_amount -= payable`, + `lease.last_paid_timestamp = min(now, end_timestamp)`. + - If the vault did not have enough collateral to cover the full + `lease_fee_due`, the residual is silently left as a debt the next + `liquidate` or `close_expired` call cleans up. (See §4 for the + rationale on this trade-off.) +- **Errors:** + - `InvalidLeaseStatus` if the lease is not `Active` + - `MathOverflow` if `elapsed * lease_fee_per_second` overflows `u64` + +### 3.4 The short seller defends the position — `top_up_collateral` + +If the price moves against the short seller and the position drifts +toward the maintenance-margin floor, the short seller can add more +collateral to push the ratio back up. They call `top_up_collateral` +with an `amount` of the collateral mint, which the program transfers +straight into `collateral_vault` and adds to `lease.collateral_amount`. +The short seller can call this any number of times while the lease +is `Active`. + +- **Signers:** `short_seller`. +- **Parameter:** `amount: u64` — how much collateral to add. +- **Accounts:** + - `short_seller` (signer) + - `holder` (UncheckedAccount, read-only) + - `lease` (mut, `has_one = holder`, `has_one = collateral_mint`, must be `Active`, must be the same `short_seller`) + - `collateral_mint`, `collateral_vault` + - `short_seller_collateral_account` (mut, source) + - `token_program` +- **What happens:** + - Transfer `amount` of the collateral mint from + `short_seller_collateral_account` into `collateral_vault`. + - `lease.collateral_amount += amount` (checked add). +- **Errors:** + - `InvalidCollateralAmount` if `amount == 0` + - `Unauthorised` if `lease.short_seller != short_seller.key()` + - `InvalidLeaseStatus` if the lease is not `Active` + - `MathOverflow` if the addition overflows `u64` + +### 3.5 The short seller closes — `return_lease` + +To close the position, the short seller buys back the leased tokens +on the open market and calls `return_lease`. The program runs the +full settlement in a single transaction: leased tokens move from the +short seller back to the holder, accrued lease fees move from the +collateral vault to the holder, the leftover collateral refunds to +the short seller, and both vaults plus the `Lease` account close. +The handler accepts a return at any time while `status == Active` — +returning before `end_timestamp` just means lease fees stop accruing +the moment the call lands; returning after `end_timestamp` does not +pile on extra charges because the fee formula already caps elapsed +time at `end_timestamp`. + +- **Signers:** `short_seller`. +- **Accounts:** + - `short_seller` (signer, mut) + - `holder` (UncheckedAccount, mut — receives `Lease` and vault rent-exempt lamports via `close = holder`) + - `lease` (mut, `close = holder`, must be `Active`, must be the same `short_seller`) + - `leased_mint`, `collateral_mint` + - `leased_vault`, `collateral_vault` (both mut) + - `short_seller_leased_account` (mut, source for the return) + - `short_seller_collateral_account` (mut, destination for the collateral refund) + - `holder_leased_account` (mut, **init_if_needed**) + - `holder_collateral_account` (mut, **init_if_needed**) + - `token_program`, `associated_token_program`, `system_program` +- **What happens:** + - Four token movements, in order: + 1. `leased_amount` of the leased mint moves from + `short_seller_leased_account` into `leased_vault`. + 2. The same `leased_amount` moves out of `leased_vault` into + `holder_leased_account`. The leased tokens hop through the + vault rather than going direct from short seller to holder so + the program can reuse the vault's program-derived-address + signing path; the atomic round-trip leaves the vault empty + and ready to close. + 3. `lease_fee_payable = min(lease_fee_due, lease.collateral_amount)` + of the collateral mint moves from `collateral_vault` to + `holder_collateral_account`. + 4. The remaining `lease.collateral_amount - lease_fee_payable` + refunds from `collateral_vault` to `short_seller_collateral_account`. + - Both vaults close via `close_account` [cross-program invocations](https://solana.com/docs/terminology); + their rent-exempt lamports go to the holder. The `Lease` account + closes via Anchor's `close = holder` constraint, with its + rent-exempt lamports going to the holder too. + - State changes before close: + `lease.last_paid_timestamp = min(now, end_timestamp)`, + `lease.collateral_amount = 0`, `lease.status = Closed`. +- **Errors:** + - `InvalidLeaseStatus` if the lease is not `Active` + - `Unauthorised` if `lease.short_seller != short_seller.key()` + - `MathOverflow` if the lease-fee or collateral subtraction overflows + +### 3.6 Branch: position underwater — `liquidate` + +If the leased asset rallies far enough that the locked collateral is +no longer worth more than the debt times the maintenance margin, +anyone — typically a keeper bot — can call `liquidate` with a fresh +Pyth price update. The program decodes the update by hand +(production code would use `pyth-solana-receiver-sdk`; the LiteSVM +tests install a `PriceUpdateV2` account whose layout is parsed +inline), checks the position is genuinely underwater, settles the +accrued lease fee to the holder, pays the keeper a bounty out of +what remains, and sends the rest to the holder. The leased tokens +stay with the short seller — the collateral is the holder's +compensation for the lost asset. + +The underwater check, in integers, is: + +`collateral_value * 10_000 < debt_value * maintenance_margin_basis_points` + +where `debt_value = leased_amount * price * 10^exponent`, with the +Pyth exponent folded into whichever side of the inequality keeps the +math non-negative (see [`is_underwater`](programs/asset-leasing/src/instructions/liquidate.rs)). + +- **Signers:** `keeper`. +- **Accounts:** + - `keeper` (signer, mut — pays `init_if_needed` cost for both associated token accounts) + - `holder` (UncheckedAccount, mut — receives lease fee, holder share, and the rent-exempt lamports from the three closed accounts) + - `lease` (mut, `close = holder`, must be `Active`) + - `leased_mint`, `collateral_mint` + - `leased_vault`, `collateral_vault` (both mut) + - `holder_collateral_account` (mut, **init_if_needed**) + - `keeper_collateral_account` (mut, **init_if_needed**) + - `price_update` (UncheckedAccount, constrained to `owner = PYTH_RECEIVER_PROGRAM_ID`) + - `token_program`, `associated_token_program`, `system_program` +- **What happens:** + - Decode `price_update`: discriminator must match + `PRICE_UPDATE_V2_DISCRIMINATOR`, account length ≥ 89 bytes, + `feed_id` must equal `lease.feed_id`, + `0 < now - publish_time <= 60 seconds`, `price > 0`. The + decoded `feed_id` check is the **feed-pinning** guard — without + it a keeper could pass any feed the Pyth Receiver program owns + (a wildly volatile pair that happens to be dipping, say) to + force a spurious liquidation. + - Confirm `is_underwater` returns true. + - Three collateral movements, in order: + 1. `lease_fee_payable = min(lease_fee_due, lease.collateral_amount)` + of the collateral mint moves from `collateral_vault` to + `holder_collateral_account`. + 2. `bounty = (remaining * liquidation_bounty_basis_points) / 10_000` + moves from `collateral_vault` to `keeper_collateral_account`, + where `remaining = lease.collateral_amount - lease_fee_payable`. + 3. `remaining - bounty` moves from `collateral_vault` to + `holder_collateral_account`. + - Both vaults close — `leased_vault` is already empty because the + short seller kept the leased tokens — and their rent-exempt + lamports go to the holder. The `Lease` account closes the same + way via Anchor's `close = holder`. + - State changes before close: + `lease.collateral_amount = 0`, + `lease.last_paid_timestamp = min(now, end_timestamp)`, + `lease.status = Liquidated`. +- **Errors:** + - `StalePrice` if the discriminator does not match, the account is + too short, `publish_time > now`, or `now - publish_time > 60` + - `PriceFeedMismatch` if `decoded.feed_id != lease.feed_id` + - `NonPositivePrice` if `price <= 0` + - `PositionHealthy` if the underwater check fails + - `InvalidLeaseStatus` if the lease is not `Active` + - `MathOverflow` on any of the integer-multiplication steps + +### 3.7 Branch: cancel or default — `close_expired` + +The holder has a single recovery handler that covers two unrelated +situations: + +- The lease sat in `Listed` and the holder wants to cancel it — + no-one ever took the offer. Allowed any time. +- The lease was `Active`, `end_timestamp` has passed, and the short + seller never called `return_lease`. The holder takes the entire + collateral vault as compensation. + +In both cases the program drains whichever vault is non-empty, closes +both vaults, and closes the `Lease` account, with all three +rent-exempt-lamport refunds going to the holder. + +- **Signers:** `holder`. +- **Accounts:** + - `holder` (signer, mut — also the rent destination for all three closes) + - `lease` (mut, `close = holder`, status ∈ `{Listed, Active}`) + - `leased_mint`, `collateral_mint` + - `leased_vault`, `collateral_vault` (both mut) + - `holder_leased_account` (mut, **init_if_needed**) + - `holder_collateral_account` (mut, **init_if_needed**) + - `token_program`, `associated_token_program`, `system_program` +- **What happens:** + - On a `Listed` cancel: `leased_vault` holds `leased_amount` — + drain it back to `holder_leased_account`. `collateral_vault` is + empty, no transfer. + - On an `Active` default (after `end_timestamp`): + `leased_vault` is empty (the short seller kept the tokens), + `collateral_vault` holds `lease.collateral_amount` — drain all + of it to `holder_collateral_account`. + - Both vaults close; the `Lease` account closes via Anchor's + `close = holder`. + - State changes before close: + - On the `Active` branch only, + `lease.last_paid_timestamp = min(now, end_timestamp)` — settles + the timestamp invariant so a future program version that wants + to split the default pot differently (pro-rata lease fees, + partial refund) has a correct anchor to start from. + - `lease.collateral_amount = 0` + - `lease.status = Closed` +- **Errors:** + - `InvalidLeaseStatus` if `status` is not `Listed` or `Active` + - `LeaseNotExpired` if `status == Active` and `now < end_timestamp` + +### 3.8 Worked branch scenarios + +The handlers above cover the happy path. The branch scenarios below +walk the same machinery through liquidation, a falling-price profit, +and the two `close_expired` situations using concrete numbers that +match the LiteSVM tests one-to-one. All four scenarios share the +same starting parameters; both mints are 6-decimal tokens, so 1 token += 1 000 000 base units. "Leased units" means base units of the leased +mint and "collateral units" means base units of the collateral mint — +descriptive labels, not real tickers. + +Shared starting parameters: + +- `leased_amount = 100_000_000` (100 leased tokens) +- `required_collateral_amount = 200_000_000` (200 collateral tokens) +- `lease_fee_per_second = 10` collateral units +- `duration_seconds = 86_400` (24 hours) +- `maintenance_margin_basis_points = 12_000` (120%) +- `liquidation_bounty_basis_points = 500` (5% of post-lease-fee collateral) +- `feed_id = [0xAB; 32]` (arbitrary, consistent across all calls) + +The holder starts with 1 000 000 000 leased units; the short seller +starts with 1 000 000 000 collateral units. Each scenario opens with +`create_lease` and (where relevant) `take_lease` running as described +in §3.1 and §3.2. Lease fees use the formula in §3.3. + +#### 3.8.1 Liquidation — leased asset rallies + +`create_lease` and `take_lease` run as standard, leaving +`collateral_vault = 200_000_000`, `leased_vault = 0`, and the short +seller holding 100 leased tokens. Time jumps to `T + 300`. + +A keeper observes a fresh Pyth price update: the leased-in-collateral +price has spiked to 4.0 (exponent = 0, raw price = 4). Debt value is +`100_000_000 × 4 = 400_000_000` collateral units against a collateral +pot of ~200 000 000 — maintenance ratio is `200/400 = 50%`, far below +the required 120%. The keeper does not need to call `pay_lease_fee` +first; `liquidate` settles accrued fees itself. + +The keeper calls `liquidate` (mechanics in §3.6). At `T + 300`: + +- Accrued lease fee: `300 × 10 = 3_000` collateral units. The vault + has 200 000 000, so `lease_fee_payable = 3_000` flows to the holder. +- Remaining: `200_000_000 − 3_000 = 199_997_000` collateral units. +- Bounty: `199_997_000 × 500 / 10_000 = 9_999_850` collateral units to + the keeper. +- Holder share: `199_997_000 − 9_999_850 = 189_997_150` collateral + units to the holder. +- Both vaults close, the `Lease` account closes; status recorded as + `Liquidated`. + +Final balances: + +- **Holder:** 900 000 000 leased units (the 100 never came back — the + short seller kept them), `3_000 + 189_997_150 = 190_000_150` + collateral units, plus rent-exempt lamports from three closes. +- **Short seller:** still holds 100 000 000 leased units, lost the + full 200 000 000 collateral. +- **Keeper:** 9 999 850 collateral units. + +The asymmetry to remember: liquidation does *not* reclaim the leased +tokens. The collateral pays the holder for the lost asset; the short +seller has effectively bought the leased tokens at the forfeit price. + +#### 3.8.2 Falling price — short seller profits + +`create_lease` and `take_lease` run as standard. Time jumps to +`T + 300`. The leased-in-collateral price has fallen sharply: take +exponent = −1, raw price = 5, so debt value is +`100_000_000 × 5 / 10 = 50_000_000` collateral units. The collateral +pot is ~200 000 000 — maintenance ratio is `200_000_000 / 50_000_000 += 400%`, far above the required 120%. A keeper calling `liquidate` +here would fail with `PositionHealthy`; the program refuses to seize +a healthy position. + +At `T + 600` (10 minutes in) the short seller buys 100 leased tokens +on the open market at the new price (about 50 collateral tokens +total — far less than the 200 they posted) and calls `return_lease` +(mechanics in §3.5). Accrued lease fees are `600 × 10 = 6_000` +collateral units. The settlement: + +- 100 000 000 leased units flow short seller → leased vault → holder. +- 6 000 collateral units flow from the collateral vault to the holder. +- The remaining `200_000_000 − 6_000 = 199_994_000` collateral units + refund to the short seller. +- Both vaults close, the `Lease` account closes. + +Final balances: + +- **Holder:** 1 000 000 000 leased units (full return), 6 000 + collateral units in lease fees. +- **Short seller:** received 100 leased tokens, sold them at the + original price, bought 100 leased tokens back at the lower price, + returned them. Net cost is the lending fee (6 000 collateral units) + plus open-market trading costs; gain is the difference between the + original sale price and the buy-back price. The standard short + payoff. -**Accounts in:** +The short seller can defend a borderline position with +`top_up_collateral` (§3.4) or close it early via `return_lease` +(§3.5). Only adverse price moves trigger liquidation. -- `holder` (signer, mut — also the rent destination for all three closes) -- `lease` (mut, `close = holder`, status ∈ `{Listed, Active}`) -- `leased_mint`, `collateral_mint` -- `leased_vault`, `collateral_vault` (both mut) -- `holder_leased_account` (mut, **init_if_needed**) -- `holder_collateral_account` (mut, **init_if_needed**) -- `token_program`, `associated_token_program`, `system_program` +#### 3.8.3 Default — `close_expired` on an `Active` lease -**Checks:** +`create_lease` and `take_lease` run as standard. The short seller +takes the tokens, posts collateral, then disappears. `pay_lease_fee` +is never called. The clock advances past +`end_timestamp = T + 86_400`. -- `status ∈ {Listed, Active}` (Anchor `constraint matches!(...)`) → - `InvalidLeaseStatus` -- If `status == Active`, also `now >= end_timestamp` → `LeaseNotExpired` +At `T + 100_000` the holder calls `close_expired` (mechanics in +§3.7). Because `status == Active` and `now >= end_timestamp`, the +default branch runs: -**Token movements:** +- `leased_vault` is empty (the short seller kept the tokens) — no + transfer. +- `collateral_vault` holds 200 000 000 collateral units; all of it + flows to `holder_collateral_account`. +- Both vaults close, the `Lease` account closes; + `last_paid_timestamp` settles at `end_timestamp`. -For a `Listed` cancel: -``` - leased_vault program-derived address --[leased_amount of leased_mint]--> holder_leased_account - collateral_vault program-derived address is empty (0 transferred) -``` +Final balances: -For an `Active` default: -``` - leased_vault program-derived address is empty (short seller kept the tokens) - collateral_vault program-derived address --[collateral_amount of collateral_mint]--> holder_collateral_account -``` +- **Holder:** 900 000 000 leased units, 200 000 000 collateral units + (the entire collateral pot as compensation), plus three + account-close refunds. +- **Short seller:** 100 000 000 leased units, paid the full + collateral and kept the leased tokens. -In both cases both vaults are then closed and the `Lease` account is -closed; all three rent-exempt lamport refunds go to the holder. +#### 3.8.4 Cancel — `close_expired` on a `Listed` lease -**State changes before close:** +The cheap cancel path. `create_lease` runs; no short seller ever +calls `take_lease`. The holder calls `close_expired` immediately +(mechanics in §3.7). Because `status == Listed`, no expiry check +applies: -- If `Active`: `lease.last_paid_timestamp = now.min(end_timestamp)` - (settles the accounting so any future program version that wants - to split the default pot differently has a correct timestamp to - start from) -- `lease.collateral_amount = 0` -- `lease.status = Closed` +- `leased_vault` holds 100 000 000 leased units; all of it drains + back to `holder_leased_account`. +- `collateral_vault` is empty — no transfer. +- Both vaults close, the `Lease` account closes. ---- - -## 4. Full-lifecycle worked examples - -These are abstract walkthroughs of the same machinery the §1 xNVDA -example uses, with round numbers chosen to make the arithmetic easy -to follow and to match the LiteSVM tests one-to-one. All paths share -the same starting parameters. Both mints are 6-decimal tokens, so -1 token = 1 000 000 base units. Throughout this section, "leased -units" means base units of the leased mint and "collateral units" -means base units of the collateral mint — they are descriptive -labels, not real tickers. -The diagrams use the same convention: `[ leased]` and -`[ collateral]`. - -- `leased_amount = 100_000_000` (100 leased tokens). -- `required_collateral_amount = 200_000_000` (200 collateral tokens). -- `lease_fee_per_second = 10` collateral units. -- `duration_seconds = 86_400` (24 hours). -- `maintenance_margin_basis_points = 12_000` (120%). -- `liquidation_bounty_basis_points = 500` (5% of post-lease-fee collateral). -- `feed_id = [0xAB; 32]` (arbitrary, consistent across all calls). - -The holder starts with 1 000 000 000 leased units in their -associated token account. The short seller starts with 1 000 000 000 -collateral units in theirs. - -### 4.1 Happy path — short seller returns on time - -Calls, in order: - -1. **`create_lease`** — holder posts 100 leased tokens into - `leased_vault`, parameters written to `lease`. - ``` - holder_leased_account --[100_000_000 leased]--> leased_vault program-derived address - ``` - Balances after: holder has 900 000 000 leased units, `leased_vault` has - 100 000 000 leased units, `collateral_vault` has 0. - -2. **`take_lease`** — short seller posts 200 collateral tokens, receives - 100 leased tokens. - ``` - short_seller_collateral_account --[200_000_000 collateral]--> collateral_vault program-derived address - leased_vault program-derived address --[100_000_000 leased]--> short_seller_leased_account - ``` - `lease.status = Active`, `start_timestamp = T`, `end_timestamp = T + 86_400`. - -3. **`pay_lease_fee`** called at `T + 120` seconds. Lease fee due = 120 × 10 = - 1 200 collateral units. - ``` - collateral_vault program-derived address --[1_200 collateral]--> holder_collateral_account - ``` - `collateral_amount = 200_000_000 − 1_200 = 199_998_800`. - -4. **`top_up_collateral(amount = 50_000_000)`** at `T + 600`. The - short seller decides to add a cushion. - ``` - short_seller_collateral_account --[50_000_000 collateral]--> collateral_vault program-derived address - ``` - `collateral_amount = 199_998_800 + 50_000_000 = 249_998_800`. - -5. **`return_lease`** called at `T + 3_600` (one hour in). Total lease fees - from `start_timestamp` to `now` is 3 600 × 10 = 36 000 collateral units; 1 200 of that - was paid in step 3. Residual lease fees = 36 000 − 1 200 = 34 800 collateral units. - ``` - short_seller_leased_account --[100_000_000 leased]--> leased_vault program-derived address - leased_vault program-derived address --[100_000_000 leased]--> holder_leased_account - collateral_vault program-derived address --[34_800 collateral]------> holder_collateral_account - collateral_vault program-derived address --[249_964_000 collateral]--> short_seller_collateral_account - ``` - Where `249_964_000 = 249_998_800 − 34_800`. - - Both vaults close, their rent-exempt lamports go to the holder. - The `Lease` account closes via `close = holder`. - -**Final balances:** - -- Holder: 1 000 000 000 leased units (full return), 36 000 collateral units (total lease fees - received in steps 3 + 5), plus the lamports from three account closes. -- Short seller: 100 000 000 leased units → 0 (all returned), collateral: started with - 1 000 000 000, spent 200 000 000 on initial deposit + 50 000 000 on - top-up, got back 249 964 000, so holds 999 964 000 collateral units (net cost - of 36 000 — exactly the total lease fees paid). - -### 4.2 Liquidation path - -Same setup. Steps 1 and 2 run identically. - -3. Time jumps to `T + 300`. A keeper observes a new Pyth price update: - the leased-in-collateral price has spiked to 4.0 (exponent 0, price - = 4). At that price, the debt value is `100_000_000 × 4 = - 400_000_000` collateral units. The collateral - pot is still ~`200_000_000` (minus some streamed lease fees). - Maintenance ratio = `200/400 = 50%`, well below the required 120%. - - Calling `pay_lease_fee` first is *not* required — `liquidate` - settles accrued lease fees itself. The keeper goes straight to - `liquidate`. - -4. **`liquidate`** at `T + 300`: - - Lease fee due = 300 × 10 = 3 000 collateral units; collateral_amount = 200 000 000 - so `lease_fee_payable = 3 000`. - ``` - collateral_vault program-derived address --[3_000 collateral]--> holder_collateral_account - ``` - - Remaining = 200 000 000 − 3 000 = 199 997 000 collateral units. - - Bounty = 199 997 000 × 500 / 10 000 = 9 999 850 collateral units. - ``` - collateral_vault program-derived address --[9_999_850 collateral]--> keeper_collateral_account - ``` - - Holder share = 199 997 000 − 9 999 850 = 189 997 150 collateral units. - ``` - collateral_vault program-derived address --[189_997_150 collateral]--> holder_collateral_account - ``` - - Both vaults close; Lease closes. Status recorded as `Liquidated`. - -**Final balances:** - -- Holder: 900 000 000 leased units (never got the 100 back — the - short seller kept them), `3 000 + 189 997 150 = 190 000 150` - collateral units, plus rent-exempt lamports from three closes. -- Short seller: *still* has 100 000 000 leased units. Spent 200 000 000 collateral units on - deposit, got nothing back. Net: the short seller walks away with the leased tokens - but forfeited the entire collateral minus the keeper's cut. -- Keeper: 9 999 850 collateral units for the keeper's trouble. - -(This is the key asymmetry: liquidation does *not* reclaim the leased -tokens. The collateral pays the holder for the lost asset. The short -seller has effectively bought the leased tokens at the forfeit price.) - -### 4.3 Falling-price path — short seller profits - -Liquidation is a one-sided risk: liquidation only ever fires when the -leased asset *appreciates* against the collateral. If the leased asset -depreciates, the collateral ratio rises and the short seller's -position gets safer. The streaming lending fee is the position's only -ongoing cost. - -Same setup. Steps 1 and 2 run identically. - -3. Time jumps to `T + 300`. The leased-in-collateral price has - *fallen* to 0.5 (exponent 0, price = 0). To make the math - non-trivial, take exponent = −1, price = 5: the debt value is - `100_000_000 × 5 / 10 = 50_000_000` collateral units. The - collateral pot is ~`200_000_000` (minus a tiny bit of streamed - lease fees). Maintenance ratio = `200_000_000 / 50_000_000 = - 400%`, far above the required 120%. - - A keeper calling `liquidate` here would fail with - `PositionHealthy` — the program refuses to seize a healthy - position. The short seller is in the clear. - -4. **`return_lease`** called at `T + 600` (10 minutes in). The short - seller buys 100 leased tokens on the open market at the new price - (about 50 collateral tokens total — far less than the 200 - collateral tokens they posted), then returns those tokens to - close out the lease. - - Lease fees accrued: 600 × 10 = 6 000 collateral units. - - ``` - short_seller_leased_account --[100_000_000 leased]--> leased_vault program-derived address - leased_vault program-derived address --[100_000_000 leased]--> holder_leased_account - collateral_vault program-derived address --[6_000 collateral]--------> holder_collateral_account - collateral_vault program-derived address --[199_994_000 collateral]--> short_seller_collateral_account - ``` - -**Final balances:** - -- Holder: 1 000 000 000 leased units (full return), 6 000 collateral units in lease - fees. -- Short seller: received 100 000 000 leased units, sold them at the - original price, bought 100 leased tokens back at the lower price, - returned them. Net cost is the lending fee (6 000 collateral units) - plus whatever the short seller paid on the open market for the - replacement tokens; gain is the difference between the original - sale price and the buy-back price. The standard short payoff. - -The short seller can defend a borderline position with -`top_up_collateral` or close it early via `return_lease`. Only -adverse price moves trigger liquidation. - -### 4.4 Default / expiry path — `close_expired` on an `Active` lease - -Same setup. Steps 1 and 2 run as usual. The short seller takes the -tokens, posts collateral, then disappears. - -3. `pay_lease_fee` is never called. Clock advances all the way past - `end_timestamp = T + 86_400`. - -4. **`close_expired`** called by the holder at `T + 100_000`: - - `status == Active` and `now >= end_timestamp` → the default branch runs. - - `leased_vault` is empty (short seller kept the tokens). No transfer. - - `collateral_vault` has 200 000 000 collateral units. All of it - goes to the holder: - ``` - collateral_vault program-derived address --[200_000_000 collateral]--> holder_collateral_account - ``` - - Both vaults close; Lease closes. - - `last_paid_timestamp = min(now, end_timestamp) = end_timestamp`. - -**Final balances:** - -- Holder: 900 000 000 leased units, 200 000 000 collateral units (the entire - collateral pot as compensation), plus three account-close refunds. -- Short seller: 100 000 000 leased units, −200 000 000 collateral - units. The short seller paid the full collateral and kept the leased tokens. - -### 4.5 Default / expiry path — `close_expired` on a `Listed` lease - -This is the cheap cancel path. No short seller ever showed up. - -1. `create_lease` as above. -2. `close_expired` called by the holder immediately. - - `status == Listed` → no expiry check. - - `leased_vault` holds 100 000 000 leased units. Drain back: - ``` - leased_vault program-derived address --[100_000_000 leased]--> holder_leased_account - ``` - - `collateral_vault` is empty. No transfer. - - Both vaults close; Lease closes. - -**Final balances:** holder is back to 1 000 000 000 leased units; +Final balances: the holder is back to 1 000 000 000 leased units; nothing else moved. --- -## 5. Safety and edge cases +## 4. Safety and edge cases -### 5.1 What the program refuses to do +### 4.1 What the program refuses to do All of the following come from [`errors.rs`](programs/asset-leasing/src/errors.rs) and are enforced by either an Anchor constraint or a `require!` in the @@ -921,7 +766,7 @@ handler: | `LeasedMintEqualsCollateralMint` | `create_lease` called with the same mint for both sides | | `PriceFeedMismatch` | `liquidate` called with a Pyth update whose `feed_id` does not match `lease.feed_id` | -### 5.2 Guarded design choices worth knowing +### 4.2 Guarded design choices worth knowing - **Leased tokens are locked up-front.** `create_lease` moves the tokens into the `leased_vault` immediately, so a short seller calling @@ -965,9 +810,9 @@ handler: cut would dwarf the holder's recovery on default. The cap keeps liquidation economics roughly in line with holder-first semantics. -### 5.3 Things the program does *not* guard against +### 4.3 Things the program does *not* guard against -A production protocol would want more: +A production version of the program would want more: - **Price feed correctness.** The program verifies the owner (`PYTH_RECEIVER_PROGRAM_ID`), the discriminator, the layout and the @@ -978,7 +823,7 @@ A production protocol would want more: would fail), but it will mean *no* liquidation can succeed, so a short seller could drain the collateral via lease fees and walk away. A production version would cross-check the price feed's - `feed_id` against a protocol registry. + `feed_id` against a program-maintained registry. - **Lease-fee dust accumulation.** Lease fees are paid in whole base units per second of `lease_fee_per_second`. Choose a small @@ -1007,7 +852,7 @@ A production protocol would want more: --- -## 6. Running the tests +## 5. Running the tests All the tests are LiteSVM-based Rust integration tests under [`programs/asset-leasing/tests/`](programs/asset-leasing/tests/). They @@ -1080,7 +925,7 @@ CI is already covered. --- -## 7. Quasar port +## 6. Quasar port A parallel implementation of the same program using [Quasar](https://github.com/blueshift-gg/quasar) lives in @@ -1181,9 +1026,10 @@ handler. Tests are in `src/tests.rs`. --- -## 8. Extending the program +## 7. Extending the program -Directions a real protocol would consider, grouped by effort: +Directions a real-world version of the program would consider, +grouped by effort: ### Easy From d55e3af02f97653b65d0349a6d9c32e9763bcf5f Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 21:15:46 +0000 Subject: [PATCH 26/41] docs(asset-leasing): add 'what the short seller really gets' framing; remove section preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the new §3 Lifecycle: 1. Drop the section-preview paragraph and the redundant 7-handler bullet list at the top of §3. Subsection headings (§3.1 through §3.7) already convey the same information; previewing them inside the section is filler. 2. Add a new opening subsection 'What the short seller really gets' that frames the trade as the cash/obligation asymmetry: at open, today's value in stables; at close, the obligation to return N tokens at whatever future price exists. This is the economic intuition that makes the rest of the lifecycle make sense before diving into mechanics. Concrete $190 → $160 example so readers immediately see how the asymmetry prints money on a price drop. --- defi/asset-leasing/anchor/README.md | 38 +++++++++++++++-------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 35449a1b1..96d51c3f4 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -262,24 +262,26 @@ record the terminal state, but the account disappears at the end. ## 3. Lifecycle -This section walks the program from listing to close. Each -[instruction handler](https://solana.com/docs/terminology) is -introduced in narrative order; the first time a handler appears, its -full mechanics are listed inline (signers, accounts, what happens, -errors). After the happy path is covered, two branch handlers — -`liquidate` and `close_expired` — handle the failure modes. The -section ends with four worked branch scenarios using concrete numbers -that match the LiteSVM tests one-to-one. - -The program has seven instruction handlers in total: - -- `create_lease` — holder lists tokens -- `take_lease` — short seller takes the offer -- `pay_lease_fee` — settle accrued lease fee -- `top_up_collateral` — short seller adds collateral -- `return_lease` — short seller closes cleanly -- `liquidate` — keeper closes an underwater position -- `close_expired` — holder closes a stale or defaulted lease +### What the short seller really gets + +When a short seller takes a lease, they walk away with two things: + +- **At open: today's market value of the leased tokens, in stables.** + They borrow the leased tokens from a holder, sell them on the open + market immediately, and pocket the stables. +- **At close: an obligation to return the same number of tokens, + regardless of what those tokens are worth then.** The obligation + is fixed in *units of the leased token*, not in *units of value*. + If the price falls — say from $190 to $160 per token — the cost of + acquiring the same number of tokens to return drops, and the short + seller keeps the difference. + +The asymmetry is the trade: cash received today is fixed in stables; +the cost of fulfilling the obligation later is fixed in tokens whose +price is unknown. Bet correctly on the direction and that asymmetry +prints money. Bet wrong and the cost of buying the tokens back can +exceed the cash plus the collateral, at which point the keepers +arrive (see [§3.6](#36-branch-position-underwater---liquidate)). ### 3.1 The holder lists the tokens — `create_lease` From c5636413b05c71711c3a18a4554388dd6bac3cc7 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 21:32:19 +0000 Subject: [PATCH 27/41] =?UTF-8?q?docs(asset-leasing):=20name=20the=20instr?= =?UTF-8?q?uction=20handlers=20in=20the=20=C2=A71=20lifecycle=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §1 'short seller's full lifecycle' steps described the mechanics in plain English ('open the position', 'close the position') without naming the instruction handlers that actually perform those steps. The reader had no signpost connecting the narrative to the code. Now each step calls out the handler explicitly: take_lease, pay_lease_fee, top_up_collateral, return_lease — plus liquidate and close_expired in the failure paragraph. Reader can grep the handler name straight to §3 for full mechanics. --- defi/asset-leasing/anchor/README.md | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 96d51c3f4..52b9b8e62 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -42,21 +42,28 @@ of the A tokens. The short seller's full lifecycle is: -1. **Open the position.** Borrow A from the holder by posting B as - collateral. Pay a per-second lending fee out of the collateral. +1. **Open the position** by calling `take_lease`. This borrows A from + the holder and locks B as collateral. A per-second lending fee + accrues from this point onward, settled by `pay_lease_fee`. 2. **Sell A immediately** on a market like Jupiter, receiving more B in return. The short seller now has more B and owes A. 3. **Wait.** They are betting A's price (denominated in B) will fall. -4. **Close the position.** Buy A back on the open market — hopefully - at a lower price than they sold it for — and return the same - quantity of A to the holder. The B they paid to re-acquire A is - less than the B they got for selling it, and the difference is - the short seller's profit. + While waiting, they may call `top_up_collateral` to defend the + position if A's price moves against them, and `pay_lease_fee` to + settle accrued fees. +4. **Close the position** by calling `return_lease`. They buy A back + on the open market — hopefully at a lower price than they sold it + for — and return the same quantity of A to the holder. The B they + paid to re-acquire A is less than the B they got for selling it, + and the difference is the short seller's profit. If A's price *rises* instead, buying it back costs more B than they got for selling it — that's a loss. If it rises far enough that their -locked collateral is no longer worth more than the A they owe, they -get liquidated (see below). +locked collateral is no longer worth more than the A they owe, anyone +can call `liquidate` to close the position out, paying the keeper a +bounty from the collateral. If the lease term ends without the short +seller calling `return_lease`, the holder calls `close_expired` to +seize the collateral and recover. The program acts as a non-custodial escrow. It: From bdb511df7ea9ec9eddcfb55a1c3835650ab11bfa Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 21:34:14 +0000 Subject: [PATCH 28/41] docs(asset-leasing): drop 'worked' from example/scenario headings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Worked example' is textbook/maths-lecturer language — 'here's a problem solved step-by-step'. Not wrong, but pretentious for a software README and assumes the reader knows academic conventions. Renamed: - 'Worked example: shorting xNVDA' → 'Example: shorting xNVDA' - '3.8 Worked branch scenarios' → '3.8 Branch scenarios' No cross-references needed updating. --- defi/asset-leasing/anchor/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 52b9b8e62..b5091816f 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -112,7 +112,7 @@ how much has been paid, and an oracle check. - **Keeper / liquidator.** Standard role — watches for undercollateralised positions and takes the bounty for closing them. -### Worked example: shorting xNVDA via the lending market +### Example: shorting xNVDA via the lending market Concrete numbers using assets that already trade on Solana — [xNVDA](https://www.backed.fi/) (a Backed Finance / xStocks tokenised @@ -603,7 +603,7 @@ rent-exempt-lamport refunds going to the holder. - `InvalidLeaseStatus` if `status` is not `Listed` or `Active` - `LeaseNotExpired` if `status == Active` and `now < end_timestamp` -### 3.8 Worked branch scenarios +### 3.8 Branch scenarios The handlers above cover the happy path. The branch scenarios below walk the same machinery through liquidation, a falling-price profit, From 1298583610bc783e385396e7e5e369fed31d530b Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 21:36:05 +0000 Subject: [PATCH 29/41] docs(asset-leasing): replace em-dashes with regular dashes; say 'token A' not 'mint A' - Replaced all 74 em-dashes (\u2014) with regular dashes (-) per Mike's preference. Em-dashes are LLM-output tells; regular dashes everywhere. - Section 1 introduced the two assets as 'mint A' and 'mint B', conflating the asset itself with the mint account that controls its supply. The asset is a token; the mint is the factory. Now reads 'token A' / 'token B' for the asset, with 'leased mint' / 'collateral mint' kept ONLY where the prose is genuinely talking about the mint account (token-account field descriptions, instruction-handler walkthroughs - these correctly refer to the mint of an associated token account). Note: the old per-instruction tables in section 2 still use markdown tables. Those are a separate cleanup; flagged for follow-up. --- defi/asset-leasing/anchor/README.md | 150 ++++++++++++++-------------- 1 file changed, 75 insertions(+), 75 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index b5091816f..845e64972 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -35,10 +35,10 @@ need to sell short. The program is written in ## 1. What does this program do? -A **holder** offers some quantity of a token — mint **A**, the -"leased mint" — for a fixed term. A **short seller** posts collateral -in a different mint **B** — the "collateral mint" — to take delivery -of the A tokens. +A **holder** offers some quantity of **token A** - the leased token - +for a fixed term. A **short seller** posts collateral in a different +**token B** - the collateral token - to take delivery of the A +tokens. The short seller's full lifecycle is: @@ -52,13 +52,13 @@ The short seller's full lifecycle is: position if A's price moves against them, and `pay_lease_fee` to settle accrued fees. 4. **Close the position** by calling `return_lease`. They buy A back - on the open market — hopefully at a lower price than they sold it - for — and return the same quantity of A to the holder. The B they + on the open market - hopefully at a lower price than they sold it + for - and return the same quantity of A to the holder. The B they paid to re-acquire A is less than the B they got for selling it, and the difference is the short seller's profit. If A's price *rises* instead, buying it back costs more B than they -got for selling it — that's a loss. If it rises far enough that their +got for selling it - that's a loss. If it rises far enough that their locked collateral is no longer worth more than the A they owe, anyone can call `liquidate` to close the position out, paying the keeper a bounty from the collateral. If the lease term ends without the short @@ -76,10 +76,10 @@ The program acts as a non-custodial escrow. It: pays the holder out of the collateral vault. 4. If the price of A (measured in B) rises far enough that the locked collateral is no longer enough to cover the cost of re-acquiring - the borrowed tokens, anyone can call `liquidate` — the collateral + the borrowed tokens, anyone can call `liquidate` - the collateral is seized, most of it goes to the holder, and a small percentage (the **liquidation bounty**) goes to whoever called `liquidate`. - Such a caller is known as a **keeper** — a bot or anyone else who + Such a caller is known as a **keeper** - a bot or anyone else who watches the chain for positions that have gone underwater and earns the bounty by cleaning them up. 5. If the short seller returns the full A amount before the deadline, @@ -103,18 +103,18 @@ how much has been paid, and an oracle check. - **Holder.** Long the asset, willing to part with the asset temporarily to earn the lending fee. The economic match for this - role is a passive allocator — someone who would hold the asset + role is a passive allocator - someone who would hold the asset anyway and is happy to earn yield on idle inventory. - **Short seller.** Pays the lending fee for the right to sell the borrowed tokens now and buy them back later. The payoff shape is the same as a short: profit if the borrowed asset falls, loss (and possible liquidation) if the asset rises. -- **Keeper / liquidator.** Standard role — watches for +- **Keeper / liquidator.** Standard role - watches for undercollateralised positions and takes the bounty for closing them. ### Example: shorting xNVDA via the lending market -Concrete numbers using assets that already trade on Solana — +Concrete numbers using assets that already trade on Solana - [xNVDA](https://www.backed.fi/) (a Backed Finance / xStocks tokenised NVIDIA share) and USDC. xNVDA has its own Pyth feed; the program takes the feed id verbatim at `create_lease`. @@ -143,7 +143,7 @@ sells the 100 xNVDA on Jupiter for ~18 000 USDC at the spot price. #### If NVIDIA rallies to $200 - Bob's debt to repurchase the 100 xNVDA is now `100 × $200 = $20 000`. -- Collateral ratio: `22 000 / 20 000 = 110%` — exactly at the +- Collateral ratio: `22 000 / 20 000 = 110%` - exactly at the maintenance margin. - One more upward tick and a keeper can call `liquidate` with a fresh Pyth update. Of the 22 000 USDC vault: a small portion has @@ -158,7 +158,7 @@ sells the 100 xNVDA on Jupiter for ~18 000 USDC at the spot price. #### If NVIDIA falls to $160 - Bob's debt drops to `100 × $160 = $16 000`. -- Collateral ratio: `22 000 / 16 000 = 137.5%` — well above the 110% +- Collateral ratio: `22 000 / 16 000 = 137.5%` - well above the 110% maintenance margin. No liquidation pressure. - Bob buys back 100 xNVDA on Jupiter for ~16 000 USDC and calls `return_lease`. Alice receives the 100 xNVDA back plus the @@ -279,7 +279,7 @@ When a short seller takes a lease, they walk away with two things: - **At close: an obligation to return the same number of tokens, regardless of what those tokens are worth then.** The obligation is fixed in *units of the leased token*, not in *units of value*. - If the price falls — say from $190 to $160 per token — the cost of + If the price falls - say from $190 to $160 per token - the cost of acquiring the same number of tokens to return drops, and the short seller keeps the difference. @@ -290,7 +290,7 @@ prints money. Bet wrong and the cost of buying the tokens back can exceed the cash plus the collateral, at which point the keepers arrive (see [§3.6](#36-branch-position-underwater---liquidate)). -### 3.1 The holder lists the tokens — `create_lease` +### 3.1 The holder lists the tokens - `create_lease` The holder calls `create_lease`, naming the leased mint, the collateral mint, the amount of leased tokens to offer, the @@ -302,17 +302,17 @@ program creates the `Lease` account, creates two empty token vault mint), and moves the leased tokens out of the holder's wallet into the leased vault. Locking the leased tokens up front means a short seller calling `take_lease` later cannot fail because the holder -spent the inventory in the meantime — the atomicity guarantee +spent the inventory in the meantime - the atomicity guarantee transfers to the program the moment the lease is listed. - **Signers:** `holder`. - **Accounts:** - - `holder` (signer, mut — pays account rent) + - `holder` (signer, mut - pays account rent) - `leased_mint`, `collateral_mint` (read-only) - - `holder_leased_account` (mut, holder's [associated token account](https://solana.com/docs/terminology) for the leased mint — source) - - `lease` (program-derived address, **init**) — created here, seeds `[b"lease", holder, lease_id.to_le_bytes()]` - - `leased_vault` (program-derived address, **init**, token account) — created here, seeds `[b"leased_vault", lease]`, authority = itself - - `collateral_vault` (program-derived address, **init**, token account) — created here, seeds `[b"collateral_vault", lease]`, authority = itself + - `holder_leased_account` (mut, holder's [associated token account](https://solana.com/docs/terminology) for the leased mint - source) + - `lease` (program-derived address, **init**) - created here, seeds `[b"lease", holder, lease_id.to_le_bytes()]` + - `leased_vault` (program-derived address, **init**, token account) - created here, seeds `[b"leased_vault", lease]`, authority = itself + - `collateral_vault` (program-derived address, **init**, token account) - created here, seeds `[b"collateral_vault", lease]`, authority = itself - `token_program`, `system_program` - **What happens:** - Single token movement: `leased_amount` of the leased mint @@ -331,12 +331,12 @@ transfers to the program the moment the lease is listed. - `InvalidMaintenanceMargin` if `maintenance_margin_basis_points` is `0` or `> 50_000` - `InvalidLiquidationBounty` if `liquidation_bounty_basis_points > 2_000` -### 3.2 The short seller takes the offer — `take_lease` +### 3.2 The short seller takes the offer - `take_lease` A short seller who has spotted the `Lease` account onchain (via an indexer or a direct lookup) calls `take_lease` to take delivery. The program deposits the short seller's collateral first, then hands over -the leased tokens — depositing collateral first means that if the +the leased tokens - depositing collateral first means that if the leased-token payout fails for any reason the whole transaction reverts and the short seller gets their collateral back. The lease moves from `Listed` to `Active`. @@ -344,12 +344,12 @@ moves from `Listed` to `Active`. - **Signers:** `short_seller`. - **Accounts:** - `short_seller` (signer, mut) - - `holder` (UncheckedAccount — read for program-derived address seed derivation only, no signature required) + - `holder` (UncheckedAccount - read for program-derived address seed derivation only, no signature required) - `lease` (mut, `has_one = holder`, `has_one = leased_mint`, `has_one = collateral_mint`, must be `Listed`) - `leased_mint`, `collateral_mint` - `leased_vault`, `collateral_vault` (both mut, both program-derived addresses) - - `short_seller_collateral_account` (mut, short seller's associated token account — source) - - `short_seller_leased_account` (mut, **init_if_needed** — destination) + - `short_seller_collateral_account` (mut, short seller's associated token account - source) + - `short_seller_leased_account` (mut, **init_if_needed** - destination) - `token_program`, `associated_token_program`, `system_program` - **What happens:** - Two token movements, in order: @@ -370,7 +370,7 @@ moves from `Listed` to `Active`. `collateral_mint` do not match the values stored on the lease - `MathOverflow` if `now + duration_seconds` overflows `i64` -### 3.3 The lease fee streams — `pay_lease_fee` +### 3.3 The lease fee streams - `pay_lease_fee` The lease fee accrues second by second out of the collateral vault. Anyone can call `pay_lease_fee` to settle whatever has accrued since @@ -379,14 +379,14 @@ the position out of liquidation), and a keeper bot may push a payment before checking margins so healthy leases stay healthy. The fee formula is `(min(now, end_timestamp) - last_paid_timestamp) * lease_fee_per_second`, capped at the collateral actually sitting in -the vault. Fees do not accrue past `end_timestamp` — once the +the vault. Fees do not accrue past `end_timestamp` - once the deadline hits, the short seller is either returning the tokens, being liquidated, or defaulting; no further lease fees are owed. - **Signers:** `payer` (anyone). - **Accounts:** - - `payer` (signer, mut — pays for `init_if_needed` of the holder associated token account) - - `holder` (UncheckedAccount, read-only — used for `has_one` check) + - `payer` (signer, mut - pays for `init_if_needed` of the holder associated token account) + - `holder` (UncheckedAccount, read-only - used for `has_one` check) - `lease` (mut, must be `Active`) - `collateral_mint`, `collateral_vault` - `holder_collateral_account` (mut, **init_if_needed**) @@ -406,7 +406,7 @@ being liquidated, or defaulting; no further lease fees are owed. - `InvalidLeaseStatus` if the lease is not `Active` - `MathOverflow` if `elapsed * lease_fee_per_second` overflows `u64` -### 3.4 The short seller defends the position — `top_up_collateral` +### 3.4 The short seller defends the position - `top_up_collateral` If the price moves against the short seller and the position drifts toward the maintenance-margin floor, the short seller can add more @@ -417,7 +417,7 @@ The short seller can call this any number of times while the lease is `Active`. - **Signers:** `short_seller`. -- **Parameter:** `amount: u64` — how much collateral to add. +- **Parameter:** `amount: u64` - how much collateral to add. - **Accounts:** - `short_seller` (signer) - `holder` (UncheckedAccount, read-only) @@ -435,7 +435,7 @@ is `Active`. - `InvalidLeaseStatus` if the lease is not `Active` - `MathOverflow` if the addition overflows `u64` -### 3.5 The short seller closes — `return_lease` +### 3.5 The short seller closes - `return_lease` To close the position, the short seller buys back the leased tokens on the open market and calls `return_lease`. The program runs the @@ -443,7 +443,7 @@ full settlement in a single transaction: leased tokens move from the short seller back to the holder, accrued lease fees move from the collateral vault to the holder, the leftover collateral refunds to the short seller, and both vaults plus the `Lease` account close. -The handler accepts a return at any time while `status == Active` — +The handler accepts a return at any time while `status == Active` - returning before `end_timestamp` just means lease fees stop accruing the moment the call lands; returning after `end_timestamp` does not pile on extra charges because the fee formula already caps elapsed @@ -452,7 +452,7 @@ time at `end_timestamp`. - **Signers:** `short_seller`. - **Accounts:** - `short_seller` (signer, mut) - - `holder` (UncheckedAccount, mut — receives `Lease` and vault rent-exempt lamports via `close = holder`) + - `holder` (UncheckedAccount, mut - receives `Lease` and vault rent-exempt lamports via `close = holder`) - `lease` (mut, `close = holder`, must be `Active`, must be the same `short_seller`) - `leased_mint`, `collateral_mint` - `leased_vault`, `collateral_vault` (both mut) @@ -488,18 +488,18 @@ time at `end_timestamp`. - `Unauthorised` if `lease.short_seller != short_seller.key()` - `MathOverflow` if the lease-fee or collateral subtraction overflows -### 3.6 Branch: position underwater — `liquidate` +### 3.6 Branch: position underwater - `liquidate` If the leased asset rallies far enough that the locked collateral is no longer worth more than the debt times the maintenance margin, -anyone — typically a keeper bot — can call `liquidate` with a fresh +anyone - typically a keeper bot - can call `liquidate` with a fresh Pyth price update. The program decodes the update by hand (production code would use `pyth-solana-receiver-sdk`; the LiteSVM tests install a `PriceUpdateV2` account whose layout is parsed inline), checks the position is genuinely underwater, settles the accrued lease fee to the holder, pays the keeper a bounty out of what remains, and sends the rest to the holder. The leased tokens -stay with the short seller — the collateral is the holder's +stay with the short seller - the collateral is the holder's compensation for the lost asset. The underwater check, in integers, is: @@ -512,8 +512,8 @@ math non-negative (see [`is_underwater`](programs/asset-leasing/src/instructions - **Signers:** `keeper`. - **Accounts:** - - `keeper` (signer, mut — pays `init_if_needed` cost for both associated token accounts) - - `holder` (UncheckedAccount, mut — receives lease fee, holder share, and the rent-exempt lamports from the three closed accounts) + - `keeper` (signer, mut - pays `init_if_needed` cost for both associated token accounts) + - `holder` (UncheckedAccount, mut - receives lease fee, holder share, and the rent-exempt lamports from the three closed accounts) - `lease` (mut, `close = holder`, must be `Active`) - `leased_mint`, `collateral_mint` - `leased_vault`, `collateral_vault` (both mut) @@ -526,7 +526,7 @@ math non-negative (see [`is_underwater`](programs/asset-leasing/src/instructions `PRICE_UPDATE_V2_DISCRIMINATOR`, account length ≥ 89 bytes, `feed_id` must equal `lease.feed_id`, `0 < now - publish_time <= 60 seconds`, `price > 0`. The - decoded `feed_id` check is the **feed-pinning** guard — without + decoded `feed_id` check is the **feed-pinning** guard - without it a keeper could pass any feed the Pyth Receiver program owns (a wildly volatile pair that happens to be dipping, say) to force a spurious liquidation. @@ -540,8 +540,8 @@ math non-negative (see [`is_underwater`](programs/asset-leasing/src/instructions where `remaining = lease.collateral_amount - lease_fee_payable`. 3. `remaining - bounty` moves from `collateral_vault` to `holder_collateral_account`. - - Both vaults close — `leased_vault` is already empty because the - short seller kept the leased tokens — and their rent-exempt + - Both vaults close - `leased_vault` is already empty because the + short seller kept the leased tokens - and their rent-exempt lamports go to the holder. The `Lease` account closes the same way via Anchor's `close = holder`. - State changes before close: @@ -557,12 +557,12 @@ math non-negative (see [`is_underwater`](programs/asset-leasing/src/instructions - `InvalidLeaseStatus` if the lease is not `Active` - `MathOverflow` on any of the integer-multiplication steps -### 3.7 Branch: cancel or default — `close_expired` +### 3.7 Branch: cancel or default - `close_expired` The holder has a single recovery handler that covers two unrelated situations: -- The lease sat in `Listed` and the holder wants to cancel it — +- The lease sat in `Listed` and the holder wants to cancel it - no-one ever took the offer. Allowed any time. - The lease was `Active`, `end_timestamp` has passed, and the short seller never called `return_lease`. The holder takes the entire @@ -574,7 +574,7 @@ rent-exempt-lamport refunds going to the holder. - **Signers:** `holder`. - **Accounts:** - - `holder` (signer, mut — also the rent destination for all three closes) + - `holder` (signer, mut - also the rent destination for all three closes) - `lease` (mut, `close = holder`, status ∈ `{Listed, Active}`) - `leased_mint`, `collateral_mint` - `leased_vault`, `collateral_vault` (both mut) @@ -582,18 +582,18 @@ rent-exempt-lamport refunds going to the holder. - `holder_collateral_account` (mut, **init_if_needed**) - `token_program`, `associated_token_program`, `system_program` - **What happens:** - - On a `Listed` cancel: `leased_vault` holds `leased_amount` — + - On a `Listed` cancel: `leased_vault` holds `leased_amount` - drain it back to `holder_leased_account`. `collateral_vault` is empty, no transfer. - On an `Active` default (after `end_timestamp`): `leased_vault` is empty (the short seller kept the tokens), - `collateral_vault` holds `lease.collateral_amount` — drain all + `collateral_vault` holds `lease.collateral_amount` - drain all of it to `holder_collateral_account`. - Both vaults close; the `Lease` account closes via Anchor's `close = holder`. - State changes before close: - On the `Active` branch only, - `lease.last_paid_timestamp = min(now, end_timestamp)` — settles + `lease.last_paid_timestamp = min(now, end_timestamp)` - settles the timestamp invariant so a future program version that wants to split the default pot differently (pro-rata lease fees, partial refund) has a correct anchor to start from. @@ -611,7 +611,7 @@ and the two `close_expired` situations using concrete numbers that match the LiteSVM tests one-to-one. All four scenarios share the same starting parameters; both mints are 6-decimal tokens, so 1 token = 1 000 000 base units. "Leased units" means base units of the leased -mint and "collateral units" means base units of the collateral mint — +mint and "collateral units" means base units of the collateral mint - descriptive labels, not real tickers. Shared starting parameters: @@ -629,7 +629,7 @@ starts with 1 000 000 000 collateral units. Each scenario opens with `create_lease` and (where relevant) `take_lease` running as described in §3.1 and §3.2. Lease fees use the formula in §3.3. -#### 3.8.1 Liquidation — leased asset rallies +#### 3.8.1 Liquidation - leased asset rallies `create_lease` and `take_lease` run as standard, leaving `collateral_vault = 200_000_000`, `leased_vault = 0`, and the short @@ -638,7 +638,7 @@ seller holding 100 leased tokens. Time jumps to `T + 300`. A keeper observes a fresh Pyth price update: the leased-in-collateral price has spiked to 4.0 (exponent = 0, raw price = 4). Debt value is `100_000_000 × 4 = 400_000_000` collateral units against a collateral -pot of ~200 000 000 — maintenance ratio is `200/400 = 50%`, far below +pot of ~200 000 000 - maintenance ratio is `200/400 = 50%`, far below the required 120%. The keeper does not need to call `pay_lease_fee` first; `liquidate` settles accrued fees itself. @@ -656,7 +656,7 @@ The keeper calls `liquidate` (mechanics in §3.6). At `T + 300`: Final balances: -- **Holder:** 900 000 000 leased units (the 100 never came back — the +- **Holder:** 900 000 000 leased units (the 100 never came back - the short seller kept them), `3_000 + 189_997_150 = 190_000_150` collateral units, plus rent-exempt lamports from three closes. - **Short seller:** still holds 100 000 000 leased units, lost the @@ -667,20 +667,20 @@ The asymmetry to remember: liquidation does *not* reclaim the leased tokens. The collateral pays the holder for the lost asset; the short seller has effectively bought the leased tokens at the forfeit price. -#### 3.8.2 Falling price — short seller profits +#### 3.8.2 Falling price - short seller profits `create_lease` and `take_lease` run as standard. Time jumps to `T + 300`. The leased-in-collateral price has fallen sharply: take exponent = −1, raw price = 5, so debt value is `100_000_000 × 5 / 10 = 50_000_000` collateral units. The collateral -pot is ~200 000 000 — maintenance ratio is `200_000_000 / 50_000_000 +pot is ~200 000 000 - maintenance ratio is `200_000_000 / 50_000_000 = 400%`, far above the required 120%. A keeper calling `liquidate` here would fail with `PositionHealthy`; the program refuses to seize a healthy position. At `T + 600` (10 minutes in) the short seller buys 100 leased tokens on the open market at the new price (about 50 collateral tokens -total — far less than the 200 they posted) and calls `return_lease` +total - far less than the 200 they posted) and calls `return_lease` (mechanics in §3.5). Accrued lease fees are `600 × 10 = 6_000` collateral units. The settlement: @@ -705,7 +705,7 @@ The short seller can defend a borderline position with `top_up_collateral` (§3.4) or close it early via `return_lease` (§3.5). Only adverse price moves trigger liquidation. -#### 3.8.3 Default — `close_expired` on an `Active` lease +#### 3.8.3 Default - `close_expired` on an `Active` lease `create_lease` and `take_lease` run as standard. The short seller takes the tokens, posts collateral, then disappears. `pay_lease_fee` @@ -716,7 +716,7 @@ At `T + 100_000` the holder calls `close_expired` (mechanics in §3.7). Because `status == Active` and `now >= end_timestamp`, the default branch runs: -- `leased_vault` is empty (the short seller kept the tokens) — no +- `leased_vault` is empty (the short seller kept the tokens) - no transfer. - `collateral_vault` holds 200 000 000 collateral units; all of it flows to `holder_collateral_account`. @@ -731,7 +731,7 @@ Final balances: - **Short seller:** 100 000 000 leased units, paid the full collateral and kept the leased tokens. -#### 3.8.4 Cancel — `close_expired` on a `Listed` lease +#### 3.8.4 Cancel - `close_expired` on a `Listed` lease The cheap cancel path. `create_lease` runs; no short seller ever calls `take_lease`. The holder calls `close_expired` immediately @@ -740,7 +740,7 @@ applies: - `leased_vault` holds 100 000 000 leased units; all of it drains back to `holder_leased_account`. -- `collateral_vault` is empty — no transfer. +- `collateral_vault` is empty - no transfer. - Both vaults close, the `Lease` account closes. Final balances: the holder is back to 1 000 000 000 leased units; @@ -797,7 +797,7 @@ handler: front-run the validator clock). - **Integer-only math.** Every percentage and price calculation folds - into a `checked_mul` / `checked_div` of `u128` — no floats, no + into a `checked_mul` / `checked_div` of `u128` - no floats, no surprising NaN. `BASIS_POINTS_DENOMINATOR = 10 000` is the only "percentage denominator" anywhere; cross-check against `constants.rs` if you're porting the math. @@ -811,8 +811,8 @@ handler: - **Max maintenance margin = 500%.** Without an upper bound a holder could set a margin that is unreachable on day one and liquidate the - short seller instantly. 50 000 basis points is generous — enough - for truly speculative leases — while still blocking the pathological + short seller instantly. 50 000 basis points is generous - enough + for truly speculative leases - while still blocking the pathological 10 000× trap. - **Max liquidation bounty = 20%.** Higher than 20% and the keeper's @@ -827,7 +827,7 @@ A production version of the program would want more: (`PYTH_RECEIVER_PROGRAM_ID`), the discriminator, the layout and the feed id, but the program cannot know whether the feed the holder pinned quotes the right pair. Supplying the wrong feed at creation - is the holder's problem — the wrong feed won't cause a liquidation + is the holder's problem - the wrong feed won't cause a liquidation to succeed against a truly healthy position (the feed id check would fail), but it will mean *no* liquidation can succeed, so a short seller could drain the collateral via lease fees and walk @@ -838,8 +838,8 @@ A production version of the program would want more: units per second of `lease_fee_per_second`. Choose a small `lease_fee_per_second` and short-lived leases can settle 0 lease fees if no-one calls `pay_lease_fee` for a very short period. Not a - security issue — the accrual timestamp only moves forward when the - lease fee is actually settled — but worth knowing. + security issue - the accrual timestamp only moves forward when the + lease fee is actually settled - but worth knowing. - **Griefing on `init_if_needed`.** `take_lease`, `pay_lease_fee`, `liquidate`, `return_lease` and `close_expired` all do @@ -852,7 +852,7 @@ A production version of the program would want more: - **No partial lease-fee refund on default.** When `close_expired` runs on an `Active` lease, the holder takes the entire collateral regardless of how many lease fees had actually accrued by then. - This is a deliberate simplification — the `last_paid_timestamp` + This is a deliberate simplification - the `last_paid_timestamp` bookkeeping is in place precisely so a future version can split the pot correctly. @@ -880,7 +880,7 @@ so a fresh build must produce the `.so` first. From this directory (`defi/asset-leasing/anchor/`): ```bash -# 1. Build the BPF .so — writes to target/deploy/asset_leasing.so +# 1. Build the BPF .so - writes to target/deploy/asset_leasing.so anchor build # 2. Run the LiteSVM tests (just cargo under the hood; `anchor test` @@ -917,11 +917,11 @@ test top_up_collateral_increases_vault_balance ... ok | `take_lease_posts_collateral_and_delivers_tokens` | Collateral deposit + leased-token payout in one instruction | | `pay_lease_fee_streams_collateral_by_elapsed_time` | Lease fee math: `elapsed * lease_fee_per_second`, lease fee transferred to holder | | `top_up_collateral_increases_vault_balance` | Collateral balance after `top_up` equals deposit + top-up | -| `return_lease_refunds_unused_collateral` | Happy path round-trip — leased tokens returned, residual collateral refunded, accounts closed | +| `return_lease_refunds_unused_collateral` | Happy path round-trip - leased tokens returned, residual collateral refunded, accounts closed | | `liquidate_seizes_collateral_on_price_drop` | Price-induced underwater position → lease fee + bounty + holder share paid, accounts closed | | `liquidate_rejects_healthy_position` | Program refuses to liquidate a position that passes the margin check | | `liquidate_rejects_mismatched_price_feed` | Program refuses a `PriceUpdateV2` whose `feed_id` ≠ `lease.feed_id` | -| `close_expired_reclaims_collateral_after_end_timestamp` | Default path — holder seizes the collateral | +| `close_expired_reclaims_collateral_after_end_timestamp` | Default path - holder seizes the collateral | | `close_expired_cancels_listed_lease` | Holder-initiated cancel of an unrented lease | ### Note on CI @@ -940,7 +940,7 @@ A parallel implementation of the same program using [Quasar](https://github.com/blueshift-gg/quasar) lives in [`../quasar/`](../quasar/). Quasar is a lightweight alternative to Anchor that compiles to bare Solana program binaries without pulling in -`anchor-lang` — useful when you care about compute-unit budget, binary +`anchor-lang` - useful when you care about compute-unit budget, binary size, or simply want fewer layers between your code and the runtime. The port implements the same seven instruction handlers, the same @@ -974,7 +974,7 @@ The Quasar example in this repo's CI workflow - **Explicit instruction discriminators.** Each instruction handler carries `#[instruction(discriminator = N)]` with `N` an explicit - integer — Quasar uses one-byte discriminators by default rather than + integer - Quasar uses one-byte discriminators by default rather than Anchor's 8-byte sha256 prefix. The wire format for every call is `[discriminator: u8][borsh-serialised args]`. @@ -1019,7 +1019,7 @@ The Quasar example in this repo's CI workflow macro embeds raw references into generated code and does not (yet) have a borrow-safe way to splice instruction args like `lease_id.to_le_bytes()` into the seed list, so the Quasar port - keys its program-derived address on `[LEASE_SEED, holder]` alone — + keys its program-derived address on `[LEASE_SEED, holder]` alone - one active lease per holder. The `lease_id` is still stored on the `Lease` account for book-keeping and is a caller-supplied u64 in `create_lease`; the off-chain client just has to ensure the previous @@ -1082,7 +1082,7 @@ grouped by effort: before a keeper has an economic reason to move. - **Flash liquidation.** Let the keeper settle the debt in the same - transaction as the liquidation — borrow the leased amount from a + transaction as the liquidation - borrow the leased amount from a separate liquidity pool, hand it to the holder, take the full collateral, repay the pool, keep the spread. Requires integrating a second program. From 53c7937b3bc3f6cdb665f43c0db2b36e5f071b0b Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 21:36:54 +0000 Subject: [PATCH 30/41] docs(asset-leasing): remove redundant Roles subsection By the time the reader reaches '### Roles' they have already met: - Holder (intro lede + section 1 first paragraph) - Short seller (intro lede + section 1 first paragraph) - Keeper (defined inline at section 1 lifecycle step 4) Re-defining all three under a separate heading is filler. Deleted. --- defi/asset-leasing/anchor/README.md | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 845e64972..9942e3f9f 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -99,19 +99,6 @@ below and the position becomes liquidatable. The program is a pair of vaults, a small piece of state that tracks how much has been paid, and an oracle check. -### Roles - -- **Holder.** Long the asset, willing to part with the asset - temporarily to earn the lending fee. The economic match for this - role is a passive allocator - someone who would hold the asset - anyway and is happy to earn yield on idle inventory. -- **Short seller.** Pays the lending fee for the right to sell the - borrowed tokens now and buy them back later. The payoff shape is - the same as a short: profit if the borrowed asset falls, loss (and - possible liquidation) if the asset rises. -- **Keeper / liquidator.** Standard role - watches for - undercollateralised positions and takes the bounty for closing them. - ### Example: shorting xNVDA via the lending market Concrete numbers using assets that already trade on Solana - From f5ebe0e889a65dc428b32e5aef8e23aaf4df2884 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 21:39:48 +0000 Subject: [PATCH 31/41] docs(asset-leasing): convert markdown tables to bullet lists Converts all 6 markdown tables in defi/asset-leasing/anchor/README.md to bullet lists (parameter-values example, state/data accounts, token vaults, user accounts, error catalogue, test matrix). Tables don't render cleanly on viewers narrower or wider than ~76 chars (mobile, terminal, narrow editor split); bullet lists work everywhere. No information lost; wider account tables rewritten as natural prose rather than mechanical column-by-column key/value bullets. --- defi/asset-leasing/anchor/README.md | 106 ++++++++++++---------------- 1 file changed, 47 insertions(+), 59 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 9942e3f9f..25a9da596 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -114,15 +114,13 @@ Bob wants short exposure to NVIDIA without using a perpetual future. Alice lists the lease (assume USDC is 6-decimal, xNVDA is also 6-decimal for round numbers): -| Parameter | Value | Notes | -|---|---|---| -| `leased_amount` | `100_000_000` (100 xNVDA) | | -| `required_collateral_amount` | `22_000_000_000` (22 000 USDC) | ~122% LTV at the spot price | -| `lease_fee_per_second` | `456` (USDC base units / s) | ≈ 8% APR on 18 000 USDC notional | -| `duration_seconds` | `2_592_000` | 30 days | -| `maintenance_margin_basis_points` | `11_000` | 110% | -| `liquidation_bounty_basis_points` | `100` | 1% of post-fee collateral | -| `feed_id` | Pyth xNVDA/USD feed id | ([Pyth feed registry](https://www.pyth.network/price-feeds)) | +- **`leased_amount`**: `100_000_000` (100 xNVDA) +- **`required_collateral_amount`**: `22_000_000_000` (22 000 USDC) - ~122% LTV at the spot price +- **`lease_fee_per_second`**: `456` (USDC base units / s) - ≈ 8% APR on 18 000 USDC notional +- **`duration_seconds`**: `2_592_000` - 30 days +- **`maintenance_margin_basis_points`**: `11_000` - 110% +- **`liquidation_bounty_basis_points`**: `100` - 1% of post-fee collateral +- **`feed_id`**: Pyth xNVDA/USD feed id ([Pyth feed registry](https://www.pyth.network/price-feeds)) Bob calls `take_lease`, posts 22 000 USDC, receives 100 xNVDA, and sells the 100 xNVDA on Jupiter for ~18 000 USDC at the spot price. @@ -183,31 +181,25 @@ are created on `create_lease` and destroyed on `return_lease` / ### State / data accounts -| Account | program-derived address? | Seeds | Kind | Authority | Holds | -|---|---|---|---|---|---| -| `Lease` | yes | `["lease", holder, lease_id]` | data | program | all the lease parameters and current lifecycle state (see below) | +- **`Lease`** - program-derived address with seeds `["lease", holder, lease_id]`. Data account owned by the program, holding all the lease parameters and current lifecycle state (see below). ### Token vaults -| Account | program-derived address? | Seeds | Kind | Authority | Holds | -|---|---|---|---|---|---| -| `leased_vault` | yes | `["leased_vault", lease]` | token account | itself (program-derived address-signed) | `leased_amount` while `Listed`; 0 while `Active` (short seller has the tokens); full amount again briefly inside `return_lease` | -| `collateral_vault` | yes | `["collateral_vault", lease]` | token account | itself (program-derived address-signed) | 0 while `Listed`; `collateral_amount` while `Active`, decreasing as lease fee streams out and increasing on `top_up_collateral` | +- **`leased_vault`** - program-derived address with seeds `["leased_vault", lease]`. Token account whose authority is itself (program-derived-address-signed). Holds `leased_amount` while `Listed`; `0` while `Active` (the short seller has the tokens); full amount again briefly inside `return_lease`. +- **`collateral_vault`** - program-derived address with seeds `["collateral_vault", lease]`. Token account whose authority is itself (program-derived-address-signed). Holds `0` while `Listed`; `collateral_amount` while `Active`, decreasing as lease fee streams out and increasing on `top_up_collateral`. ### User accounts passed in -| Account | Owner | Purpose | -|---|---|---| -| `holder` wallet | user | `create_lease` signer, receives the lease fee and final recovery | -| `short_seller` wallet | user | `take_lease` / `top_up_collateral` / `return_lease` signer | -| `keeper` wallet | user | `liquidate` signer, receives the bounty | -| `payer` wallet | user | `pay_lease_fee` signer (can be anyone, not just the short seller) | -| `holder_leased_account` | token account | holder's [associated token account](https://solana.com/docs/terminology) for the leased mint; source on `create_lease`, destination on `return_lease` / `close_expired` | -| `holder_collateral_account` | token account | holder's associated token account for the collateral mint; destination for the lease fee and liquidation proceeds | -| `short_seller_leased_account` | token account | short seller's associated token account for the leased mint; destination on `take_lease`, source on `return_lease` | -| `short_seller_collateral_account` | token account | short seller's associated token account for the collateral mint; source on `take_lease` / `top_up_collateral`, destination for collateral refund on `return_lease` | -| `keeper_collateral_account` | token account | keeper's associated token account for the collateral mint; receives the liquidation bounty | -| `price_update` | Pyth Receiver program | `PriceUpdateV2` account for the feed the lease is pinned to | +- **`holder` wallet** (user-owned) - `create_lease` signer, receives the lease fee and final recovery. +- **`short_seller` wallet** (user-owned) - `take_lease` / `top_up_collateral` / `return_lease` signer. +- **`keeper` wallet** (user-owned) - `liquidate` signer, receives the bounty. +- **`payer` wallet** (user-owned) - `pay_lease_fee` signer (can be anyone, not just the short seller). +- **`holder_leased_account`** - holder's [associated token account](https://solana.com/docs/terminology) for the leased mint; source on `create_lease`, destination on `return_lease` / `close_expired`. +- **`holder_collateral_account`** - holder's associated token account for the collateral mint; destination for the lease fee and liquidation proceeds. +- **`short_seller_leased_account`** - short seller's associated token account for the leased mint; destination on `take_lease`, source on `return_lease`. +- **`short_seller_collateral_account`** - short seller's associated token account for the collateral mint; source on `take_lease` / `top_up_collateral`, destination for collateral refund on `return_lease`. +- **`keeper_collateral_account`** - keeper's associated token account for the collateral mint; receives the liquidation bounty. +- **`price_update`** - `PriceUpdateV2` account owned by the Pyth Receiver program, for the feed the lease is pinned to. ### Fields on `Lease` @@ -743,24 +735,22 @@ All of the following come from [`errors.rs`](programs/asset-leasing/src/errors.r and are enforced by either an Anchor constraint or a `require!` in the handler: -| Error | When | -|---|---| -| `InvalidLeaseStatus` | Action tried against a lease in the wrong state (e.g. `take_lease` on a lease that is already `Active`) | -| `InvalidDuration` | `duration_seconds <= 0` on `create_lease` | -| `InvalidLeasedAmount` | `leased_amount == 0` on `create_lease` | -| `InvalidCollateralAmount` | `required_collateral_amount == 0` on `create_lease`; `amount == 0` on `top_up_collateral` | -| `InvalidLeaseFeePerSecond` | `lease_fee_per_second == 0` on `create_lease` | -| `InvalidMaintenanceMargin` | `maintenance_margin_basis_points == 0` or `> 50_000` on `create_lease` | -| `InvalidLiquidationBounty` | `liquidation_bounty_basis_points > 2_000` on `create_lease` | -| `LeaseExpired` | Reserved; not currently used (lease fee accrual naturally caps at `end_timestamp`) | -| `LeaseNotExpired` | `close_expired` called on an `Active` lease before `end_timestamp` | -| `PositionHealthy` | `liquidate` called on a lease that passes the maintenance-margin check | -| `StalePrice` | Pyth price update older than 60 s, or has a future `publish_time`, or fails discriminator / length check | -| `NonPositivePrice` | Pyth price is `<= 0` | -| `MathOverflow` | Any of the `checked_*` arithmetic returned `None` | -| `Unauthorised` | Lease-modifying handler called by someone who is not the registered short seller (`top_up_collateral`, `return_lease`) | -| `LeasedMintEqualsCollateralMint` | `create_lease` called with the same mint for both sides | -| `PriceFeedMismatch` | `liquidate` called with a Pyth update whose `feed_id` does not match `lease.feed_id` | +- **`InvalidLeaseStatus`** - action tried against a lease in the wrong state (e.g. `take_lease` on a lease that is already `Active`). +- **`InvalidDuration`** - `duration_seconds <= 0` on `create_lease`. +- **`InvalidLeasedAmount`** - `leased_amount == 0` on `create_lease`. +- **`InvalidCollateralAmount`** - `required_collateral_amount == 0` on `create_lease`; `amount == 0` on `top_up_collateral`. +- **`InvalidLeaseFeePerSecond`** - `lease_fee_per_second == 0` on `create_lease`. +- **`InvalidMaintenanceMargin`** - `maintenance_margin_basis_points == 0` or `> 50_000` on `create_lease`. +- **`InvalidLiquidationBounty`** - `liquidation_bounty_basis_points > 2_000` on `create_lease`. +- **`LeaseExpired`** - reserved; not currently used (lease fee accrual naturally caps at `end_timestamp`). +- **`LeaseNotExpired`** - `close_expired` called on an `Active` lease before `end_timestamp`. +- **`PositionHealthy`** - `liquidate` called on a lease that passes the maintenance-margin check. +- **`StalePrice`** - Pyth price update older than 60 s, or has a future `publish_time`, or fails discriminator / length check. +- **`NonPositivePrice`** - Pyth price is `<= 0`. +- **`MathOverflow`** - any of the `checked_*` arithmetic returned `None`. +- **`Unauthorised`** - lease-modifying handler called by someone who is not the registered short seller (`top_up_collateral`, `return_lease`). +- **`LeasedMintEqualsCollateralMint`** - `create_lease` called with the same mint for both sides. +- **`PriceFeedMismatch`** - `liquidate` called with a Pyth update whose `feed_id` does not match `lease.feed_id`. ### 4.2 Guarded design choices worth knowing @@ -897,19 +887,17 @@ test top_up_collateral_increases_vault_balance ... ok ### What each test exercises -| Test | Exercises | -|---|---| -| `create_lease_locks_tokens_and_lists` | Holder funds vault, `Lease` created, collateral vault empty | -| `create_lease_rejects_same_mint_for_leased_and_collateral` | Guard against `leased_mint == collateral_mint` | -| `take_lease_posts_collateral_and_delivers_tokens` | Collateral deposit + leased-token payout in one instruction | -| `pay_lease_fee_streams_collateral_by_elapsed_time` | Lease fee math: `elapsed * lease_fee_per_second`, lease fee transferred to holder | -| `top_up_collateral_increases_vault_balance` | Collateral balance after `top_up` equals deposit + top-up | -| `return_lease_refunds_unused_collateral` | Happy path round-trip - leased tokens returned, residual collateral refunded, accounts closed | -| `liquidate_seizes_collateral_on_price_drop` | Price-induced underwater position → lease fee + bounty + holder share paid, accounts closed | -| `liquidate_rejects_healthy_position` | Program refuses to liquidate a position that passes the margin check | -| `liquidate_rejects_mismatched_price_feed` | Program refuses a `PriceUpdateV2` whose `feed_id` ≠ `lease.feed_id` | -| `close_expired_reclaims_collateral_after_end_timestamp` | Default path - holder seizes the collateral | -| `close_expired_cancels_listed_lease` | Holder-initiated cancel of an unrented lease | +- **`create_lease_locks_tokens_and_lists`** - holder funds vault, `Lease` created, collateral vault empty. +- **`create_lease_rejects_same_mint_for_leased_and_collateral`** - guard against `leased_mint == collateral_mint`. +- **`take_lease_posts_collateral_and_delivers_tokens`** - collateral deposit + leased-token payout in one instruction. +- **`pay_lease_fee_streams_collateral_by_elapsed_time`** - lease fee math: `elapsed * lease_fee_per_second`, lease fee transferred to holder. +- **`top_up_collateral_increases_vault_balance`** - collateral balance after `top_up` equals deposit + top-up. +- **`return_lease_refunds_unused_collateral`** - happy path round-trip; leased tokens returned, residual collateral refunded, accounts closed. +- **`liquidate_seizes_collateral_on_price_drop`** - price-induced underwater position; lease fee + bounty + holder share paid, accounts closed. +- **`liquidate_rejects_healthy_position`** - program refuses to liquidate a position that passes the margin check. +- **`liquidate_rejects_mismatched_price_feed`** - program refuses a `PriceUpdateV2` whose `feed_id` does not match `lease.feed_id`. +- **`close_expired_reclaims_collateral_after_end_timestamp`** - default path; holder seizes the collateral. +- **`close_expired_cancels_listed_lease`** - holder-initiated cancel of an unrented lease. ### Note on CI From 6cf024f3442b522f08a8615b9dd5891aa629af05 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 21:44:15 +0000 Subject: [PATCH 32/41] docs(asset-leasing): clarify how the per-second lease fee actually accrues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §1 lifecycle steps said 'a per-second lending fee accrues... settled by pay_lease_fee' which made it sound like the short seller needs to call pay_lease_fee every second. They don't. Now explained correctly: - The fee is a number that GROWS continuously against the locked collateral, but no transactions fire automatically. - The program computes the accrued fee ON DEMAND: every handler multiplies (now - last_paid_timestamp) * lease_fee_per_second and debits the result from collateral. - The short seller doesn't have to do anything while waiting; fees auto-settle at return_lease / liquidate / close_expired. - pay_lease_fee is OPTIONAL - call it to settle the running balance early so it doesn't eat into the collateral cushion. --- defi/asset-leasing/anchor/README.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 25a9da596..7e2aa8349 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -43,14 +43,22 @@ tokens. The short seller's full lifecycle is: 1. **Open the position** by calling `take_lease`. This borrows A from - the holder and locks B as collateral. A per-second lending fee - accrues from this point onward, settled by `pay_lease_fee`. + the holder and locks B as collateral. From this point on, a + per-second lending fee accrues against the locked collateral. The + fee is computed on demand: the program tracks + `last_paid_timestamp` and `lease_fee_per_second` on the lease + account, multiplies by elapsed seconds whenever any handler runs, + and debits the result from the collateral. Nothing happens onchain + each second - the fee is just a number that grows until someone + pokes the lease. 2. **Sell A immediately** on a market like Jupiter, receiving more B in return. The short seller now has more B and owes A. 3. **Wait.** They are betting A's price (denominated in B) will fall. - While waiting, they may call `top_up_collateral` to defend the - position if A's price moves against them, and `pay_lease_fee` to - settle accrued fees. + The short seller doesn't have to call anything while they wait - + accrued fees auto-settle at close. They can optionally call + `pay_lease_fee` to settle the running balance early (so the fee + doesn't eat into their collateral cushion), and `top_up_collateral` + to add more collateral if A's price moves against them. 4. **Close the position** by calling `return_lease`. They buy A back on the open market - hopefully at a lower price than they sold it for - and return the same quantity of A to the holder. The B they From db46658f0beae7aecf2947aaf908767a4562ef2f Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 21:49:29 +0000 Subject: [PATCH 33/41] docs(asset-leasing): replace bare \u00a73.x section references with named anchor links Bare '\u00a73.4' or 'See \u00a74' references force the reader to scroll back through the doc to find the section. Lawyer-style cross-references should always be clickable links to a named anchor. Replaced all 12 bare \u00a7-references with markdown links to the corresponding GitHub-auto-generated heading anchors. Verified each anchor slug against the heading text manually. --- defi/asset-leasing/anchor/README.md | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 7e2aa8349..103369698 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -165,9 +165,9 @@ rallies against the collateral. A drop in the borrowed asset price is purely beneficial to the short seller. The streaming lending fee is the position's only ongoing cost in either direction. -§3 walks each instruction handler with concrete numbers that match -the LiteSVM tests; the xNVDA example above is the same machinery -applied to a real asset pair. +[Section 3 (Lifecycle)](#3-lifecycle) walks each instruction handler +with concrete numbers that match the LiteSVM tests; the xNVDA example +above is the same machinery applied to a real asset pair. ### Production deviations to know @@ -176,7 +176,7 @@ applied to a real asset pair. inline in `liquidate.rs`. Production code would depend on the `pyth-solana-receiver-sdk` crate so layout changes are caught at compile time. -- See §4 for the rest of the deliberate simplifications. +- See [Section 4 (Safety and edge cases)](#4-safety-and-edge-cases) for the rest of the deliberate simplifications. --- @@ -387,8 +387,9 @@ being liquidated, or defaulting; no further lease fees are owed. `lease.last_paid_timestamp = min(now, end_timestamp)`. - If the vault did not have enough collateral to cover the full `lease_fee_due`, the residual is silently left as a debt the next - `liquidate` or `close_expired` call cleans up. (See §4 for the - rationale on this trade-off.) + `liquidate` or `close_expired` call cleans up. (See + [Section 4 (Safety and edge cases)](#4-safety-and-edge-cases) for + the rationale on this trade-off.) - **Errors:** - `InvalidLeaseStatus` if the lease is not `Active` - `MathOverflow` if `elapsed * lease_fee_per_second` overflows `u64` @@ -614,7 +615,7 @@ Shared starting parameters: The holder starts with 1 000 000 000 leased units; the short seller starts with 1 000 000 000 collateral units. Each scenario opens with `create_lease` and (where relevant) `take_lease` running as described -in §3.1 and §3.2. Lease fees use the formula in §3.3. +in [§3.1](#31-the-holder-lists-the-tokens---create_lease) and [§3.2](#32-the-short-seller-takes-the-offer---take_lease). Lease fees use the formula in [§3.3](#33-the-lease-fee-streams---pay_lease_fee). #### 3.8.1 Liquidation - leased asset rallies @@ -629,7 +630,7 @@ pot of ~200 000 000 - maintenance ratio is `200/400 = 50%`, far below the required 120%. The keeper does not need to call `pay_lease_fee` first; `liquidate` settles accrued fees itself. -The keeper calls `liquidate` (mechanics in §3.6). At `T + 300`: +The keeper calls `liquidate` (mechanics in [§3.6](#36-branch-position-underwater---liquidate)). At `T + 300`: - Accrued lease fee: `300 × 10 = 3_000` collateral units. The vault has 200 000 000, so `lease_fee_payable = 3_000` flows to the holder. @@ -668,7 +669,7 @@ a healthy position. At `T + 600` (10 minutes in) the short seller buys 100 leased tokens on the open market at the new price (about 50 collateral tokens total - far less than the 200 they posted) and calls `return_lease` -(mechanics in §3.5). Accrued lease fees are `600 × 10 = 6_000` +(mechanics in [§3.5](#35-the-short-seller-closes---return_lease)). Accrued lease fees are `600 × 10 = 6_000` collateral units. The settlement: - 100 000 000 leased units flow short seller → leased vault → holder. @@ -689,8 +690,8 @@ Final balances: payoff. The short seller can defend a borderline position with -`top_up_collateral` (§3.4) or close it early via `return_lease` -(§3.5). Only adverse price moves trigger liquidation. +`top_up_collateral` ([§3.4](#34-the-short-seller-defends-the-position---top_up_collateral)) or close it early via `return_lease` +([§3.5](#35-the-short-seller-closes---return_lease)). Only adverse price moves trigger liquidation. #### 3.8.3 Default - `close_expired` on an `Active` lease @@ -700,7 +701,7 @@ is never called. The clock advances past `end_timestamp = T + 86_400`. At `T + 100_000` the holder calls `close_expired` (mechanics in -§3.7). Because `status == Active` and `now >= end_timestamp`, the +[§3.7](#37-branch-cancel-or-default---close_expired)). Because `status == Active` and `now >= end_timestamp`, the default branch runs: - `leased_vault` is empty (the short seller kept the tokens) - no @@ -722,7 +723,7 @@ Final balances: The cheap cancel path. `create_lease` runs; no short seller ever calls `take_lease`. The holder calls `close_expired` immediately -(mechanics in §3.7). Because `status == Listed`, no expiry check +(mechanics in [§3.7](#37-branch-cancel-or-default---close_expired)). Because `status == Listed`, no expiry check applies: - `leased_vault` holds 100 000 000 leased units; all of it drains From 7c0f71004ae1d728a42a4f4e51a30179d6d2d998 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 21:54:51 +0000 Subject: [PATCH 34/41] docs(asset-leasing): strip section numbers from headings; rewrite cross-references with word-based link text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHAT: removed leading section numbers (1., 2., 3.1, 3.6, 3.8.1, etc.) from every heading in defi/asset-leasing/anchor/README.md. Updated the table of contents to point to the new numberless anchor slugs. Rewrote every cross-reference (previously written as [§3.6](...) or [Section 4 (...)](...)) to use word-based link text drawn from the heading itself, so the link reads as part of the surrounding prose. WHY: clickable links should read naturally inside a sentence rather than interrupt the reader with section-number references. Headings with leading numbers also drift out of sync the moment a section is reordered or renamed. --- defi/asset-leasing/anchor/README.md | 82 ++++++++++++++--------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 103369698..d4c04175a 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -17,23 +17,23 @@ anyway (think exchange-traded funds, pension funds, or any passive allocator), and short sellers and arbitrageurs get the tokens they need to sell short. The program is written in [Anchor](https://solana.com/docs/terminology); a parallel -[Quasar port](#6-quasar-port) implements the same onchain behaviour. +[Quasar port](#quasar-port) implements the same onchain behaviour. --- ## Table of contents -1. [What does this program do?](#1-what-does-this-program-do) -2. [Accounts and program-derived addresses](#2-accounts-and-program-derived-addresses) -3. [Lifecycle](#3-lifecycle) -4. [Safety and edge cases](#4-safety-and-edge-cases) -5. [Running the tests](#5-running-the-tests) -6. [Quasar port](#6-quasar-port) -7. [Extending the program](#7-extending-the-program) +1. [What does this program do?](#what-does-this-program-do) +2. [Accounts and program-derived addresses](#accounts-and-program-derived-addresses) +3. [Lifecycle](#lifecycle) +4. [Safety and edge cases](#safety-and-edge-cases) +5. [Running the tests](#running-the-tests) +6. [Quasar port](#quasar-port) +7. [Extending the program](#extending-the-program) --- -## 1. What does this program do? +## What does this program do? A **holder** offers some quantity of **token A** - the leased token - for a fixed term. A **short seller** posts collateral in a different @@ -165,7 +165,7 @@ rallies against the collateral. A drop in the borrowed asset price is purely beneficial to the short seller. The streaming lending fee is the position's only ongoing cost in either direction. -[Section 3 (Lifecycle)](#3-lifecycle) walks each instruction handler +The [lifecycle](#lifecycle) section walks each instruction handler with concrete numbers that match the LiteSVM tests; the xNVDA example above is the same machinery applied to a real asset pair. @@ -176,11 +176,11 @@ above is the same machinery applied to a real asset pair. inline in `liquidate.rs`. Production code would depend on the `pyth-solana-receiver-sdk` crate so layout changes are caught at compile time. -- See [Section 4 (Safety and edge cases)](#4-safety-and-edge-cases) for the rest of the deliberate simplifications. +- See [safety and edge cases](#safety-and-edge-cases) for the rest of the deliberate simplifications. --- -## 2. Accounts and program-derived addresses +## Accounts and program-derived addresses Every call to the program touches some subset of these accounts. The three [program-derived addresses](https://solana.com/docs/terminology) @@ -254,7 +254,7 @@ record the terminal state, but the account disappears at the end. --- -## 3. Lifecycle +## Lifecycle ### What the short seller really gets @@ -275,9 +275,9 @@ the cost of fulfilling the obligation later is fixed in tokens whose price is unknown. Bet correctly on the direction and that asymmetry prints money. Bet wrong and the cost of buying the tokens back can exceed the cash plus the collateral, at which point the keepers -arrive (see [§3.6](#36-branch-position-underwater---liquidate)). +arrive (see [branch: position underwater - `liquidate`](#branch-position-underwater---liquidate)). -### 3.1 The holder lists the tokens - `create_lease` +### The holder lists the tokens - `create_lease` The holder calls `create_lease`, naming the leased mint, the collateral mint, the amount of leased tokens to offer, the @@ -318,7 +318,7 @@ transfers to the program the moment the lease is listed. - `InvalidMaintenanceMargin` if `maintenance_margin_basis_points` is `0` or `> 50_000` - `InvalidLiquidationBounty` if `liquidation_bounty_basis_points > 2_000` -### 3.2 The short seller takes the offer - `take_lease` +### The short seller takes the offer - `take_lease` A short seller who has spotted the `Lease` account onchain (via an indexer or a direct lookup) calls `take_lease` to take delivery. The @@ -357,7 +357,7 @@ moves from `Listed` to `Active`. `collateral_mint` do not match the values stored on the lease - `MathOverflow` if `now + duration_seconds` overflows `i64` -### 3.3 The lease fee streams - `pay_lease_fee` +### The lease fee streams - `pay_lease_fee` The lease fee accrues second by second out of the collateral vault. Anyone can call `pay_lease_fee` to settle whatever has accrued since @@ -388,13 +388,13 @@ being liquidated, or defaulting; no further lease fees are owed. - If the vault did not have enough collateral to cover the full `lease_fee_due`, the residual is silently left as a debt the next `liquidate` or `close_expired` call cleans up. (See - [Section 4 (Safety and edge cases)](#4-safety-and-edge-cases) for + [safety and edge cases](#safety-and-edge-cases) for the rationale on this trade-off.) - **Errors:** - `InvalidLeaseStatus` if the lease is not `Active` - `MathOverflow` if `elapsed * lease_fee_per_second` overflows `u64` -### 3.4 The short seller defends the position - `top_up_collateral` +### The short seller defends the position - `top_up_collateral` If the price moves against the short seller and the position drifts toward the maintenance-margin floor, the short seller can add more @@ -423,7 +423,7 @@ is `Active`. - `InvalidLeaseStatus` if the lease is not `Active` - `MathOverflow` if the addition overflows `u64` -### 3.5 The short seller closes - `return_lease` +### The short seller closes - `return_lease` To close the position, the short seller buys back the leased tokens on the open market and calls `return_lease`. The program runs the @@ -476,7 +476,7 @@ time at `end_timestamp`. - `Unauthorised` if `lease.short_seller != short_seller.key()` - `MathOverflow` if the lease-fee or collateral subtraction overflows -### 3.6 Branch: position underwater - `liquidate` +### Branch: position underwater - `liquidate` If the leased asset rallies far enough that the locked collateral is no longer worth more than the debt times the maintenance margin, @@ -545,7 +545,7 @@ math non-negative (see [`is_underwater`](programs/asset-leasing/src/instructions - `InvalidLeaseStatus` if the lease is not `Active` - `MathOverflow` on any of the integer-multiplication steps -### 3.7 Branch: cancel or default - `close_expired` +### Branch: cancel or default - `close_expired` The holder has a single recovery handler that covers two unrelated situations: @@ -591,7 +591,7 @@ rent-exempt-lamport refunds going to the holder. - `InvalidLeaseStatus` if `status` is not `Listed` or `Active` - `LeaseNotExpired` if `status == Active` and `now < end_timestamp` -### 3.8 Branch scenarios +### Branch scenarios The handlers above cover the happy path. The branch scenarios below walk the same machinery through liquidation, a falling-price profit, @@ -615,9 +615,9 @@ Shared starting parameters: The holder starts with 1 000 000 000 leased units; the short seller starts with 1 000 000 000 collateral units. Each scenario opens with `create_lease` and (where relevant) `take_lease` running as described -in [§3.1](#31-the-holder-lists-the-tokens---create_lease) and [§3.2](#32-the-short-seller-takes-the-offer---take_lease). Lease fees use the formula in [§3.3](#33-the-lease-fee-streams---pay_lease_fee). +in [the holder lists the tokens - `create_lease`](#the-holder-lists-the-tokens---create_lease) and [the short seller takes the offer - `take_lease`](#the-short-seller-takes-the-offer---take_lease). Lease fees use the formula in [the lease fee streams - `pay_lease_fee`](#the-lease-fee-streams---pay_lease_fee). -#### 3.8.1 Liquidation - leased asset rallies +#### Liquidation - leased asset rallies `create_lease` and `take_lease` run as standard, leaving `collateral_vault = 200_000_000`, `leased_vault = 0`, and the short @@ -630,7 +630,7 @@ pot of ~200 000 000 - maintenance ratio is `200/400 = 50%`, far below the required 120%. The keeper does not need to call `pay_lease_fee` first; `liquidate` settles accrued fees itself. -The keeper calls `liquidate` (mechanics in [§3.6](#36-branch-position-underwater---liquidate)). At `T + 300`: +The keeper calls `liquidate` (mechanics in [branch: position underwater - `liquidate`](#branch-position-underwater---liquidate)). At `T + 300`: - Accrued lease fee: `300 × 10 = 3_000` collateral units. The vault has 200 000 000, so `lease_fee_payable = 3_000` flows to the holder. @@ -655,7 +655,7 @@ The asymmetry to remember: liquidation does *not* reclaim the leased tokens. The collateral pays the holder for the lost asset; the short seller has effectively bought the leased tokens at the forfeit price. -#### 3.8.2 Falling price - short seller profits +#### Falling price - short seller profits `create_lease` and `take_lease` run as standard. Time jumps to `T + 300`. The leased-in-collateral price has fallen sharply: take @@ -669,7 +669,7 @@ a healthy position. At `T + 600` (10 minutes in) the short seller buys 100 leased tokens on the open market at the new price (about 50 collateral tokens total - far less than the 200 they posted) and calls `return_lease` -(mechanics in [§3.5](#35-the-short-seller-closes---return_lease)). Accrued lease fees are `600 × 10 = 6_000` +(mechanics in [the short seller closes - `return_lease`](#the-short-seller-closes---return_lease)). Accrued lease fees are `600 × 10 = 6_000` collateral units. The settlement: - 100 000 000 leased units flow short seller → leased vault → holder. @@ -690,10 +690,10 @@ Final balances: payoff. The short seller can defend a borderline position with -`top_up_collateral` ([§3.4](#34-the-short-seller-defends-the-position---top_up_collateral)) or close it early via `return_lease` -([§3.5](#35-the-short-seller-closes---return_lease)). Only adverse price moves trigger liquidation. +`top_up_collateral` ([the short seller defends the position - `top_up_collateral`](#the-short-seller-defends-the-position---top_up_collateral)) or close it early via `return_lease` +([the short seller closes - `return_lease`](#the-short-seller-closes---return_lease)). Only adverse price moves trigger liquidation. -#### 3.8.3 Default - `close_expired` on an `Active` lease +#### Default - `close_expired` on an `Active` lease `create_lease` and `take_lease` run as standard. The short seller takes the tokens, posts collateral, then disappears. `pay_lease_fee` @@ -701,7 +701,7 @@ is never called. The clock advances past `end_timestamp = T + 86_400`. At `T + 100_000` the holder calls `close_expired` (mechanics in -[§3.7](#37-branch-cancel-or-default---close_expired)). Because `status == Active` and `now >= end_timestamp`, the +[branch: cancel or default - `close_expired`](#branch-cancel-or-default---close_expired)). Because `status == Active` and `now >= end_timestamp`, the default branch runs: - `leased_vault` is empty (the short seller kept the tokens) - no @@ -719,11 +719,11 @@ Final balances: - **Short seller:** 100 000 000 leased units, paid the full collateral and kept the leased tokens. -#### 3.8.4 Cancel - `close_expired` on a `Listed` lease +#### Cancel - `close_expired` on a `Listed` lease The cheap cancel path. `create_lease` runs; no short seller ever calls `take_lease`. The holder calls `close_expired` immediately -(mechanics in [§3.7](#37-branch-cancel-or-default---close_expired)). Because `status == Listed`, no expiry check +(mechanics in [branch: cancel or default - `close_expired`](#branch-cancel-or-default---close_expired)). Because `status == Listed`, no expiry check applies: - `leased_vault` holds 100 000 000 leased units; all of it drains @@ -736,9 +736,9 @@ nothing else moved. --- -## 4. Safety and edge cases +## Safety and edge cases -### 4.1 What the program refuses to do +### What the program refuses to do All of the following come from [`errors.rs`](programs/asset-leasing/src/errors.rs) and are enforced by either an Anchor constraint or a `require!` in the @@ -761,7 +761,7 @@ handler: - **`LeasedMintEqualsCollateralMint`** - `create_lease` called with the same mint for both sides. - **`PriceFeedMismatch`** - `liquidate` called with a Pyth update whose `feed_id` does not match `lease.feed_id`. -### 4.2 Guarded design choices worth knowing +### Guarded design choices worth knowing - **Leased tokens are locked up-front.** `create_lease` moves the tokens into the `leased_vault` immediately, so a short seller calling @@ -805,7 +805,7 @@ handler: cut would dwarf the holder's recovery on default. The cap keeps liquidation economics roughly in line with holder-first semantics. -### 4.3 Things the program does *not* guard against +### Things the program does *not* guard against A production version of the program would want more: @@ -847,7 +847,7 @@ A production version of the program would want more: --- -## 5. Running the tests +## Running the tests All the tests are LiteSVM-based Rust integration tests under [`programs/asset-leasing/tests/`](programs/asset-leasing/tests/). They @@ -918,7 +918,7 @@ CI is already covered. --- -## 6. Quasar port +## Quasar port A parallel implementation of the same program using [Quasar](https://github.com/blueshift-gg/quasar) lives in @@ -1019,7 +1019,7 @@ handler. Tests are in `src/tests.rs`. --- -## 7. Extending the program +## Extending the program Directions a real-world version of the program would consider, grouped by effort: From ab992044c49ece5bd76a736375a60fa1afb582c1 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 21:55:21 +0000 Subject: [PATCH 35/41] docs(asset-leasing): add the holder's full lifecycle The README walked through the short seller's lifecycle in detail (open / sell / wait / close) but never gave the holder the same treatment. Now both parties have explicit lifecycle steps: - list tokens via create_lease (Listed status) - wait for a taker (Active) or cancel - earn fees passively while Active - get paid out at close via any of return_lease / liquidate / close_expired Plus a paragraph clarifying the two close_expired situations (cancel a Listed lease, or seize collateral on an Active default) since these are the holder's most surprising calls. --- defi/asset-leasing/anchor/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index d4c04175a..db291130f 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -73,6 +73,31 @@ bounty from the collateral. If the lease term ends without the short seller calling `return_lease`, the holder calls `close_expired` to seize the collateral and recover. +The holder's full lifecycle is shorter: + +1. **List the tokens** by calling `create_lease`. This locks the A + tokens in a program-owned vault and publishes the terms (collateral + required, lease fee, duration, maintenance margin, liquidation + bounty, oracle feed). The lease starts in `Listed` status. +2. **Wait for a taker.** If a short seller takes the offer (calling + `take_lease`), the lease moves to `Active` status and the holder + starts earning the per-second lending fee. If no-one takes it, the + holder can cancel at any time. +3. **Earn fees while the lease is `Active`.** The holder doesn't have + to call anything; the fee accrues against the short seller's + collateral and settles whenever any handler runs against the lease. +4. **Get paid out at close.** Whichever path the lease takes (clean + return, liquidation, or expiry), the holder ends up with their A + tokens back (or, on liquidation/expiry default, the equivalent + value in B as compensation) plus all the lease fees that accrued. + +The holder can call `close_expired` to terminate the lease in two +situations: (a) the lease is `Listed` and they want to cancel it +before any short seller takes it, or (b) the lease is `Active`, the +deadline has passed, and the short seller hasn't returned the tokens - +in which case the holder seizes the entire collateral as compensation +for the missing tokens. + The program acts as a non-custodial escrow. It: 1. Takes the holder's A tokens and locks them in a program-owned From 61e25c58088eac43fa1a94045abbda180d0a1ea3 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 22:00:22 +0000 Subject: [PATCH 36/41] asset-leasing: merge accounts section into lifecycle WHAT: Deleted the standalone Accounts and program-derived addresses section and integrated its content into the Lifecycle handler walkthroughs. Each account is now introduced at the moment it is first created or first used: - Lease, leased_vault, collateral_vault: introduced in create_lease with seeds and roles in a new program-derived addresses bullet list, plus the Lease struct definition in a new What's on the lease account subsection. - holder, short_seller, payer, keeper user wallets: a one-line description added on first appearance in their respective handler's Signers bullet. - Associated token accounts (holder_leased_account, holder_collateral_account, short_seller_*, keeper_collateral_account): one-line associated-token-account description added on first appearance in each handler's Accounts bullet. - price_update: introduced in liquidate, where the Pyth oracle account is first passed in. - The Closed/Liquidated states paragraph moved to the end of return_lease (the first close handler), since it covers all three closing paths. Table of contents and any cross-references to the deleted section updated. WHY: A reader walking the lifecycle no longer has to flip back to a static account dump to know what each account is - the prose introduces every account the moment it appears in the narrative. --- defi/asset-leasing/anchor/README.md | 199 ++++++++++++++-------------- 1 file changed, 97 insertions(+), 102 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index db291130f..2be1502d1 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -24,12 +24,11 @@ need to sell short. The program is written in ## Table of contents 1. [What does this program do?](#what-does-this-program-do) -2. [Accounts and program-derived addresses](#accounts-and-program-derived-addresses) -3. [Lifecycle](#lifecycle) -4. [Safety and edge cases](#safety-and-edge-cases) -5. [Running the tests](#running-the-tests) -6. [Quasar port](#quasar-port) -7. [Extending the program](#extending-the-program) +2. [Lifecycle](#lifecycle) +3. [Safety and edge cases](#safety-and-edge-cases) +4. [Running the tests](#running-the-tests) +5. [Quasar port](#quasar-port) +6. [Extending the program](#extending-the-program) --- @@ -205,80 +204,6 @@ above is the same machinery applied to a real asset pair. --- -## Accounts and program-derived addresses - -Every call to the program touches some subset of these accounts. The -three [program-derived addresses](https://solana.com/docs/terminology) -are created on `create_lease` and destroyed on `return_lease` / -`liquidate` / `close_expired`. - -### State / data accounts - -- **`Lease`** - program-derived address with seeds `["lease", holder, lease_id]`. Data account owned by the program, holding all the lease parameters and current lifecycle state (see below). - -### Token vaults - -- **`leased_vault`** - program-derived address with seeds `["leased_vault", lease]`. Token account whose authority is itself (program-derived-address-signed). Holds `leased_amount` while `Listed`; `0` while `Active` (the short seller has the tokens); full amount again briefly inside `return_lease`. -- **`collateral_vault`** - program-derived address with seeds `["collateral_vault", lease]`. Token account whose authority is itself (program-derived-address-signed). Holds `0` while `Listed`; `collateral_amount` while `Active`, decreasing as lease fee streams out and increasing on `top_up_collateral`. - -### User accounts passed in - -- **`holder` wallet** (user-owned) - `create_lease` signer, receives the lease fee and final recovery. -- **`short_seller` wallet** (user-owned) - `take_lease` / `top_up_collateral` / `return_lease` signer. -- **`keeper` wallet** (user-owned) - `liquidate` signer, receives the bounty. -- **`payer` wallet** (user-owned) - `pay_lease_fee` signer (can be anyone, not just the short seller). -- **`holder_leased_account`** - holder's [associated token account](https://solana.com/docs/terminology) for the leased mint; source on `create_lease`, destination on `return_lease` / `close_expired`. -- **`holder_collateral_account`** - holder's associated token account for the collateral mint; destination for the lease fee and liquidation proceeds. -- **`short_seller_leased_account`** - short seller's associated token account for the leased mint; destination on `take_lease`, source on `return_lease`. -- **`short_seller_collateral_account`** - short seller's associated token account for the collateral mint; source on `take_lease` / `top_up_collateral`, destination for collateral refund on `return_lease`. -- **`keeper_collateral_account`** - keeper's associated token account for the collateral mint; receives the liquidation bounty. -- **`price_update`** - `PriceUpdateV2` account owned by the Pyth Receiver program, for the feed the lease is pinned to. - -### Fields on `Lease` - -From [`state/lease.rs`](programs/asset-leasing/src/state/lease.rs): - -```rust -pub struct Lease { - pub lease_id: u64, // caller-supplied id so one holder can run many leases - pub holder: Pubkey, // who listed it, gets paid the lease fee - pub short_seller: Pubkey, // who took the lease; Pubkey::default() while Listed - - pub leased_mint: Pubkey, - pub leased_amount: u64, // locked at creation, unchanging - - pub collateral_mint: Pubkey, - pub collateral_amount: u64, // increases on top_up, decreases as lease fees pay out - pub required_collateral_amount: u64, // what the short seller must post on take_lease - - pub lease_fee_per_second: u64, // denominated in collateral units - pub duration_seconds: i64, - pub start_timestamp: i64, // 0 while Listed - pub end_timestamp: i64, // 0 while Listed; start_timestamp + duration once Active - pub last_paid_timestamp: i64, // Lease fee accrues from here to min(now, end_timestamp) - - pub maintenance_margin_basis_points: u16, // e.g. 12_000 = 120% - pub liquidation_bounty_basis_points: u16, // e.g. 500 = 5% - - pub feed_id: [u8; 32], // Pyth feed_id this lease is pinned to - - pub status: LeaseStatus, // Listed | Active | Liquidated | Closed - - pub bump: u8, - pub leased_vault_bump: u8, - pub collateral_vault_bump: u8, -} -``` - -The `Closed` and `Liquidated` states are not directly observable -onchain: all three of `return_lease`, `liquidate` and `close_expired` -close the `Lease` account in the same transaction (`close = holder`), -returning the rent-exempt lamports to the holder. The in-memory -`status` field is set *before* the close so the transaction logs -record the terminal state, but the account disappears at the end. - ---- - ## Lifecycle ### What the short seller really gets @@ -308,16 +233,34 @@ The holder calls `create_lease`, naming the leased mint, the collateral mint, the amount of leased tokens to offer, the collateral the short seller will have to post, the per-second lease fee, the duration, the maintenance-margin and liquidation-bounty -ratios, and the Pyth `feed_id` the lease will be priced against. The -program creates the `Lease` account, creates two empty token vault -[program-derived addresses](https://solana.com/docs/terminology) (one for each -mint), and moves the leased tokens out of the holder's wallet into -the leased vault. Locking the leased tokens up front means a short -seller calling `take_lease` later cannot fail because the holder -spent the inventory in the meantime - the atomicity guarantee +ratios, and the Pyth `feed_id` the lease will be priced against. +This is where every account the rest of the lifecycle uses gets +created. The handler initialises three +[program-derived addresses](https://solana.com/docs/terminology): + +- **`Lease`** - the state account, owned by the program, holding all + the lease parameters and the current lifecycle status. Seeds: + `[b"lease", holder, lease_id.to_le_bytes()]` - keying on + `lease_id` lets one holder run many leases in parallel. +- **`leased_vault`** - a token account for the leased mint whose + authority is itself (the program signs as the vault using the + vault's own seeds). Seeds: `[b"leased_vault", lease]`. Holds + `leased_amount` while `Listed`; `0` while `Active` (the short + seller has the tokens); the full amount again briefly inside + `return_lease`. +- **`collateral_vault`** - a token account for the collateral mint, + also self-authoritative. Seeds: `[b"collateral_vault", lease]`. + Created empty here; filled by `take_lease`, drained over time as + lease fees stream out, and topped up by `top_up_collateral`. + +The handler then moves the leased tokens out of the holder's wallet +into the leased vault. Locking the leased tokens up front means a +short seller calling `take_lease` later cannot fail because the +holder spent the inventory in the meantime - the atomicity guarantee transfers to the program the moment the lease is listed. -- **Signers:** `holder`. +- **Signers:** `holder` (the user wallet listing the tokens; receives + the lease fee and the final recovery). - **Accounts:** - `holder` (signer, mut - pays account rent) - `leased_mint`, `collateral_mint` (read-only) @@ -343,25 +286,65 @@ transfers to the program the moment the lease is listed. - `InvalidMaintenanceMargin` if `maintenance_margin_basis_points` is `0` or `> 50_000` - `InvalidLiquidationBounty` if `liquidation_bounty_basis_points > 2_000` +#### What's on the lease account + +The `Lease` account written above carries the full set of fields +referenced by the rest of the lifecycle. From [`state/lease.rs`](programs/asset-leasing/src/state/lease.rs): + +```rust +pub struct Lease { + pub lease_id: u64, // caller-supplied id so one holder can run many leases + pub holder: Pubkey, // who listed it, gets paid the lease fee + pub short_seller: Pubkey, // who took the lease; Pubkey::default() while Listed + + pub leased_mint: Pubkey, + pub leased_amount: u64, // locked at creation, unchanging + + pub collateral_mint: Pubkey, + pub collateral_amount: u64, // increases on top_up, decreases as lease fees pay out + pub required_collateral_amount: u64, // what the short seller must post on take_lease + + pub lease_fee_per_second: u64, // denominated in collateral units + pub duration_seconds: i64, + pub start_timestamp: i64, // 0 while Listed + pub end_timestamp: i64, // 0 while Listed; start_timestamp + duration once Active + pub last_paid_timestamp: i64, // Lease fee accrues from here to min(now, end_timestamp) + + pub maintenance_margin_basis_points: u16, // e.g. 12_000 = 120% + pub liquidation_bounty_basis_points: u16, // e.g. 500 = 5% + + pub feed_id: [u8; 32], // Pyth feed_id this lease is pinned to + + pub status: LeaseStatus, // Listed | Active | Liquidated | Closed + + pub bump: u8, + pub leased_vault_bump: u8, + pub collateral_vault_bump: u8, +} +``` + ### The short seller takes the offer - `take_lease` A short seller who has spotted the `Lease` account onchain (via an indexer or a direct lookup) calls `take_lease` to take delivery. The -program deposits the short seller's collateral first, then hands over -the leased tokens - depositing collateral first means that if the -leased-token payout fails for any reason the whole transaction -reverts and the short seller gets their collateral back. The lease -moves from `Listed` to `Active`. - -- **Signers:** `short_seller`. +program deposits the short seller's collateral into `collateral_vault` +first - the vault was created empty by `create_lease` and this is +the call that fills it - then hands over the leased tokens. +Depositing collateral first means that if the leased-token payout +fails for any reason the whole transaction reverts and the short +seller gets their collateral back. The lease moves from `Listed` to +`Active`. + +- **Signers:** `short_seller` (the user wallet borrowing the tokens + and posting collateral). - **Accounts:** - `short_seller` (signer, mut) - `holder` (UncheckedAccount - read for program-derived address seed derivation only, no signature required) - `lease` (mut, `has_one = holder`, `has_one = leased_mint`, `has_one = collateral_mint`, must be `Listed`) - `leased_mint`, `collateral_mint` - `leased_vault`, `collateral_vault` (both mut, both program-derived addresses) - - `short_seller_collateral_account` (mut, short seller's associated token account - source) - - `short_seller_leased_account` (mut, **init_if_needed** - destination) + - `short_seller_collateral_account` (mut, short seller's associated token account for the collateral mint - source) + - `short_seller_leased_account` (mut, **init_if_needed** - short seller's associated token account for the leased mint, destination) - `token_program`, `associated_token_program`, `system_program` - **What happens:** - Two token movements, in order: @@ -395,13 +378,14 @@ the vault. Fees do not accrue past `end_timestamp` - once the deadline hits, the short seller is either returning the tokens, being liquidated, or defaulting; no further lease fees are owed. -- **Signers:** `payer` (anyone). +- **Signers:** `payer` (any user wallet - the short seller, a + keeper bot, or anyone else willing to pay the transaction fee). - **Accounts:** - `payer` (signer, mut - pays for `init_if_needed` of the holder associated token account) - `holder` (UncheckedAccount, read-only - used for `has_one` check) - `lease` (mut, must be `Active`) - `collateral_mint`, `collateral_vault` - - `holder_collateral_account` (mut, **init_if_needed**) + - `holder_collateral_account` (mut, **init_if_needed** - holder's [associated token account](https://solana.com/docs/terminology) for the collateral mint, destination for the lease fee) - `token_program`, `associated_token_program`, `system_program` - **What happens:** - Compute `lease_fee_due = (min(now, end_timestamp) - last_paid_timestamp) * lease_fee_per_second`. @@ -501,6 +485,15 @@ time at `end_timestamp`. - `Unauthorised` if `lease.short_seller != short_seller.key()` - `MathOverflow` if the lease-fee or collateral subtraction overflows +`return_lease` is the first place an account-close happens; the same +mechanism runs in `liquidate` and `close_expired`. The `Closed` and +`Liquidated` states are not directly observable onchain: all three +of `return_lease`, `liquidate` and `close_expired` close the `Lease` +account in the same transaction (`close = holder`), returning the +rent-exempt lamports to the holder. The in-memory `status` field is +set *before* the close so the transaction logs record the terminal +state, but the account disappears at the end. + ### Branch: position underwater - `liquidate` If the leased asset rallies far enough that the locked collateral is @@ -523,7 +516,9 @@ where `debt_value = leased_amount * price * 10^exponent`, with the Pyth exponent folded into whichever side of the inequality keeps the math non-negative (see [`is_underwater`](programs/asset-leasing/src/instructions/liquidate.rs)). -- **Signers:** `keeper`. +- **Signers:** `keeper` (any user wallet - typically a bot watching + for underwater positions; receives the bounty as payment for + cleaning up). - **Accounts:** - `keeper` (signer, mut - pays `init_if_needed` cost for both associated token accounts) - `holder` (UncheckedAccount, mut - receives lease fee, holder share, and the rent-exempt lamports from the three closed accounts) @@ -531,8 +526,8 @@ math non-negative (see [`is_underwater`](programs/asset-leasing/src/instructions - `leased_mint`, `collateral_mint` - `leased_vault`, `collateral_vault` (both mut) - `holder_collateral_account` (mut, **init_if_needed**) - - `keeper_collateral_account` (mut, **init_if_needed**) - - `price_update` (UncheckedAccount, constrained to `owner = PYTH_RECEIVER_PROGRAM_ID`) + - `keeper_collateral_account` (mut, **init_if_needed** - keeper's [associated token account](https://solana.com/docs/terminology) for the collateral mint, destination for the bounty) + - `price_update` (UncheckedAccount, constrained to `owner = PYTH_RECEIVER_PROGRAM_ID`) - a `PriceUpdateV2` account owned by the Pyth Receiver program for the feed the lease was pinned to at creation. This is the first handler that requires the oracle account itself; `create_lease` only stores the `feed_id` it expects to see here. - `token_program`, `associated_token_program`, `system_program` - **What happens:** - Decode `price_update`: discriminator must match From 0c64894cd8f4a879ea61b6d7233749417ec9ad6e Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Tue, 28 Apr 2026 22:06:12 +0000 Subject: [PATCH 37/41] docs(asset-leasing): drop the contradictory 'non-custodial escrow' claim The previous wording said 'The program acts as a non-custodial escrow' and then immediately described the program taking custody of A tokens in a program-owned vault. Both can't be true. What's actually true: the program holds funds in vaults during the lease, the program-derived address signs all transfers, no admin key exists, and the rules are the deployed bytecode. Reworded to state this directly without the 'non-custodial' label, which in common DeFi usage means 'no admin can pull funds outside the rules' - a much narrower claim than 'the program never touches your funds', which is what the original phrasing sounded like. --- defi/asset-leasing/anchor/README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 2be1502d1..0345036ad 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -97,7 +97,12 @@ deadline has passed, and the short seller hasn't returned the tokens - in which case the holder seizes the entire collateral as compensation for the missing tokens. -The program acts as a non-custodial escrow. It: +The program acts as the escrow agent. Both the leased tokens and +the collateral sit in program-owned vaults during the lease, and the +program-derived address signs all the transfers in and out. There is +no admin key and no off-program logic that can move funds: every +transfer is dictated by the rules below, and those rules are the +deployed bytecode. Specifically: 1. Takes the holder's A tokens and locks them in a program-owned vault until a short seller shows up. From 602823fbcf558d937e0603de5cbb0eb09660d1e6 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Wed, 29 Apr 2026 14:50:06 +0000 Subject: [PATCH 38/41] docs(asset-leasing): note that swap composition is the frontend's job, not the program's Clarify that take_lease does not perform the A->B swap. The program deliberately stays narrow (a token-leasing primitive); the DEX swap belongs in the frontend, bundled with take_lease in a single tx so the short seller signs once and the atomicity is preserved by Solana's transaction semantics. Avoids scope creep, removes a Jupiter dependency from the program, and keeps the tutorial value of the example tight. --- defi/asset-leasing/anchor/README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 0345036ad..90ff61875 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -51,7 +51,14 @@ The short seller's full lifecycle is: each second - the fee is just a number that grows until someone pokes the lease. 2. **Sell A immediately** on a market like Jupiter, receiving more B - in return. The short seller now has more B and owes A. + in return. The short seller now has more B and owes A. The + asset-leasing program does not perform this swap itself; that is + the DEX's job, and keeping the two concerns separate keeps each + program narrow and composable. In practice a frontend bundles + `take_lease` and the Jupiter swap into a single transaction so + the short seller signs once and the open-short flow is atomic + (Solana's transaction atomicity guarantees both succeed or both + revert). 3. **Wait.** They are betting A's price (denominated in B) will fall. The short seller doesn't have to call anything while they wait - accrued fees auto-settle at close. They can optionally call From e819828eb436a619faa560138d52efb3e6d10604 Mon Sep 17 00:00:00 2001 From: "Edward (OpenClaw)" Date: Wed, 29 Apr 2026 15:13:28 +0000 Subject: [PATCH 39/41] docs(asset-leasing): fix on-chain/off-chain hyphenation 'onchain' and 'offchain' are one word, like 'online' and 'offline'. Caught while reviewing the Quasar skill PR which had the same slip. --- defi/asset-leasing/anchor/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index 90ff61875..a4a113231 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -1025,7 +1025,7 @@ The Quasar example in this repo's CI workflow Token-2022 support is a type-parameter swap away. - **State layout is the same, byte for byte.** The `Lease` discriminator - and field order match the Anchor version, so an off-chain indexer + and field order match the Anchor version, so an offchain indexer that already decodes Anchor `Lease` accounts would also decode the Quasar ones after adjusting for the one-byte discriminator. @@ -1038,7 +1038,7 @@ The Quasar example in this repo's CI workflow keys its program-derived address on `[LEASE_SEED, holder]` alone - one active lease per holder. The `lease_id` is still stored on the `Lease` account for book-keeping and is a caller-supplied u64 in - `create_lease`; the off-chain client just has to ensure the previous + `create_lease`; the offchain client just has to ensure the previous lease from the same holder is `Closed` or `Liquidated` (i.e. its program-derived address account is gone) before creating a new one. Swapping in a multi-lease seed is a mechanical change once Quasar @@ -1058,7 +1058,7 @@ grouped by effort: ### Easy -- **Add a `lease_view` read-only helper.** An off-chain indexer-style +- **Add a `lease_view` read-only helper.** An offchain indexer-style struct that returns `{ collateral_value, debt_value, ratio_basis_points, is_underwater }` given the same inputs `is_underwater` uses. Useful for UIs that want to show "you are 15% away from liquidation". @@ -1080,7 +1080,7 @@ grouped by effort: - **Multiple outstanding leases per `(holder, short_seller)` pair with the same mint pair.** Already supported via `lease_id`, but add an instruction-level index account that lists open lease ids for a - given holder so off-chain tools don't have to `getProgramAccounts` + given holder so offchain tools don't have to `getProgramAccounts` scan. - **Quote asset ≠ collateral mint.** Rent and liquidation math assume From 9f042b4573df9d71e244ace6033b9f7ac0938600 Mon Sep 17 00:00:00 2001 From: mikemaccana-edwardbot Date: Thu, 30 Apr 2026 21:04:28 +0000 Subject: [PATCH 40/41] docs(asset-leasing): add bilateral versus pooled lending section Explains why this program uses bilateral lending (1:1 deals between one holder and one short seller) instead of a pooled-lending design like Kamino or MarginFi. Frames the choice as a design tradeoff rather than a critique of pooled lending - pooled lending already supports shorting tokens and is the right tool for deep, liquid assets. Bilateral lending wins on bilateral terms, thin-supply rate stability, holder counterparty selection, and long-tail or new tokens. Encourages readers building their own programs to consider whether a pooled-lending redesign would suit their target asset better. --- defi/asset-leasing/anchor/README.md | 63 ++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md index a4a113231..c31c28881 100644 --- a/defi/asset-leasing/anchor/README.md +++ b/defi/asset-leasing/anchor/README.md @@ -24,11 +24,12 @@ need to sell short. The program is written in ## Table of contents 1. [What does this program do?](#what-does-this-program-do) -2. [Lifecycle](#lifecycle) -3. [Safety and edge cases](#safety-and-edge-cases) -4. [Running the tests](#running-the-tests) -5. [Quasar port](#quasar-port) -6. [Extending the program](#extending-the-program) +2. [Bilateral versus pooled lending](#bilateral-versus-pooled-lending) +3. [Lifecycle](#lifecycle) +4. [Safety and edge cases](#safety-and-edge-cases) +5. [Running the tests](#running-the-tests) +6. [Quasar port](#quasar-port) +7. [Extending the program](#extending-the-program) --- @@ -216,6 +217,58 @@ above is the same machinery applied to a real asset pair. --- +## Bilateral versus pooled lending + +Our program could be redesigned to use pooled lending, like Kamino, +MarginFi, and other programs where many depositors share one +liquidity pool that borrowers draw from against collateral, with +rates set automatically by a utilisation curve. That design works +well for some assets and poorly for others. We chose bilateral +lending - direct deals between one holder and one short seller - and +it's worth explaining why. + +Pooled lending already supports shorting tokens. A short seller +deposits collateral, borrows the asset, sells it, buys it back later, +and repays the loan. So the question isn't whether pooled lending +*can* facilitate shorts. It can. The question is which structure is +the right tool for which market. + +For deep, liquid assets such as SOL, USDC, and the majors, pooled +lending is the right tool. Capital is efficient, fills are instant, +and pricing adjusts automatically as borrowing demand changes. + +Bilateral lending wins where pooled lending breaks down: + +- **Bilateral terms.** Holder and short seller agree on a fixed + duration, fixed lease fee, and a custom collateral schedule. + Pooled lending forces every borrower onto one rate model and + offers no end date. Borrowers face open-ended exposure to rate + spikes and margin calls. Bilateral lending gives both sides + predictability. +- **Pool rates spike when supply is thin.** Pool interest rates rise + gently as borrowing demand grows, then spike sharply once most of + the supply is in use. For lightly supplied assets, this makes + shorting punitive and unstable. Bilateral lending prices through + direct negotiation, so the rate is whatever holder and short + seller agree on. +- **Holder control over supply.** In a pool, the holder is one of + many depositors; the program commingles deposits and decides how + they get used. In bilateral lending, the holder chooses which + short seller borrows their tokens and on what terms. They can + refuse, charge a premium, or restrict to specific counterparties. +- **Long-tail and new tokens.** A token with no pooled-lending + market cannot be shorted through pooled lending. Bilateral lending + works on day one with one holder and one short seller, in markets + of size n=1. + +If your target asset is a major liquid token with deep existing +pooled-lending markets, redesigning around a pool is reasonable. If +your target is anything else - a thinly-supplied token, a token +where holders care who borrows from them, or a token where both +sides want fixed terms - bilateral lending is the better fit. + +--- + ## Lifecycle ### What the short seller really gets From 747e1ee1e6f6856c7cf0463b9daaaa8b542bbbce Mon Sep 17 00:00:00 2001 From: mikemaccana-edwardbot Date: Mon, 11 May 2026 20:54:01 +0000 Subject: [PATCH 41/41] docs(root): align Asset Leasing entry with canonical terminology The root README entry used 'SPL tokens', 'SPL collateral', and 'per-second rent' which contradict the in-repo terminology cleanup: the program and the asset-leasing README use 'token' (not 'SPL Token') and 'lease fee' (not 'rent'). Rewrite the one-liner to match the canonical framing (holders, short sellers, lease fee). Flagged by Cursor Bugbot on PR #7. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 123e3a5f5..68924be6e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Constant product AMM (x·y=k) — create liquidity pools, deposit and withdraw l ### Asset Leasing -Fixed-term leasing of SPL tokens with SPL collateral, per-second rent, and Pyth-priced liquidation — lessors list tokens, lessees post collateral, keepers liquidate undercollateralised positions. +Directional token lending with token collateral, per-second lease fees, and Pyth-priced liquidation. Holders rent out token inventory to short sellers, who post stable-asset collateral and borrow the asset they want to short; keepers liquidate undercollateralised positions. [⚓ Anchor](./defi/asset-leasing/anchor)