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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,4 @@ node_modules/

/target
deploy
.claude/*
!.claude/skills/
.claude
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,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

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)

### Central Limit Order Book

Order-book exchange — users post limit bids and asks at chosen prices, tokens are locked in program vaults, and orders cross against the opposing side using price-time priority. Fees route to a dedicated fee vault, maker/taker proceeds land in unsettled balances, and funds are withdrawn via `settle_funds`. A minimal teaching example of the mechanics behind Openbook and Phoenix.
Expand Down
7 changes: 7 additions & 0 deletions defi/asset-leasing/anchor/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.anchor
.DS_Store
target
**/*.rs.bk
node_modules
test-ledger
.yarn
20 changes: 20 additions & 0 deletions defi/asset-leasing/anchor/Anchor.toml
Original file line number Diff line number Diff line change
@@ -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"
15 changes: 15 additions & 0 deletions defi/asset-leasing/anchor/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
1,333 changes: 1,333 additions & 0 deletions defi/asset-leasing/anchor/README.md

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
[package]
name = "asset-leasing"
version = "0.1.0"
description = "Fixed-term 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 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.
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
# onchain 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"))'] }
28 changes: 28 additions & 0 deletions defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/// 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 short_seller's collateral for the
/// life of the lease.
pub const COLLATERAL_VAULT_SEED: &[u8] = b"collateral_vault";

/// 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 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 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
/// 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;
35 changes: 35 additions & 0 deletions defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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("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")]
InvalidLiquidationBounty,
#[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,
#[msg("Leased mint and collateral mint must be different")]
LeasedMintEqualsCollateralMint,
#[msg("Price update does not match the feed pinned on this lease")]
PriceFeedMismatch,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
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::{
pay_lease_fee::update_last_paid_timestamp,
shared::{close_vault, transfer_tokens_from_vault},
},
state::{Lease, LeaseStatus},
};

/// Holder-only recovery path. Two real-world situations collapse here:
///
/// - 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 short_seller ghosted past `end_timestamp`. The holder
/// takes the collateral as compensation and closes the books.
#[derive(Accounts)]
pub struct CloseExpiredAccountConstraints<'info> {
#[account(mut)]
pub holder: Signer<'info>,

#[account(
mut,
seeds = [LEASE_SEED, holder.key().as_ref(), &lease.lease_id.to_le_bytes()],
bump = lease.bump,
has_one = holder,
has_one = leased_mint,
has_one = collateral_mint,
constraint = matches!(lease.status, LeaseStatus::Listed | LeaseStatus::Active)
@ AssetLeasingError::InvalidLeaseStatus,
close = holder,
)]
pub lease: Account<'info, Lease>,

pub leased_mint: Box<InterfaceAccount<'info, Mint>>,
pub collateral_mint: Box<InterfaceAccount<'info, Mint>>,

#[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<InterfaceAccount<'info, TokenAccount>>,

#[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<InterfaceAccount<'info, TokenAccount>>,

#[account(
init_if_needed,
payer = holder,
associated_token::mint = leased_mint,
associated_token::authority = holder,
associated_token::token_program = token_program,
)]
pub holder_leased_account: Box<InterfaceAccount<'info, TokenAccount>>,

#[account(
init_if_needed,
payer = holder,
associated_token::mint = collateral_mint,
associated_token::authority = holder,
associated_token::token_program = token_program,
)]
pub holder_collateral_account: Box<InterfaceAccount<'info, TokenAccount>>,

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<CloseExpiredAccountConstraints>) -> 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_timestamp,
AssetLeasingError::LeaseNotExpired
);
}

// Pre-compute vault balances before any mutable borrows.
let leased_vault_balance = context.accounts.leased_vault.amount;
let collateral_vault_balance = context.accounts.collateral_vault.amount;

let leased_vault_bump = context.accounts.lease.leased_vault_bump;
let collateral_vault_bump = context.accounts.lease.collateral_vault_bump;

// Update state before CPIs (Checks-Effects-Interactions).
//
// 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
// 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.
//
// No-op on the `Listed` branch because Lease fees never started accruing.
if status == LeaseStatus::Active {
update_last_paid_timestamp(&mut context.accounts.lease, now);
}
context.accounts.lease.collateral_amount = 0;
context.accounts.lease.status = LeaseStatus::Closed;

let leased_vault_seeds: &[&[u8]] = &[
LEASED_VAULT_SEED,
lease_key.as_ref(),
core::slice::from_ref(&leased_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 holder. For a Listed
// lease this is the full leased_amount; for a defaulted Active lease the
// vault is empty (the short_seller never returned) and this is a no-op.
if leased_vault_balance > 0 {
transfer_tokens_from_vault(
&context.accounts.leased_vault,
&context.accounts.holder_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 holder. For a Listed lease this is 0.
// For a defaulted Active lease this is the short_seller's forfeited collateral.
if collateral_vault_balance > 0 {
transfer_tokens_from_vault(
&context.accounts.collateral_vault,
&context.accounts.holder_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.holder.to_account_info(),
&context.accounts.token_program,
&[leased_vault_seeds],
)?;
close_vault(
&context.accounts.collateral_vault,
&context.accounts.holder.to_account_info(),
&context.accounts.token_program,
&[collateral_vault_seeds],
)?;

Ok(())
}
Loading
Loading