Skip to content

Commit f93f93d

Browse files
committed
feat(defi): add asset-leasing example (Anchor + Quasar)
Adds a new defi/asset-leasing example demonstrating onchain securities lending — the same primitive that underpins TradFi stock-loan desks — with two full implementations: **Anchor** (defi/asset-leasing/anchor/) - Seven instruction handlers covering the complete bilateral lease lifecycle: create_lease → take_lease → pay_lease_fee / top_up_collateral → return_lease / liquidate / close_expired. - PDA-backed leased and collateral vaults; per-second fee accrual via last_paid_timestamp; Pyth PriceUpdateV2 staleness + feed-id guard on liquidation; Token-2022 compatible via TokenInterface. - LiteSVM integration test suite (11 tests) covering every handler, error path, and branch scenario. **Quasar port** (defi/asset-leasing/quasar/) - Same seven handlers, same Lease state layout (byte-for-byte compatible with the Anchor version), same PDA seed conventions. - Differs from the Anchor version in: one-byte explicit discriminators, no init_if_needed ATA creation, classic Token only (not Token-2022), one active lease per holder (no lease_id in PDA seed). **README** (defi/asset-leasing/anchor/README.md) - Explains the securities-lending model for readers unfamiliar with finance; Investopedia links on every financial term at first mention. - Program flow section with three named participants: Alice (holder, earns yield on idle inventory), Bob (short seller, bearish thesis on NVIDIA), and Carol (keeper bot, earns liquidation bounty). Uses USDC and NVDAx as primary example tokens; TSLAx noted as a parallel case. - Each lifecycle step lists the instruction handler called and every account that changes, with before/after values in table form. - Complete lifecycle reference with exact numbers that match the LiteSVM tests one-to-one. - Bilateral vs pooled lending comparison. - Safety and edge-case section covering all error variants and deliberate design trade-offs. https://claude.ai/code/session_01Etk82ApiAusLAUezvmzVts
1 parent 5540080 commit f93f93d

37 files changed

Lines changed: 6388 additions & 0 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,6 @@ node_modules/
2323
/target
2424
deploy
2525
.claude
26+
!.claude/SKILL.md
27+
!.claude/RUST.md
28+
!.claude/TYPESCRIPT.md

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ Constant product AMM (x·y=k) — create liquidity pools, deposit and withdraw l
2323

2424
[⚓ Anchor](./tokens/token-swap/anchor) [💫 Quasar](./tokens/token-swap/quasar)
2525

26+
### Asset Leasing
27+
28+
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.
29+
30+
[⚓ Anchor](./defi/asset-leasing/anchor)
31+
2632
### Escrow
2733

