Skip to content

Commit d2e0d4d

Browse files
author
Edward (Mike's AI sidekick)
committed
feat(asset-leasing): add Quasar port and apply Mike's README feedback
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 04367b8 before any edits). CI builds both sides cleanly.
1 parent 04367b8 commit d2e0d4d

20 files changed

Lines changed: 2568 additions & 242 deletions

File tree

defi/asset-leasing/anchor/README.md

Lines changed: 194 additions & 233 deletions
Large diffs are not rendered by default.

defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
/// u64 `lease_id` so one lessor can run many leases in parallel.
33
pub const LEASE_SEED: &[u8] = b"lease";
44

5-
/// PDA seed for the SPL vault that holds the leased tokens while the lease is
6-
/// `Listed` and that accepts returned tokens on settlement.
5+
/// PDA seed for the token vault that holds the leased tokens while the lease
6+
/// is `Listed` and that accepts returned tokens on settlement.
77
pub const LEASED_VAULT_SEED: &[u8] = b"leased_vault";
88

9-
/// PDA seed for the SPL vault that escrows the lessee's collateral for the
9+
/// PDA seed for the token vault that escrows the lessee's collateral for the
1010
/// life of the lease.
1111
pub const COLLATERAL_VAULT_SEED: &[u8] = b"collateral_vault";
1212

defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ pub fn handle_create_lease(
8181
liquidation_bounty_bps: u16,
8282
feed_id: [u8; 32],
8383
) -> Result<()> {
84-
// Reject leased_mint == collateral_mint. Allowing both to be the same SPL
84+
// Reject leased_mint == collateral_mint. Allowing both to be the same
8585
// mint would collapse the two vaults' seed derivations into one shared
8686
// token-balance pool, making rent-vs-collateral accounting ambiguous and
8787
// enabling griefing paths where the lessee's "collateral" is the same

defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use anchor_spl::token_interface::{
44
TransferChecked,
55
};
66

7-
/// Transfer SPL tokens from a user-controlled account to a program-controlled
7+
/// Transfer tokens from a user-controlled account to a program-controlled
88
/// vault (or any other account the signer owns). Authority is a plain signer.
99
pub fn transfer_tokens_from_user<'info>(
1010
from: &InterfaceAccount<'info, TokenAccount>,
@@ -27,8 +27,8 @@ pub fn transfer_tokens_from_user<'info>(
2727
)
2828
}
2929

30-
/// Transfer SPL tokens out of a PDA-owned vault using the supplied signer
31-
/// seeds. Used by the program when moving tokens held under its authority.
30+
/// Transfer tokens out of a PDA-owned vault using the supplied signer seeds.
31+
/// Used by the program when moving tokens held under its authority.
3232
pub fn transfer_tokens_from_vault<'info>(
3333
from: &InterfaceAccount<'info, TokenAccount>,
3434
to: &InterfaceAccount<'info, TokenAccount>,
@@ -51,7 +51,7 @@ pub fn transfer_tokens_from_vault<'info>(
5151
)
5252
}
5353

54-
/// Close a PDA-owned SPL token vault and forward its rent-exempt lamports to
54+
/// Close a PDA-owned token vault and forward its rent-exempt lamports to
5555
/// `destination`. The vault is its own token-account authority, so the caller
5656
/// just passes the same vault `AccountInfo` as both the account and the
5757
/// authority, with the vault's signer seeds for the CPI.

defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -965,7 +965,7 @@ fn close_expired_cancels_listed_lease() {
965965

966966
#[test]
967967
fn create_lease_rejects_same_mint_for_leased_and_collateral() {
968-
// Collapsing leased_mint and collateral_mint into a single SPL mint would
968+
// Collapsing leased_mint and collateral_mint into a single mint would
969969
// also collapse the two vaults into one token-balance pool (same mint,
970970
// same authority seed pattern) and make rent-vs-collateral accounting
971971
// ambiguous. The program rejects this up-front with
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
[package]
2+
name = "quasar-asset-leasing"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
# Standalone workspace — not part of the root program-examples workspace.
7+
# Quasar uses a different resolver and dependency tree from the Anchor
8+
# projects, so it must declare its own [workspace] root.
9+
[workspace]
10+
11+
[lints.rust.unexpected_cfgs]
12+
level = "warn"
13+
check-cfg = [
14+
'cfg(target_os, values("solana"))',
15+
]
16+
17+
[lib]
18+
# `cdylib` for the on-chain .so; `lib` so `cargo test` can link the Rust
19+
# code as a regular library and exercise handlers against QuasarSvm.
20+
crate-type = ["cdylib", "lib"]
21+
22+
[features]
23+
alloc = []
24+
client = []
25+
debug = []
26+
27+
[dependencies]
28+
quasar-lang = "0.0"
29+
quasar-spl = "0.0"
30+
solana-address = { version = "2.2.0" }
31+
solana-instruction = { version = "3.2.0" }
32+
33+
[dev-dependencies]
34+
quasar-svm = { version = "0.1" }
35+
spl-token-interface = { version = "2.0.0" }
36+
solana-program-pack = { version = "3.1.0" }
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[project]
2+
name = "quasar_asset_leasing"
3+
4+
[toolchain]
5+
type = "solana"
6+
7+
[testing]
8+
language = "rust"
9+
10+
[testing.rust]
11+
framework = "quasar-svm"
12+
13+
[testing.rust.test]
14+
program = "cargo"
15+
args = [
16+
"test",
17+
"tests::",
18+
]
19+
20+
[clients]
21+
path = "target/client"
22+
languages = ["rust"]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/// PDA seed for the `Lease` account. Combined with the lessor pubkey and a
2+
/// u64 `lease_id` so one lessor can run many leases in parallel.
3+
pub const LEASE_SEED: &[u8] = b"lease";
4+
5+
/// PDA 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+
/// PDA seed for the token vault that escrows the lessee'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 (bps) ratios used for the maintenance margin
14+
/// and the liquidation bounty. 10_000 bps = 100%.
15+
pub const BPS_DENOMINATOR: u64 = 10_000;
16+
17+
/// Maximum allowed maintenance margin: 50_000 bps = 500%. Prevents the lessor
18+
/// setting an impossible margin that would let them liquidate on day one.
19+
pub const MAX_MAINTENANCE_MARGIN_BPS: u16 = 50_000;
20+
21+
/// Maximum liquidation bounty the keeper can claim: 2_000 bps = 20%. Keeps
22+
/// most of the collateral flowing to the lessor on default.
23+
pub const MAX_LIQUIDATION_BOUNTY_BPS: 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 on-chain clock. 60 s matches
27+
/// the default staleness window used in the Pyth SDK docs.
28+
pub const PYTH_MAX_AGE_SECONDS: u64 = 60;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
use quasar_lang::prelude::*;
2+
3+
/// Program-specific errors. Codes start at 6000 (Quasar's default
4+
/// `#[error_code]` offset, matching Anchor), so they never collide with
5+
/// Solana's built-in `ProgramError` codes or the framework's
6+
/// `QuasarError` codes.
7+
#[error_code]
8+
pub enum AssetLeasingError {
9+
InvalidLeaseStatus,
10+
InvalidDuration,
11+
InvalidLeasedAmount,
12+
InvalidCollateralAmount,
13+
InvalidRentPerSecond,
14+
InvalidMaintenanceMargin,
15+
InvalidLiquidationBounty,
16+
LeaseExpired,
17+
LeaseNotExpired,
18+
PositionHealthy,
19+
StalePrice,
20+
NonPositivePrice,
21+
MathOverflow,
22+
Unauthorised,
23+
LeasedMintEqualsCollateralMint,
24+
PriceFeedMismatch,
25+
InvalidStatusByte,
26+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
use {
2+
crate::{
3+
constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED},
4+
errors::AssetLeasingError,
5+
instructions::pay_rent::update_last_paid_ts,
6+
state::{Lease, LeaseStatus},
7+
},
8+
quasar_lang::prelude::*,
9+
quasar_spl::{Mint, Token, TokenCpi},
10+
};
11+
12+
/// Lessor-only recovery path. Two situations collapse into this handler:
13+
///
14+
/// - The lease sat in `Listed` and the lessor wants to cancel it,
15+
/// recovering the leased tokens they pre-funded. Allowed any time.
16+
/// - The lease was `Active` but the lessee ghosted past `end_ts`. The
17+
/// lessor takes the collateral as compensation and closes the books.
18+
#[derive(Accounts)]
19+
pub struct CloseExpired<'info> {
20+
#[account(mut)]
21+
pub lessor: &'info Signer,
22+
23+
#[account(
24+
mut,
25+
seeds = [LEASE_SEED, lessor],
26+
bump = lease.bump,
27+
has_one = lessor,
28+
has_one = leased_mint,
29+
has_one = collateral_mint,
30+
constraint = {
31+
let s = LeaseStatus::from_u8(lease.status);
32+
s == Some(LeaseStatus::Listed) || s == Some(LeaseStatus::Active)
33+
} @ AssetLeasingError::InvalidLeaseStatus,
34+
close = lessor,
35+
)]
36+
pub lease: &'info mut Account<Lease>,
37+
38+
pub leased_mint: &'info Account<Mint>,
39+
pub collateral_mint: &'info Account<Mint>,
40+
41+
#[account(
42+
mut,
43+
seeds = [LEASED_VAULT_SEED, lease],
44+
bump = lease.leased_vault_bump,
45+
)]
46+
pub leased_vault: &'info mut Account<Token>,
47+
48+
#[account(
49+
mut,
50+
seeds = [COLLATERAL_VAULT_SEED, lease],
51+
bump = lease.collateral_vault_bump,
52+
)]
53+
pub collateral_vault: &'info mut Account<Token>,
54+
55+
#[account(mut)]
56+
pub lessor_leased_account: &'info mut Account<Token>,
57+
58+
#[account(mut)]
59+
pub lessor_collateral_account: &'info mut Account<Token>,
60+
61+
pub token_program: &'info Program<Token>,
62+
}
63+
64+
#[inline(always)]
65+
pub fn handle_close_expired(accounts: &mut CloseExpired) -> Result<(), ProgramError> {
66+
let now = <Clock as quasar_lang::sysvars::Sysvar>::get()?.unix_timestamp.get();
67+
let lease_address = *accounts.lease.address();
68+
let status = LeaseStatus::from_u8(accounts.lease.status)
69+
.ok_or(AssetLeasingError::InvalidStatusByte)?;
70+
71+
// Active leases can only be closed after they expire. Listed leases
72+
// have no start/end so the check is skipped.
73+
if status == LeaseStatus::Active {
74+
let end_ts = accounts.lease.end_ts.get();
75+
if now < end_ts {
76+
return Err(AssetLeasingError::LeaseNotExpired.into());
77+
}
78+
}
79+
80+
let leased_vault_bump = [accounts.lease.leased_vault_bump];
81+
let leased_vault_seeds: &[Seed] = &[
82+
Seed::from(LEASED_VAULT_SEED),
83+
Seed::from(lease_address.as_ref()),
84+
Seed::from(&leased_vault_bump as &[u8]),
85+
];
86+
let collateral_vault_bump = [accounts.lease.collateral_vault_bump];
87+
let collateral_vault_seeds: &[Seed] = &[
88+
Seed::from(COLLATERAL_VAULT_SEED),
89+
Seed::from(lease_address.as_ref()),
90+
Seed::from(&collateral_vault_bump as &[u8]),
91+
];
92+
93+
// Drain whatever is in the leased vault back to the lessor. For a
94+
// Listed lease this is the full leased_amount; for a defaulted
95+
// Active lease the vault is empty (the lessee never returned) so
96+
// this is a no-op.
97+
let leased_vault_balance = accounts.leased_vault.amount();
98+
if leased_vault_balance > 0 {
99+
accounts
100+
.token_program
101+
.transfer(
102+
accounts.leased_vault,
103+
accounts.lessor_leased_account,
104+
accounts.leased_vault,
105+
leased_vault_balance,
106+
)
107+
.invoke_signed(leased_vault_seeds)?;
108+
}
109+
110+
// Drain the collateral vault to the lessor. For a Listed lease this
111+
// is 0. For a defaulted Active lease this is the lessee's forfeited
112+
// collateral.
113+
let collateral_vault_balance = accounts.collateral_vault.amount();
114+
if collateral_vault_balance > 0 {
115+
accounts
116+
.token_program
117+
.transfer(
118+
accounts.collateral_vault,
119+
accounts.lessor_collateral_account,
120+
accounts.collateral_vault,
121+
collateral_vault_balance,
122+
)
123+
.invoke_signed(collateral_vault_seeds)?;
124+
}
125+
126+
accounts
127+
.token_program
128+
.close_account(
129+
accounts.leased_vault,
130+
accounts.lessor,
131+
accounts.leased_vault,
132+
)
133+
.invoke_signed(leased_vault_seeds)?;
134+
accounts
135+
.token_program
136+
.close_account(
137+
accounts.collateral_vault,
138+
accounts.lessor,
139+
accounts.collateral_vault,
140+
)
141+
.invoke_signed(collateral_vault_seeds)?;
142+
143+
// Keep the rent-settlement invariant intact even on default: the
144+
// lessor takes the whole collateral vault as compensation here, but
145+
// any future version of the program that wants to split the
146+
// collateral differently (pro-rata rent, partial refund on default)
147+
// can read `last_rent_paid_ts` and trust that everything up to
148+
// `now` is already settled.
149+
if status == LeaseStatus::Active {
150+
update_last_paid_ts(accounts.lease, now);
151+
}
152+
accounts.lease.collateral_amount = 0u64.into();
153+
accounts.lease.status = LeaseStatus::Closed as u8;
154+
155+
Ok(())
156+
}

0 commit comments

Comments
 (0)