forked from solana-developers/program-examples
-
Notifications
You must be signed in to change notification settings - Fork 1
feat(asset-leasing): add Quasar port and apply Mike's README feedback #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mikemaccana-edwardbot
wants to merge
40
commits into
quiknode-labs:main
Choose a base branch
from
mikemaccana-edwardbot:asset-leasing
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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
6dab03f
docs(asset-leasing): beginner-friendly README explaining finance conc…
4fb8cc0
refactor(asset-leasing): extract close_vault helper to shared.rs
28a715d
fix(asset-leasing): reject leased_mint == collateral_mint on create_l…
29f5e00
fix(asset-leasing): pin Pyth feed_id on Lease and enforce at liquidate
a92d2fb
fix(asset-leasing): settle last_rent_paid_ts on close_expired default…
04367b8
docs(asset-leasing): rewrite README to the repo-wide quality bar
d2e0d4d
feat(asset-leasing): add Quasar port and apply Mike's README feedback
33f5ef7
docs(asset-leasing): drop 'SPL Token' qualifier, just say 'token'
001ca85
refactor(asset-leasing): alias SPL_TOKEN_PROGRAM_ID to TOKEN_PROGRAM_…
709542e
Revert "refactor(asset-leasing): alias SPL_TOKEN_PROGRAM_ID to TOKEN_…
5f35c76
docs+code: rename "rent" to "lease fee" throughout asset-leasing
10a6caa
docs+code: spell out all abbreviations in asset-leasing
28eea9a
docs: reframe asset-leasing as on-chain securities lending
cde6b0d
docs: "on-chain" → "onchain" throughout
62fac5f
defi/asset-leasing: rename roles to holder/short_seller; rewrite README
487b360
docs(asset-leasing): emphasize key terms on first use; fix ambiguous …
255da13
docs(asset-leasing): scrub 'fungible token' and 'borrow' as a noun
5491142
docs(asset-leasing): drop fungibility explainer
5141f60
docs(asset-leasing): make the short seller's full lifecycle explicit
94b58f3
docs(asset-leasing): include the sell-and-rebuy step in the intro
35afc7d
docs(asset-leasing): make the two-mint asymmetry obvious in the intro
114eb7a
docs(asset-leasing): drop the README-previews-itself paragraph
25f5f8b
docs(asset-leasing): remove ASCII-art lifecycle diagram
a593158
asset-leasing: merge per-instruction reference and worked-examples se…
d55e3af
docs(asset-leasing): add 'what the short seller really gets' framing;…
c563641
docs(asset-leasing): name the instruction handlers in the §1 lifecycl…
bdb511d
docs(asset-leasing): drop 'worked' from example/scenario headings
1298583
docs(asset-leasing): replace em-dashes with regular dashes; say 'toke…
53c7937
docs(asset-leasing): remove redundant Roles subsection
f5ebe0e
docs(asset-leasing): convert markdown tables to bullet lists
6cf024f
docs(asset-leasing): clarify how the per-second lease fee actually ac…
db46658
docs(asset-leasing): replace bare \u00a73.x section references with n…
7c0f710
docs(asset-leasing): strip section numbers from headings; rewrite cro…
ab99204
docs(asset-leasing): add the holder's full lifecycle
61e25c5
asset-leasing: merge accounts section into lifecycle
0c64894
docs(asset-leasing): drop the contradictory 'non-custodial escrow' claim
602823f
docs(asset-leasing): note that swap composition is the frontend's job…
e819828
docs(asset-leasing): fix on-chain/off-chain hyphenation
9f042b4
docs(asset-leasing): add bilateral versus pooled lending section
mikemaccana-edwardbot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Large diffs are not rendered by default.
Oops, something went wrong.
49 changes: 49 additions & 0 deletions
49
defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
28
defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
37
defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| } |
179 changes: 179 additions & 0 deletions
179
defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(()) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 tokensandSPL collateral(the user instruction statesTokennotSPL Token) andper-second rent(the program and the in-repo README now consistently uselease feerather thanrent). The Anchor README and program code already follow the new terminology, so the root README is out of step.Reviewed by Cursor Bugbot for commit 5f35c76. Configure here.