Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
25bdc5f
feat(defi): add asset-leasing Anchor example
Apr 18, 2026
6dab03f
docs(asset-leasing): beginner-friendly README explaining finance conc…
Apr 18, 2026
4fb8cc0
refactor(asset-leasing): extract close_vault helper to shared.rs
Apr 19, 2026
28a715d
fix(asset-leasing): reject leased_mint == collateral_mint on create_l…
Apr 19, 2026
29f5e00
fix(asset-leasing): pin Pyth feed_id on Lease and enforce at liquidate
Apr 19, 2026
a92d2fb
fix(asset-leasing): settle last_rent_paid_ts on close_expired default…
Apr 19, 2026
04367b8
docs(asset-leasing): rewrite README to the repo-wide quality bar
Apr 19, 2026
d2e0d4d
feat(asset-leasing): add Quasar port and apply Mike's README feedback
Apr 21, 2026
33f5ef7
docs(asset-leasing): drop 'SPL Token' qualifier, just say 'token'
Apr 22, 2026
001ca85
refactor(asset-leasing): alias SPL_TOKEN_PROGRAM_ID to TOKEN_PROGRAM_…
Apr 22, 2026
709542e
Revert "refactor(asset-leasing): alias SPL_TOKEN_PROGRAM_ID to TOKEN_…
Apr 22, 2026
5f35c76
docs+code: rename "rent" to "lease fee" throughout asset-leasing
Apr 27, 2026
10a6caa
docs+code: spell out all abbreviations in asset-leasing
Apr 27, 2026
28eea9a
docs: reframe asset-leasing as on-chain securities lending
Apr 28, 2026
cde6b0d
docs: "on-chain" → "onchain" throughout
Apr 28, 2026
62fac5f
defi/asset-leasing: rename roles to holder/short_seller; rewrite README
Apr 28, 2026
487b360
docs(asset-leasing): emphasize key terms on first use; fix ambiguous …
Apr 28, 2026
255da13
docs(asset-leasing): scrub 'fungible token' and 'borrow' as a noun
Apr 28, 2026
5491142
docs(asset-leasing): drop fungibility explainer
Apr 28, 2026
5141f60
docs(asset-leasing): make the short seller's full lifecycle explicit
Apr 28, 2026
94b58f3
docs(asset-leasing): include the sell-and-rebuy step in the intro
Apr 28, 2026
35afc7d
docs(asset-leasing): make the two-mint asymmetry obvious in the intro
Apr 28, 2026
114eb7a
docs(asset-leasing): drop the README-previews-itself paragraph
Apr 28, 2026
25f5f8b
docs(asset-leasing): remove ASCII-art lifecycle diagram
Apr 28, 2026
a593158
asset-leasing: merge per-instruction reference and worked-examples se…
Apr 28, 2026
d55e3af
docs(asset-leasing): add 'what the short seller really gets' framing;…
Apr 28, 2026
c563641
docs(asset-leasing): name the instruction handlers in the §1 lifecycl…
Apr 28, 2026
bdb511d
docs(asset-leasing): drop 'worked' from example/scenario headings
Apr 28, 2026
1298583
docs(asset-leasing): replace em-dashes with regular dashes; say 'toke…
Apr 28, 2026
53c7937
docs(asset-leasing): remove redundant Roles subsection
Apr 28, 2026
f5ebe0e
docs(asset-leasing): convert markdown tables to bullet lists
Apr 28, 2026
6cf024f
docs(asset-leasing): clarify how the per-second lease fee actually ac…
Apr 28, 2026
db46658
docs(asset-leasing): replace bare \u00a73.x section references with n…
Apr 28, 2026
7c0f710
docs(asset-leasing): strip section numbers from headings; rewrite cro…
Apr 28, 2026
ab99204
docs(asset-leasing): add the holder's full lifecycle
Apr 28, 2026
61e25c5
asset-leasing: merge accounts section into lifecycle
Apr 28, 2026
0c64894
docs(asset-leasing): drop the contradictory 'non-custodial escrow' claim
Apr 28, 2026
602823f
docs(asset-leasing): note that swap composition is the frontend's job…
Apr 29, 2026
e819828
docs(asset-leasing): fix on-chain/off-chain hyphenation
Apr 29, 2026
9f042b4
docs(asset-leasing): add bilateral versus pooled lending section
mikemaccana-edwardbot Apr 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Root README uses outdated terminology in Asset Leasing entry

Low Severity

The new Asset Leasing description in the root README contradicts the terminology cleanup explicitly described in this PR's user instructions. It uses SPL tokens and SPL collateral (the user instruction states Token not SPL Token) and per-second rent (the program and the in-repo README now consistently use lease fee rather than rent). The Anchor README and program code already follow the new terminology, so the root README is out of step.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5f35c76. Configure here.


### 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.
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,195 changes: 1,195 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;
37 changes: 37 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,37 @@
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 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,
#[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,179 @@
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 CloseExpired<'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<CloseExpired>) -> 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
);
}

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 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.
let leased_vault_balance = context.accounts.leased_vault.amount;
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.
let collateral_vault_balance = context.accounts.collateral_vault.amount;
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],
)?;

// Settle lease-fee accounting on the default path.
//
// 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;

Ok(())
}
Loading