2834
Peer-to-peer OTC trade — one user deposits token A and specifies how much token B they want. A counterparty fulfills the offer and both sides receive their tokens atomically.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.anchor
2+
.DS_Store
3+
target
4+
**/*.rs.bk
5+
node_modules
6+
test-ledger
7+
.yarn
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[toolchain]
2+
# Pin Solana to the version used across the repo's Anchor 1.0 examples so the
3+
# bundled test validator and BPF toolchain stay in lock-step.
4+
solana_version = "3.1.8"
5+
6+
[features]
7+
resolution = true
8+
skip-lint = false
9+
10+
[programs.localnet]
11+
asset_leasing = "HHKEhLk6dyzG4mK1isPyZiHcEMW4J1CRKryzyQ3JFtnF"
12+
13+
[provider]
14+
cluster = "Localnet"
15+
wallet = "~/.config/solana/id.json"
16+
17+
[scripts]
18+
# LiteSVM Rust tests live under `programs/asset-leasing/tests/` and include the
19+
# built `.so` via `include_bytes!`, so a fresh `anchor build` must run first.
20+
test = "cargo test"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[workspace]
2+
# Local workspace — the repo root Cargo.toml does not include Anchor projects,
3+
# each Anchor example ships its own workspace plus Cargo.lock.
4+
members = ["programs/*"]
5+
resolver = "2"
6+
7+
[profile.release]
8+
overflow-checks = true
9+
lto = "fat"
10+
codegen-units = 1
11+
12+
[profile.release.build-override]
13+
opt-level = 3
14+
incremental = false
15+
codegen-units = 1

defi/asset-leasing/anchor/README.md

Lines changed: 1333 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
[package]
2+
name = "asset-leasing"
3+
version = "0.1.0"
4+
description = "Fixed-term token leasing with collateral and Pyth-priced liquidation"
5+
edition = "2021"
6+
7+
[lib]
8+
crate-type = ["cdylib", "lib"]
9+
name = "asset_leasing"
10+
11+
[features]
12+
default = []
13+
cpi = ["no-entrypoint"]
14+
no-entrypoint = []
15+
no-idl = []
16+
no-log-ix-name = []
17+
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
18+
anchor-debug = []
19+
custom-heap = []
20+
custom-panic = []
21+
22+
[dependencies]
23+
# `init-if-needed` is required because several instructions lazily create the
24+
# counterparty's associated token accounts (keeper's collateral associated token account on first liquidation, holder's
25+
# leased associated token account on first return, etc.). Anchor forces an opt-in to make us
26+
# re-affirm that we verify ownership on every touch — which we do via the
27+
# `associated_token::authority = ...` constraints.
28+
anchor-lang = { version = "1.0.0", features = ["init-if-needed"] }
29+
anchor-spl = "1.0.0"
30+
# Note: we intentionally do NOT depend on `pyth-solana-receiver-sdk` here.
31+
# Version 1.1.0 currently pulls in a transitive `borsh` conflict with
32+
# `anchor-lang` 1.0.0 (see program-examples/.github/.ghaignore — the
33+
# oracles/pyth/anchor example is flagged "not building" for the same reason).
34+
# Instead we parse the fixed layout of the Pyth Receiver `PriceUpdateV2`
35+
# account by hand in `instructions/liquidate.rs`, matching the published
36+
# onchain schema.
37+
38+
[dev-dependencies]
39+
# Match the test stack used by tokens/escrow and tokens/token-fundraiser so
40+
# contributors can move between examples without version drift.
41+
litesvm = "0.11.0"
42+
solana-signer = "3.0.0"
43+
solana-keypair = "3.0.1"
44+
solana-account = "3.0.0"
45+
solana-kite = "0.3.0"
46+
borsh = "1.6.1"
47+
48+
[lints.rust]
49+
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/// program-derived address seed for the `Lease` account. Combined with the holder pubkey and a
2+
/// u64 `lease_id` so one holder can run many leases in parallel.
3+
pub const LEASE_SEED: &[u8] = b"lease";
4+
5+
/// program-derived address seed for the token vault that holds the leased tokens while the lease
6+
/// is `Listed` and that accepts returned tokens on settlement.
7+
pub const LEASED_VAULT_SEED: &[u8] = b"leased_vault";
8+
9+
/// program-derived address seed for the token vault that escrows the short_seller's collateral for the
10+
/// life of the lease.
11+
pub const COLLATERAL_VAULT_SEED: &[u8] = b"collateral_vault";
12+
13+
/// Denominator for basis-point (basis points) ratios used for the maintenance margin
14+
/// and the liquidation bounty. 10_000 basis points = 100%.
15+
pub const BASIS_POINTS_DENOMINATOR: u64 = 10_000;
16+
17+
/// Maximum allowed maintenance margin: 50_000 basis points = 500%. Prevents the holder
18+
/// setting an impossible margin that would let them liquidate on day one.
19+
pub const MAX_MAINTENANCE_MARGIN_BASIS_POINTS: u16 = 50_000;
20+
21+
/// Maximum liquidation bounty the keeper can claim: 2_000 basis points = 20%. Keeps
22+
/// most of the collateral flowing to the holder on default.
23+
pub const MAX_LIQUIDATION_BOUNTY_BASIS_POINTS: u16 = 2_000;
24+
25+
/// A Pyth price update is considered stale if its `publish_time` is older
26+
/// than this many seconds versus the current onchain clock. 60 s matches the
27+
/// default staleness window used in the Pyth SDK docs.
28+
pub const PYTH_MAX_AGE_SECONDS: u64 = 60;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
use anchor_lang::prelude::*;
2+
3+
#[error_code]
4+
pub enum AssetLeasingError {
5+
#[msg("Lease is not in the required state for this action")]
6+
InvalidLeaseStatus,
7+
#[msg("Duration must be greater than zero")]
8+
InvalidDuration,
9+
#[msg("Leased amount must be greater than zero")]
10+
InvalidLeasedAmount,
11+
#[msg("Required collateral amount must be greater than zero")]
12+
InvalidCollateralAmount,
13+
#[msg("Lease fee per second must be greater than zero")]
14+
InvalidLeaseFeePerSecond,
15+
#[msg("Maintenance margin is outside the allowed range")]
16+
InvalidMaintenanceMargin,
17+
#[msg("Liquidation bounty is outside the allowed range")]
18+
InvalidLiquidationBounty,
19+
#[msg("Lease has not yet expired")]
20+
LeaseNotExpired,
21+
#[msg("Position is healthy; liquidation is not allowed")]
22+
PositionHealthy,
23+
#[msg("Pyth price update is stale")]
24+
StalePrice,
25+
#[msg("Pyth price is not positive")]
26+
NonPositivePrice,
27+
#[msg("Arithmetic overflow")]
28+
MathOverflow,
29+
#[msg("Signer is not authorised for this action")]
30+
Unauthorised,
31+
#[msg("Leased mint and collateral mint must be different")]
32+
LeasedMintEqualsCollateralMint,
33+
#[msg("Price update does not match the feed pinned on this lease")]
34+
PriceFeedMismatch,
35+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
use anchor_lang::prelude::*;
2+
use anchor_spl::{
3+
associated_token::AssociatedToken,
4+
token_interface::{Mint, TokenAccount, TokenInterface},
5+
};
6+
7+
use crate::{
8+
constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED},
9+
errors::AssetLeasingError,
10+
instructions::{
11+
pay_lease_fee::update_last_paid_timestamp,
12+
shared::{close_vault, transfer_tokens_from_vault},
13+
},
14+
state::{Lease, LeaseStatus},
15+
};
16+
17+
/// Holder-only recovery path. Two real-world situations collapse here:
18+
///
19+
/// - The lease sat in `Listed` and the holder wants to cancel it, recovering
20+
/// the leased tokens they pre-funded. Allowed any time.
21+
/// - The lease was `Active` but the short_seller ghosted past `end_timestamp`. The holder
22+
/// takes the collateral as compensation and closes the books.
23+
#[derive(Accounts)]
24+
pub struct CloseExpiredAccountConstraints<'info> {
25+
#[account(mut)]
26+
pub holder: Signer<'info>,
27+
28+
#[account(
29+
mut,
30+
seeds = [LEASE_SEED, holder.key().as_ref(), &lease.lease_id.to_le_bytes()],
31+
bump = lease.bump,
32+
has_one = holder,
33+
has_one = leased_mint,
34+
has_one = collateral_mint,
35+
constraint = matches!(lease.status, LeaseStatus::Listed | LeaseStatus::Active)
36+
@ AssetLeasingError::InvalidLeaseStatus,
37+
close = holder,
38+
)]
39+
pub lease: Account<'info, Lease>,
40+
41+
pub leased_mint: Box<InterfaceAccount<'info, Mint>>,
42+
pub collateral_mint: Box<InterfaceAccount<'info, Mint>>,
43+
44+
#[account(
45+
mut,
46+
seeds = [LEASED_VAULT_SEED, lease.key().as_ref()],
47+
bump = lease.leased_vault_bump,
48+
token::mint = leased_mint,
49+
token::authority = leased_vault,
50+
token::token_program = token_program,
51+
)]
52+
pub leased_vault: Box<InterfaceAccount<'info, TokenAccount>>,
53+
54+
#[account(
55+
mut,
56+
seeds = [COLLATERAL_VAULT_SEED, lease.key().as_ref()],
57+
bump = lease.collateral_vault_bump,
58+
token::mint = collateral_mint,
59+
token::authority = collateral_vault,
60+
token::token_program = token_program,
61+
)]
62+
pub collateral_vault: Box<InterfaceAccount<'info, TokenAccount>>,
63+
64+
#[account(
65+
init_if_needed,
66+
payer = holder,
67+
associated_token::mint = leased_mint,
68+
associated_token::authority = holder,
69+
associated_token::token_program = token_program,
70+
)]
71+
pub holder_leased_account: Box<InterfaceAccount<'info, TokenAccount>>,
72+
73+
#[account(
74+
init_if_needed,
75+
payer = holder,
76+
associated_token::mint = collateral_mint,
77+
associated_token::authority = holder,
78+
associated_token::token_program = token_program,
79+
)]
80+
pub holder_collateral_account: Box<InterfaceAccount<'info, TokenAccount>>,
81+
82+
pub token_program: Interface<'info, TokenInterface>,
83+
pub associated_token_program: Program<'info, AssociatedToken>,
84+
pub system_program: Program<'info, System>,
85+
}
86+
87+
pub fn handle_close_expired(context: Context<CloseExpiredAccountConstraints>) -> Result<()> {
88+
let now = Clock::get()?.unix_timestamp;
89+
let lease_key = context.accounts.lease.key();
90+
let status = context.accounts.lease.status;
91+
92+
// Active leases can only be closed after they expire. Listed leases have
93+
// no start/end so the check is skipped.
94+
if status == LeaseStatus::Active {
95+
require!(
96+
now >= context.accounts.lease.end_timestamp,
97+
AssetLeasingError::LeaseNotExpired
98+
);
99+
}
100+
101+
// Pre-compute vault balances before any mutable borrows.
102+
let leased_vault_balance = context.accounts.leased_vault.amount;
103+
let collateral_vault_balance = context.accounts.collateral_vault.amount;
104+
105+
let leased_vault_bump = context.accounts.lease.leased_vault_bump;
106+
let collateral_vault_bump = context.accounts.lease.collateral_vault_bump;
107+
108+
// Update state before CPIs (Checks-Effects-Interactions).
109+
//
110+
// We are not forwarding any accrued lease fees to the holder here — on default
111+
// the holder takes the whole collateral vault as compensation — but we
112+
// still bump `last_paid_timestamp` so the invariant
113+
// `last_paid_timestamp <= now.min(end_timestamp)` stays intact. That matters for
114+
// any future version of the program that wants to split the collateral
115+
// differently (pro-rata lease fees, partial refund on default, haircut to the
116+
// short_seller for unused time): such a version can read
117+
// `last_paid_timestamp` and trust that everything up to `now` is already
118+
// settled, rather than having to reason about whether this branch ever
119+
// bumped the timestamp.
120+
//
121+
// No-op on the `Listed` branch because Lease fees never started accruing.
122+
if status == LeaseStatus::Active {
123+
update_last_paid_timestamp(&mut context.accounts.lease, now);
124+
}
125+
context.accounts.lease.collateral_amount = 0;
126+
context.accounts.lease.status = LeaseStatus::Closed;
127+
128+
let leased_vault_seeds: &[&[u8]] = &[
129+
LEASED_VAULT_SEED,
130+
lease_key.as_ref(),
131+
core::slice::from_ref(&leased_vault_bump),
132+
];
133+
let collateral_vault_seeds: &[&[u8]] = &[
134+
COLLATERAL_VAULT_SEED,
135+
lease_key.as_ref(),
136+
core::slice::from_ref(&collateral_vault_bump),
137+
];
138+
139+
// Drain whatever is in the leased vault back to the holder. For a Listed
140+
// lease this is the full leased_amount; for a defaulted Active lease the
141+
// vault is empty (the short_seller never returned) and this is a no-op.
142+
if leased_vault_balance > 0 {
143+
transfer_tokens_from_vault(
144+
&context.accounts.leased_vault,
145+
&context.accounts.holder_leased_account,
146+
leased_vault_balance,
147+
&context.accounts.leased_mint,
148+
&context.accounts.leased_vault.to_account_info(),
149+
&context.accounts.token_program,
150+
&[leased_vault_seeds],
151+
)?;
152+
}
153+
154+
// Drain the collateral vault to the holder. For a Listed lease this is 0.
155+
// For a defaulted Active lease this is the short_seller's forfeited collateral.
156+
if collateral_vault_balance > 0 {
157+
transfer_tokens_from_vault(
158+
&context.accounts.collateral_vault,
159+
&context.accounts.holder_collateral_account,
160+
collateral_vault_balance,
161+
&context.accounts.collateral_mint,
162+
&context.accounts.collateral_vault.to_account_info(),
163+
&context.accounts.token_program,
164+
&[collateral_vault_seeds],
165+
)?;
166+
}
167+
168+
close_vault(
169+
&context.accounts.leased_vault,
170+
&context.accounts.holder.to_account_info(),
171+
&context.accounts.token_program,
172+
&[leased_vault_seeds],
173+
)?;
174+
close_vault(
175+
&context.accounts.collateral_vault,
176+
&context.accounts.holder.to_account_info(),
177+
&context.accounts.token_program,
178+
&[collateral_vault_seeds],
179+
)?;
180+
181+
Ok(())
182+
}

0 commit comments

Comments
 (0)