diff --git a/README.md b/README.md index 6f6ecf0ed..68924be6e 100644 --- a/README.md +++ b/README.md @@ -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 + +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) + ### 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. diff --git a/defi/asset-leasing/anchor/.gitignore b/defi/asset-leasing/anchor/.gitignore new file mode 100644 index 000000000..2e0446b07 --- /dev/null +++ b/defi/asset-leasing/anchor/.gitignore @@ -0,0 +1,7 @@ +.anchor +.DS_Store +target +**/*.rs.bk +node_modules +test-ledger +.yarn diff --git a/defi/asset-leasing/anchor/Anchor.toml b/defi/asset-leasing/anchor/Anchor.toml new file mode 100644 index 000000000..acec9f668 --- /dev/null +++ b/defi/asset-leasing/anchor/Anchor.toml @@ -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" diff --git a/defi/asset-leasing/anchor/Cargo.toml b/defi/asset-leasing/anchor/Cargo.toml new file mode 100644 index 000000000..11cbab7ba --- /dev/null +++ b/defi/asset-leasing/anchor/Cargo.toml @@ -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 diff --git a/defi/asset-leasing/anchor/README.md b/defi/asset-leasing/anchor/README.md new file mode 100644 index 000000000..c31c28881 --- /dev/null +++ b/defi/asset-leasing/anchor/README.md @@ -0,0 +1,1195 @@ +# Asset Leasing + +**Directional token lending.** **Holders** rent out token inventory +to **short sellers**. The short seller posts collateral in a stable +asset (e.g. USDC) and borrows the asset they want to short (e.g. +xNVDA). They immediately sell the borrowed xNVDA on the open market +for more USDC, pay a second-by-second lending fee while the position +is open, and later buy equivalent xNVDA back to return to the +holder. If xNVDA's price falls between the sell and the re-buy, the +short seller pockets the difference in USDC; if xNVDA rallies far +enough that their collateral no longer covers the cost of buying it +back, keepers liquidate the position. + +This is the same primitive that underpins traditional securities +lending in TradFi: holders earn yield on inventory they would hold +anyway (think exchange-traded funds, pension funds, or any passive +allocator), and short sellers and arbitrageurs get the tokens they +need to sell short. The program is written in +[Anchor](https://solana.com/docs/terminology); a parallel +[Quasar port](#quasar-port) implements the same onchain behaviour. + +--- + +## Table of contents + +1. [What does this program do?](#what-does-this-program-do) +2. [Bilateral versus pooled lending](#bilateral-versus-pooled-lending) +3. [Lifecycle](#lifecycle) +4. [Safety and edge cases](#safety-and-edge-cases) +5. [Running the tests](#running-the-tests) +6. [Quasar port](#quasar-port) +7. [Extending the program](#extending-the-program) + +--- + +## What does this program do? + +A **holder** offers some quantity of **token A** - the leased token - +for a fixed term. A **short seller** posts collateral in a different +**token B** - the collateral token - to take delivery of the A +tokens. + +The short seller's full lifecycle is: + +1. **Open the position** by calling `take_lease`. This borrows A from + the holder and locks B as collateral. From this point on, a + per-second lending fee accrues against the locked collateral. The + fee is computed on demand: the program tracks + `last_paid_timestamp` and `lease_fee_per_second` on the lease + account, multiplies by elapsed seconds whenever any handler runs, + and debits the result from the collateral. Nothing happens onchain + each second - the fee is just a number that grows until someone + pokes the lease. +2. **Sell A immediately** on a market like Jupiter, receiving more B + in return. The short seller now has more B and owes A. The + asset-leasing program does not perform this swap itself; that is + the DEX's job, and keeping the two concerns separate keeps each + program narrow and composable. In practice a frontend bundles + `take_lease` and the Jupiter swap into a single transaction so + the short seller signs once and the open-short flow is atomic + (Solana's transaction atomicity guarantees both succeed or both + revert). +3. **Wait.** They are betting A's price (denominated in B) will fall. + The short seller doesn't have to call anything while they wait - + accrued fees auto-settle at close. They can optionally call + `pay_lease_fee` to settle the running balance early (so the fee + doesn't eat into their collateral cushion), and `top_up_collateral` + to add more collateral if A's price moves against them. +4. **Close the position** by calling `return_lease`. They buy A back + on the open market - hopefully at a lower price than they sold it + for - and return the same quantity of A to the holder. The B they + paid to re-acquire A is less than the B they got for selling it, + and the difference is the short seller's profit. + +If A's price *rises* instead, buying it back costs more B than they +got for selling it - that's a loss. If it rises far enough that their +locked collateral is no longer worth more than the A they owe, anyone +can call `liquidate` to close the position out, paying the keeper a +bounty from the collateral. If the lease term ends without the short +seller calling `return_lease`, the holder calls `close_expired` to +seize the collateral and recover. + +The holder's full lifecycle is shorter: + +1. **List the tokens** by calling `create_lease`. This locks the A + tokens in a program-owned vault and publishes the terms (collateral + required, lease fee, duration, maintenance margin, liquidation + bounty, oracle feed). The lease starts in `Listed` status. +2. **Wait for a taker.** If a short seller takes the offer (calling + `take_lease`), the lease moves to `Active` status and the holder + starts earning the per-second lending fee. If no-one takes it, the + holder can cancel at any time. +3. **Earn fees while the lease is `Active`.** The holder doesn't have + to call anything; the fee accrues against the short seller's + collateral and settles whenever any handler runs against the lease. +4. **Get paid out at close.** Whichever path the lease takes (clean + return, liquidation, or expiry), the holder ends up with their A + tokens back (or, on liquidation/expiry default, the equivalent + value in B as compensation) plus all the lease fees that accrued. + +The holder can call `close_expired` to terminate the lease in two +situations: (a) the lease is `Listed` and they want to cancel it +before any short seller takes it, or (b) the lease is `Active`, the +deadline has passed, and the short seller hasn't returned the tokens - +in which case the holder seizes the entire collateral as compensation +for the missing tokens. + +The program acts as the escrow agent. Both the leased tokens and +the collateral sit in program-owned vaults during the lease, and the +program-derived address signs all the transfers in and out. There is +no admin key and no off-program logic that can move funds: every +transfer is dictated by the rules below, and those rules are the +deployed bytecode. Specifically: + +1. Takes the holder's A tokens and locks them in a program-owned + vault until a short seller shows up. +2. When a short seller calls `take_lease`, the program locks the + short seller's B tokens as collateral and hands the A tokens to + the short seller. +3. While the loan is live, a second-by-second **lending fee stream** + pays the holder out of the collateral vault. +4. If the price of A (measured in B) rises far enough that the locked + collateral is no longer enough to cover the cost of re-acquiring + the borrowed tokens, anyone can call `liquidate` - the collateral + is seized, most of it goes to the holder, and a small percentage + (the **liquidation bounty**) goes to whoever called `liquidate`. + Such a caller is known as a **keeper** - a bot or anyone else who + watches the chain for positions that have gone underwater and + earns the bounty by cleaning them up. +5. If the short seller returns the full A amount before the deadline, + the short seller gets back whatever collateral is left after + lending fees. +6. If the short seller ghosts past the deadline without returning + anything, the holder calls `close_expired` and sweeps the + collateral as compensation. + +The trigger for step 4 is the **maintenance margin**: a ratio, +expressed in basis points (1 basis point = 1/100 of a percent), of +required collateral value to debt value. +`maintenance_margin_basis_points = 12_000` is 120%, meaning the +collateral must stay worth at least 1.2ร— the borrowed tokens. Drop +below and the position becomes liquidatable. + +The program is a pair of vaults, a small piece of state that tracks +how much has been paid, and an oracle check. + +### Example: shorting xNVDA via the lending market + +Concrete numbers using assets that already trade on Solana - +[xNVDA](https://www.backed.fi/) (a Backed Finance / xStocks tokenised +NVIDIA share) and USDC. xNVDA has its own Pyth feed; the program +takes the feed id verbatim at `create_lease`. + +Alice holds 100 xNVDA at ~$180 / share, ~$18 000 notional. She wants +yield on inventory she would hold anyway. + +Bob wants short exposure to NVIDIA without using a perpetual future. + +Alice lists the lease (assume USDC is 6-decimal, xNVDA is also +6-decimal for round numbers): + +- **`leased_amount`**: `100_000_000` (100 xNVDA) +- **`required_collateral_amount`**: `22_000_000_000` (22 000 USDC) - ~122% LTV at the spot price +- **`lease_fee_per_second`**: `456` (USDC base units / s) - โ‰ˆ 8% APR on 18 000 USDC notional +- **`duration_seconds`**: `2_592_000` - 30 days +- **`maintenance_margin_basis_points`**: `11_000` - 110% +- **`liquidation_bounty_basis_points`**: `100` - 1% of post-fee collateral +- **`feed_id`**: Pyth xNVDA/USD feed id ([Pyth feed registry](https://www.pyth.network/price-feeds)) + +Bob calls `take_lease`, posts 22 000 USDC, receives 100 xNVDA, and +sells the 100 xNVDA on Jupiter for ~18 000 USDC at the spot price. + +#### If NVIDIA rallies to $200 + +- Bob's debt to repurchase the 100 xNVDA is now `100 ร— $200 = $20 000`. +- Collateral ratio: `22 000 / 20 000 = 110%` - exactly at the + maintenance margin. +- One more upward tick and a keeper can call `liquidate` with a fresh + Pyth update. Of the 22 000 USDC vault: a small portion has + already streamed out as lease fees (Bob's incentive to keep paying + was to keep the position alive); of what's left, 1% goes to the + keeper as the bounty (~220 USDC), the rest to Alice. +- Bob can avoid liquidation by: + - Calling `top_up_collateral` to push the ratio back above 110%, or + - Buying 100 xNVDA on the open market and calling `return_lease` to + close out cleanly. + +#### If NVIDIA falls to $160 + +- Bob's debt drops to `100 ร— $160 = $16 000`. +- Collateral ratio: `22 000 / 16 000 = 137.5%` - well above the 110% + maintenance margin. No liquidation pressure. +- Bob buys back 100 xNVDA on Jupiter for ~16 000 USDC and calls + `return_lease`. Alice receives the 100 xNVDA back plus the + accrued lease fee. The remaining ~22 000 USDC (minus fees paid) + refunds to Bob. +- Bob's profit โ‰ˆ `$18 000 โˆ’ $16 000 โˆ’ fees โˆ’ trading costs โ‰ˆ $2 000` + minus carry. This is a 30-day short on NVIDIA, expressed onchain. + +The asymmetry: liquidation only ever fires when the *borrowed* asset +rallies against the collateral. A drop in the borrowed asset price is +purely beneficial to the short seller. The streaming lending fee is +the position's only ongoing cost in either direction. + +The [lifecycle](#lifecycle) section walks each instruction handler +with concrete numbers that match the LiteSVM tests; the xNVDA example +above is the same machinery applied to a real asset pair. + +### Production deviations to know + +- **Pyth integration is hand-rolled, not via the SDK.** The LiteSVM + tests install a `PriceUpdateV2` account whose layout is decoded + inline in `liquidate.rs`. Production code would depend on the + `pyth-solana-receiver-sdk` crate so layout changes are caught at + compile time. +- See [safety and edge cases](#safety-and-edge-cases) for the rest of the deliberate simplifications. + +--- + +## Bilateral versus pooled lending + +Our program could be redesigned to use pooled lending, like Kamino, +MarginFi, and other programs where many depositors share one +liquidity pool that borrowers draw from against collateral, with +rates set automatically by a utilisation curve. That design works +well for some assets and poorly for others. We chose bilateral +lending - direct deals between one holder and one short seller - and +it's worth explaining why. + +Pooled lending already supports shorting tokens. A short seller +deposits collateral, borrows the asset, sells it, buys it back later, +and repays the loan. So the question isn't whether pooled lending +*can* facilitate shorts. It can. The question is which structure is +the right tool for which market. + +For deep, liquid assets such as SOL, USDC, and the majors, pooled +lending is the right tool. Capital is efficient, fills are instant, +and pricing adjusts automatically as borrowing demand changes. + +Bilateral lending wins where pooled lending breaks down: + +- **Bilateral terms.** Holder and short seller agree on a fixed + duration, fixed lease fee, and a custom collateral schedule. + Pooled lending forces every borrower onto one rate model and + offers no end date. Borrowers face open-ended exposure to rate + spikes and margin calls. Bilateral lending gives both sides + predictability. +- **Pool rates spike when supply is thin.** Pool interest rates rise + gently as borrowing demand grows, then spike sharply once most of + the supply is in use. For lightly supplied assets, this makes + shorting punitive and unstable. Bilateral lending prices through + direct negotiation, so the rate is whatever holder and short + seller agree on. +- **Holder control over supply.** In a pool, the holder is one of + many depositors; the program commingles deposits and decides how + they get used. In bilateral lending, the holder chooses which + short seller borrows their tokens and on what terms. They can + refuse, charge a premium, or restrict to specific counterparties. +- **Long-tail and new tokens.** A token with no pooled-lending + market cannot be shorted through pooled lending. Bilateral lending + works on day one with one holder and one short seller, in markets + of size n=1. + +If your target asset is a major liquid token with deep existing +pooled-lending markets, redesigning around a pool is reasonable. If +your target is anything else - a thinly-supplied token, a token +where holders care who borrows from them, or a token where both +sides want fixed terms - bilateral lending is the better fit. + +--- + +## Lifecycle + +### What the short seller really gets + +When a short seller takes a lease, they walk away with two things: + +- **At open: today's market value of the leased tokens, in stables.** + They borrow the leased tokens from a holder, sell them on the open + market immediately, and pocket the stables. +- **At close: an obligation to return the same number of tokens, + regardless of what those tokens are worth then.** The obligation + is fixed in *units of the leased token*, not in *units of value*. + If the price falls - say from $190 to $160 per token - the cost of + acquiring the same number of tokens to return drops, and the short + seller keeps the difference. + +The asymmetry is the trade: cash received today is fixed in stables; +the cost of fulfilling the obligation later is fixed in tokens whose +price is unknown. Bet correctly on the direction and that asymmetry +prints money. Bet wrong and the cost of buying the tokens back can +exceed the cash plus the collateral, at which point the keepers +arrive (see [branch: position underwater - `liquidate`](#branch-position-underwater---liquidate)). + +### The holder lists the tokens - `create_lease` + +The holder calls `create_lease`, naming the leased mint, the +collateral mint, the amount of leased tokens to offer, the +collateral the short seller will have to post, the per-second lease +fee, the duration, the maintenance-margin and liquidation-bounty +ratios, and the Pyth `feed_id` the lease will be priced against. +This is where every account the rest of the lifecycle uses gets +created. The handler initialises three +[program-derived addresses](https://solana.com/docs/terminology): + +- **`Lease`** - the state account, owned by the program, holding all + the lease parameters and the current lifecycle status. Seeds: + `[b"lease", holder, lease_id.to_le_bytes()]` - keying on + `lease_id` lets one holder run many leases in parallel. +- **`leased_vault`** - a token account for the leased mint whose + authority is itself (the program signs as the vault using the + vault's own seeds). Seeds: `[b"leased_vault", lease]`. Holds + `leased_amount` while `Listed`; `0` while `Active` (the short + seller has the tokens); the full amount again briefly inside + `return_lease`. +- **`collateral_vault`** - a token account for the collateral mint, + also self-authoritative. Seeds: `[b"collateral_vault", lease]`. + Created empty here; filled by `take_lease`, drained over time as + lease fees stream out, and topped up by `top_up_collateral`. + +The handler then moves the leased tokens out of the holder's wallet +into the leased vault. Locking the leased tokens up front means a +short seller calling `take_lease` later cannot fail because the +holder spent the inventory in the meantime - the atomicity guarantee +transfers to the program the moment the lease is listed. + +- **Signers:** `holder` (the user wallet listing the tokens; receives + the lease fee and the final recovery). +- **Accounts:** + - `holder` (signer, mut - pays account rent) + - `leased_mint`, `collateral_mint` (read-only) + - `holder_leased_account` (mut, holder's [associated token account](https://solana.com/docs/terminology) for the leased mint - source) + - `lease` (program-derived address, **init**) - created here, seeds `[b"lease", holder, lease_id.to_le_bytes()]` + - `leased_vault` (program-derived address, **init**, token account) - created here, seeds `[b"leased_vault", lease]`, authority = itself + - `collateral_vault` (program-derived address, **init**, token account) - created here, seeds `[b"collateral_vault", lease]`, authority = itself + - `token_program`, `system_program` +- **What happens:** + - Single token movement: `leased_amount` of the leased mint + transfers from `holder_leased_account` to `leased_vault`. + - The `Lease` account is written with `status = Listed`, + `short_seller = Pubkey::default()`, `collateral_amount = 0`, + `start_timestamp = 0`, `end_timestamp = 0`, + `last_paid_timestamp = 0`, and the supplied parameters including + `feed_id`. All three bumps are stored. +- **Errors:** + - `LeasedMintEqualsCollateralMint` if `leased_mint == collateral_mint` + - `InvalidLeasedAmount` if `leased_amount == 0` + - `InvalidCollateralAmount` if `required_collateral_amount == 0` + - `InvalidLeaseFeePerSecond` if `lease_fee_per_second == 0` + - `InvalidDuration` if `duration_seconds <= 0` + - `InvalidMaintenanceMargin` if `maintenance_margin_basis_points` is `0` or `> 50_000` + - `InvalidLiquidationBounty` if `liquidation_bounty_basis_points > 2_000` + +#### What's on the lease account + +The `Lease` account written above carries the full set of fields +referenced by the rest of the lifecycle. From [`state/lease.rs`](programs/asset-leasing/src/state/lease.rs): + +```rust +pub struct Lease { + pub lease_id: u64, // caller-supplied id so one holder can run many leases + pub holder: Pubkey, // who listed it, gets paid the lease fee + pub short_seller: Pubkey, // who took the lease; Pubkey::default() while Listed + + pub leased_mint: Pubkey, + pub leased_amount: u64, // locked at creation, unchanging + + pub collateral_mint: Pubkey, + pub collateral_amount: u64, // increases on top_up, decreases as lease fees pay out + pub required_collateral_amount: u64, // what the short seller must post on take_lease + + pub lease_fee_per_second: u64, // denominated in collateral units + pub duration_seconds: i64, + pub start_timestamp: i64, // 0 while Listed + pub end_timestamp: i64, // 0 while Listed; start_timestamp + duration once Active + pub last_paid_timestamp: i64, // Lease fee accrues from here to min(now, end_timestamp) + + pub maintenance_margin_basis_points: u16, // e.g. 12_000 = 120% + pub liquidation_bounty_basis_points: u16, // e.g. 500 = 5% + + pub feed_id: [u8; 32], // Pyth feed_id this lease is pinned to + + pub status: LeaseStatus, // Listed | Active | Liquidated | Closed + + pub bump: u8, + pub leased_vault_bump: u8, + pub collateral_vault_bump: u8, +} +``` + +### The short seller takes the offer - `take_lease` + +A short seller who has spotted the `Lease` account onchain (via an +indexer or a direct lookup) calls `take_lease` to take delivery. The +program deposits the short seller's collateral into `collateral_vault` +first - the vault was created empty by `create_lease` and this is +the call that fills it - then hands over the leased tokens. +Depositing collateral first means that if the leased-token payout +fails for any reason the whole transaction reverts and the short +seller gets their collateral back. The lease moves from `Listed` to +`Active`. + +- **Signers:** `short_seller` (the user wallet borrowing the tokens + and posting collateral). +- **Accounts:** + - `short_seller` (signer, mut) + - `holder` (UncheckedAccount - read for program-derived address seed derivation only, no signature required) + - `lease` (mut, `has_one = holder`, `has_one = leased_mint`, `has_one = collateral_mint`, must be `Listed`) + - `leased_mint`, `collateral_mint` + - `leased_vault`, `collateral_vault` (both mut, both program-derived addresses) + - `short_seller_collateral_account` (mut, short seller's associated token account for the collateral mint - source) + - `short_seller_leased_account` (mut, **init_if_needed** - short seller's associated token account for the leased mint, destination) + - `token_program`, `associated_token_program`, `system_program` +- **What happens:** + - Two token movements, in order: + 1. `required_collateral_amount` of the collateral mint moves + from `short_seller_collateral_account` into `collateral_vault`. + 2. `leased_amount` of the leased mint moves from `leased_vault` + to `short_seller_leased_account`. + - State changes on `lease`: + - `short_seller = short_seller.key()` + - `collateral_amount = required_collateral_amount` + - `start_timestamp = now` + - `end_timestamp = now + duration_seconds` (checked add) + - `last_paid_timestamp = now` (nothing has accrued yet) + - `status = Active` +- **Errors:** + - `InvalidLeaseStatus` if the lease is not `Listed` + - Anchor `has_one` mismatch errors if `holder`, `leased_mint`, or + `collateral_mint` do not match the values stored on the lease + - `MathOverflow` if `now + duration_seconds` overflows `i64` + +### The lease fee streams - `pay_lease_fee` + +The lease fee accrues second by second out of the collateral vault. +Anyone can call `pay_lease_fee` to settle whatever has accrued since +the last settlement: the short seller has the obvious incentive (keep +the position out of liquidation), and a keeper bot may push a payment +before checking margins so healthy leases stay healthy. The fee +formula is `(min(now, end_timestamp) - last_paid_timestamp) * +lease_fee_per_second`, capped at the collateral actually sitting in +the vault. Fees do not accrue past `end_timestamp` - once the +deadline hits, the short seller is either returning the tokens, +being liquidated, or defaulting; no further lease fees are owed. + +- **Signers:** `payer` (any user wallet - the short seller, a + keeper bot, or anyone else willing to pay the transaction fee). +- **Accounts:** + - `payer` (signer, mut - pays for `init_if_needed` of the holder associated token account) + - `holder` (UncheckedAccount, read-only - used for `has_one` check) + - `lease` (mut, must be `Active`) + - `collateral_mint`, `collateral_vault` + - `holder_collateral_account` (mut, **init_if_needed** - holder's [associated token account](https://solana.com/docs/terminology) for the collateral mint, destination for the lease fee) + - `token_program`, `associated_token_program`, `system_program` +- **What happens:** + - Compute `lease_fee_due = (min(now, end_timestamp) - last_paid_timestamp) * lease_fee_per_second`. + - Compute `payable = min(lease_fee_due, lease.collateral_amount)`. + - If `payable > 0`, transfer `payable` of the collateral mint from + `collateral_vault` to `holder_collateral_account`. + - State changes: `lease.collateral_amount -= payable`, + `lease.last_paid_timestamp = min(now, end_timestamp)`. + - If the vault did not have enough collateral to cover the full + `lease_fee_due`, the residual is silently left as a debt the next + `liquidate` or `close_expired` call cleans up. (See + [safety and edge cases](#safety-and-edge-cases) for + the rationale on this trade-off.) +- **Errors:** + - `InvalidLeaseStatus` if the lease is not `Active` + - `MathOverflow` if `elapsed * lease_fee_per_second` overflows `u64` + +### The short seller defends the position - `top_up_collateral` + +If the price moves against the short seller and the position drifts +toward the maintenance-margin floor, the short seller can add more +collateral to push the ratio back up. They call `top_up_collateral` +with an `amount` of the collateral mint, which the program transfers +straight into `collateral_vault` and adds to `lease.collateral_amount`. +The short seller can call this any number of times while the lease +is `Active`. + +- **Signers:** `short_seller`. +- **Parameter:** `amount: u64` - how much collateral to add. +- **Accounts:** + - `short_seller` (signer) + - `holder` (UncheckedAccount, read-only) + - `lease` (mut, `has_one = holder`, `has_one = collateral_mint`, must be `Active`, must be the same `short_seller`) + - `collateral_mint`, `collateral_vault` + - `short_seller_collateral_account` (mut, source) + - `token_program` +- **What happens:** + - Transfer `amount` of the collateral mint from + `short_seller_collateral_account` into `collateral_vault`. + - `lease.collateral_amount += amount` (checked add). +- **Errors:** + - `InvalidCollateralAmount` if `amount == 0` + - `Unauthorised` if `lease.short_seller != short_seller.key()` + - `InvalidLeaseStatus` if the lease is not `Active` + - `MathOverflow` if the addition overflows `u64` + +### The short seller closes - `return_lease` + +To close the position, the short seller buys back the leased tokens +on the open market and calls `return_lease`. The program runs the +full settlement in a single transaction: leased tokens move from the +short seller back to the holder, accrued lease fees move from the +collateral vault to the holder, the leftover collateral refunds to +the short seller, and both vaults plus the `Lease` account close. +The handler accepts a return at any time while `status == Active` - +returning before `end_timestamp` just means lease fees stop accruing +the moment the call lands; returning after `end_timestamp` does not +pile on extra charges because the fee formula already caps elapsed +time at `end_timestamp`. + +- **Signers:** `short_seller`. +- **Accounts:** + - `short_seller` (signer, mut) + - `holder` (UncheckedAccount, mut - receives `Lease` and vault rent-exempt lamports via `close = holder`) + - `lease` (mut, `close = holder`, must be `Active`, must be the same `short_seller`) + - `leased_mint`, `collateral_mint` + - `leased_vault`, `collateral_vault` (both mut) + - `short_seller_leased_account` (mut, source for the return) + - `short_seller_collateral_account` (mut, destination for the collateral refund) + - `holder_leased_account` (mut, **init_if_needed**) + - `holder_collateral_account` (mut, **init_if_needed**) + - `token_program`, `associated_token_program`, `system_program` +- **What happens:** + - Four token movements, in order: + 1. `leased_amount` of the leased mint moves from + `short_seller_leased_account` into `leased_vault`. + 2. The same `leased_amount` moves out of `leased_vault` into + `holder_leased_account`. The leased tokens hop through the + vault rather than going direct from short seller to holder so + the program can reuse the vault's program-derived-address + signing path; the atomic round-trip leaves the vault empty + and ready to close. + 3. `lease_fee_payable = min(lease_fee_due, lease.collateral_amount)` + of the collateral mint moves from `collateral_vault` to + `holder_collateral_account`. + 4. The remaining `lease.collateral_amount - lease_fee_payable` + refunds from `collateral_vault` to `short_seller_collateral_account`. + - Both vaults close via `close_account` [cross-program invocations](https://solana.com/docs/terminology); + their rent-exempt lamports go to the holder. The `Lease` account + closes via Anchor's `close = holder` constraint, with its + rent-exempt lamports going to the holder too. + - State changes before close: + `lease.last_paid_timestamp = min(now, end_timestamp)`, + `lease.collateral_amount = 0`, `lease.status = Closed`. +- **Errors:** + - `InvalidLeaseStatus` if the lease is not `Active` + - `Unauthorised` if `lease.short_seller != short_seller.key()` + - `MathOverflow` if the lease-fee or collateral subtraction overflows + +`return_lease` is the first place an account-close happens; the same +mechanism runs in `liquidate` and `close_expired`. The `Closed` and +`Liquidated` states are not directly observable onchain: all three +of `return_lease`, `liquidate` and `close_expired` close the `Lease` +account in the same transaction (`close = holder`), returning the +rent-exempt lamports to the holder. The in-memory `status` field is +set *before* the close so the transaction logs record the terminal +state, but the account disappears at the end. + +### Branch: position underwater - `liquidate` + +If the leased asset rallies far enough that the locked collateral is +no longer worth more than the debt times the maintenance margin, +anyone - typically a keeper bot - can call `liquidate` with a fresh +Pyth price update. The program decodes the update by hand +(production code would use `pyth-solana-receiver-sdk`; the LiteSVM +tests install a `PriceUpdateV2` account whose layout is parsed +inline), checks the position is genuinely underwater, settles the +accrued lease fee to the holder, pays the keeper a bounty out of +what remains, and sends the rest to the holder. The leased tokens +stay with the short seller - the collateral is the holder's +compensation for the lost asset. + +The underwater check, in integers, is: + +`collateral_value * 10_000 < debt_value * maintenance_margin_basis_points` + +where `debt_value = leased_amount * price * 10^exponent`, with the +Pyth exponent folded into whichever side of the inequality keeps the +math non-negative (see [`is_underwater`](programs/asset-leasing/src/instructions/liquidate.rs)). + +- **Signers:** `keeper` (any user wallet - typically a bot watching + for underwater positions; receives the bounty as payment for + cleaning up). +- **Accounts:** + - `keeper` (signer, mut - pays `init_if_needed` cost for both associated token accounts) + - `holder` (UncheckedAccount, mut - receives lease fee, holder share, and the rent-exempt lamports from the three closed accounts) + - `lease` (mut, `close = holder`, must be `Active`) + - `leased_mint`, `collateral_mint` + - `leased_vault`, `collateral_vault` (both mut) + - `holder_collateral_account` (mut, **init_if_needed**) + - `keeper_collateral_account` (mut, **init_if_needed** - keeper's [associated token account](https://solana.com/docs/terminology) for the collateral mint, destination for the bounty) + - `price_update` (UncheckedAccount, constrained to `owner = PYTH_RECEIVER_PROGRAM_ID`) - a `PriceUpdateV2` account owned by the Pyth Receiver program for the feed the lease was pinned to at creation. This is the first handler that requires the oracle account itself; `create_lease` only stores the `feed_id` it expects to see here. + - `token_program`, `associated_token_program`, `system_program` +- **What happens:** + - Decode `price_update`: discriminator must match + `PRICE_UPDATE_V2_DISCRIMINATOR`, account length โ‰ฅ 89 bytes, + `feed_id` must equal `lease.feed_id`, + `0 < now - publish_time <= 60 seconds`, `price > 0`. The + decoded `feed_id` check is the **feed-pinning** guard - without + it a keeper could pass any feed the Pyth Receiver program owns + (a wildly volatile pair that happens to be dipping, say) to + force a spurious liquidation. + - Confirm `is_underwater` returns true. + - Three collateral movements, in order: + 1. `lease_fee_payable = min(lease_fee_due, lease.collateral_amount)` + of the collateral mint moves from `collateral_vault` to + `holder_collateral_account`. + 2. `bounty = (remaining * liquidation_bounty_basis_points) / 10_000` + moves from `collateral_vault` to `keeper_collateral_account`, + where `remaining = lease.collateral_amount - lease_fee_payable`. + 3. `remaining - bounty` moves from `collateral_vault` to + `holder_collateral_account`. + - Both vaults close - `leased_vault` is already empty because the + short seller kept the leased tokens - and their rent-exempt + lamports go to the holder. The `Lease` account closes the same + way via Anchor's `close = holder`. + - State changes before close: + `lease.collateral_amount = 0`, + `lease.last_paid_timestamp = min(now, end_timestamp)`, + `lease.status = Liquidated`. +- **Errors:** + - `StalePrice` if the discriminator does not match, the account is + too short, `publish_time > now`, or `now - publish_time > 60` + - `PriceFeedMismatch` if `decoded.feed_id != lease.feed_id` + - `NonPositivePrice` if `price <= 0` + - `PositionHealthy` if the underwater check fails + - `InvalidLeaseStatus` if the lease is not `Active` + - `MathOverflow` on any of the integer-multiplication steps + +### Branch: cancel or default - `close_expired` + +The holder has a single recovery handler that covers two unrelated +situations: + +- The lease sat in `Listed` and the holder wants to cancel it - + no-one ever took the offer. Allowed any time. +- The lease was `Active`, `end_timestamp` has passed, and the short + seller never called `return_lease`. The holder takes the entire + collateral vault as compensation. + +In both cases the program drains whichever vault is non-empty, closes +both vaults, and closes the `Lease` account, with all three +rent-exempt-lamport refunds going to the holder. + +- **Signers:** `holder`. +- **Accounts:** + - `holder` (signer, mut - also the rent destination for all three closes) + - `lease` (mut, `close = holder`, status โˆˆ `{Listed, Active}`) + - `leased_mint`, `collateral_mint` + - `leased_vault`, `collateral_vault` (both mut) + - `holder_leased_account` (mut, **init_if_needed**) + - `holder_collateral_account` (mut, **init_if_needed**) + - `token_program`, `associated_token_program`, `system_program` +- **What happens:** + - On a `Listed` cancel: `leased_vault` holds `leased_amount` - + drain it back to `holder_leased_account`. `collateral_vault` is + empty, no transfer. + - On an `Active` default (after `end_timestamp`): + `leased_vault` is empty (the short seller kept the tokens), + `collateral_vault` holds `lease.collateral_amount` - drain all + of it to `holder_collateral_account`. + - Both vaults close; the `Lease` account closes via Anchor's + `close = holder`. + - State changes before close: + - On the `Active` branch only, + `lease.last_paid_timestamp = min(now, end_timestamp)` - settles + the timestamp invariant so a future program version that wants + to split the default pot differently (pro-rata lease fees, + partial refund) has a correct anchor to start from. + - `lease.collateral_amount = 0` + - `lease.status = Closed` +- **Errors:** + - `InvalidLeaseStatus` if `status` is not `Listed` or `Active` + - `LeaseNotExpired` if `status == Active` and `now < end_timestamp` + +### Branch scenarios + +The handlers above cover the happy path. The branch scenarios below +walk the same machinery through liquidation, a falling-price profit, +and the two `close_expired` situations using concrete numbers that +match the LiteSVM tests one-to-one. All four scenarios share the +same starting parameters; both mints are 6-decimal tokens, so 1 token += 1 000 000 base units. "Leased units" means base units of the leased +mint and "collateral units" means base units of the collateral mint - +descriptive labels, not real tickers. + +Shared starting parameters: + +- `leased_amount = 100_000_000` (100 leased tokens) +- `required_collateral_amount = 200_000_000` (200 collateral tokens) +- `lease_fee_per_second = 10` collateral units +- `duration_seconds = 86_400` (24 hours) +- `maintenance_margin_basis_points = 12_000` (120%) +- `liquidation_bounty_basis_points = 500` (5% of post-lease-fee collateral) +- `feed_id = [0xAB; 32]` (arbitrary, consistent across all calls) + +The holder starts with 1 000 000 000 leased units; the short seller +starts with 1 000 000 000 collateral units. Each scenario opens with +`create_lease` and (where relevant) `take_lease` running as described +in [the holder lists the tokens - `create_lease`](#the-holder-lists-the-tokens---create_lease) and [the short seller takes the offer - `take_lease`](#the-short-seller-takes-the-offer---take_lease). Lease fees use the formula in [the lease fee streams - `pay_lease_fee`](#the-lease-fee-streams---pay_lease_fee). + +#### Liquidation - leased asset rallies + +`create_lease` and `take_lease` run as standard, leaving +`collateral_vault = 200_000_000`, `leased_vault = 0`, and the short +seller holding 100 leased tokens. Time jumps to `T + 300`. + +A keeper observes a fresh Pyth price update: the leased-in-collateral +price has spiked to 4.0 (exponent = 0, raw price = 4). Debt value is +`100_000_000 ร— 4 = 400_000_000` collateral units against a collateral +pot of ~200 000 000 - maintenance ratio is `200/400 = 50%`, far below +the required 120%. The keeper does not need to call `pay_lease_fee` +first; `liquidate` settles accrued fees itself. + +The keeper calls `liquidate` (mechanics in [branch: position underwater - `liquidate`](#branch-position-underwater---liquidate)). At `T + 300`: + +- Accrued lease fee: `300 ร— 10 = 3_000` collateral units. The vault + has 200 000 000, so `lease_fee_payable = 3_000` flows to the holder. +- Remaining: `200_000_000 โˆ’ 3_000 = 199_997_000` collateral units. +- Bounty: `199_997_000 ร— 500 / 10_000 = 9_999_850` collateral units to + the keeper. +- Holder share: `199_997_000 โˆ’ 9_999_850 = 189_997_150` collateral + units to the holder. +- Both vaults close, the `Lease` account closes; status recorded as + `Liquidated`. + +Final balances: + +- **Holder:** 900 000 000 leased units (the 100 never came back - the + short seller kept them), `3_000 + 189_997_150 = 190_000_150` + collateral units, plus rent-exempt lamports from three closes. +- **Short seller:** still holds 100 000 000 leased units, lost the + full 200 000 000 collateral. +- **Keeper:** 9 999 850 collateral units. + +The asymmetry to remember: liquidation does *not* reclaim the leased +tokens. The collateral pays the holder for the lost asset; the short +seller has effectively bought the leased tokens at the forfeit price. + +#### Falling price - short seller profits + +`create_lease` and `take_lease` run as standard. Time jumps to +`T + 300`. The leased-in-collateral price has fallen sharply: take +exponent = โˆ’1, raw price = 5, so debt value is +`100_000_000 ร— 5 / 10 = 50_000_000` collateral units. The collateral +pot is ~200 000 000 - maintenance ratio is `200_000_000 / 50_000_000 += 400%`, far above the required 120%. A keeper calling `liquidate` +here would fail with `PositionHealthy`; the program refuses to seize +a healthy position. + +At `T + 600` (10 minutes in) the short seller buys 100 leased tokens +on the open market at the new price (about 50 collateral tokens +total - far less than the 200 they posted) and calls `return_lease` +(mechanics in [the short seller closes - `return_lease`](#the-short-seller-closes---return_lease)). Accrued lease fees are `600 ร— 10 = 6_000` +collateral units. The settlement: + +- 100 000 000 leased units flow short seller โ†’ leased vault โ†’ holder. +- 6 000 collateral units flow from the collateral vault to the holder. +- The remaining `200_000_000 โˆ’ 6_000 = 199_994_000` collateral units + refund to the short seller. +- Both vaults close, the `Lease` account closes. + +Final balances: + +- **Holder:** 1 000 000 000 leased units (full return), 6 000 + collateral units in lease fees. +- **Short seller:** received 100 leased tokens, sold them at the + original price, bought 100 leased tokens back at the lower price, + returned them. Net cost is the lending fee (6 000 collateral units) + plus open-market trading costs; gain is the difference between the + original sale price and the buy-back price. The standard short + payoff. + +The short seller can defend a borderline position with +`top_up_collateral` ([the short seller defends the position - `top_up_collateral`](#the-short-seller-defends-the-position---top_up_collateral)) or close it early via `return_lease` +([the short seller closes - `return_lease`](#the-short-seller-closes---return_lease)). Only adverse price moves trigger liquidation. + +#### Default - `close_expired` on an `Active` lease + +`create_lease` and `take_lease` run as standard. The short seller +takes the tokens, posts collateral, then disappears. `pay_lease_fee` +is never called. The clock advances past +`end_timestamp = T + 86_400`. + +At `T + 100_000` the holder calls `close_expired` (mechanics in +[branch: cancel or default - `close_expired`](#branch-cancel-or-default---close_expired)). Because `status == Active` and `now >= end_timestamp`, the +default branch runs: + +- `leased_vault` is empty (the short seller kept the tokens) - no + transfer. +- `collateral_vault` holds 200 000 000 collateral units; all of it + flows to `holder_collateral_account`. +- Both vaults close, the `Lease` account closes; + `last_paid_timestamp` settles at `end_timestamp`. + +Final balances: + +- **Holder:** 900 000 000 leased units, 200 000 000 collateral units + (the entire collateral pot as compensation), plus three + account-close refunds. +- **Short seller:** 100 000 000 leased units, paid the full + collateral and kept the leased tokens. + +#### Cancel - `close_expired` on a `Listed` lease + +The cheap cancel path. `create_lease` runs; no short seller ever +calls `take_lease`. The holder calls `close_expired` immediately +(mechanics in [branch: cancel or default - `close_expired`](#branch-cancel-or-default---close_expired)). Because `status == Listed`, no expiry check +applies: + +- `leased_vault` holds 100 000 000 leased units; all of it drains + back to `holder_leased_account`. +- `collateral_vault` is empty - no transfer. +- Both vaults close, the `Lease` account closes. + +Final balances: the holder is back to 1 000 000 000 leased units; +nothing else moved. + +--- + +## Safety and edge cases + +### What the program refuses to do + +All of the following come from [`errors.rs`](programs/asset-leasing/src/errors.rs) +and are enforced by either an Anchor constraint or a `require!` in the +handler: + +- **`InvalidLeaseStatus`** - action tried against a lease in the wrong state (e.g. `take_lease` on a lease that is already `Active`). +- **`InvalidDuration`** - `duration_seconds <= 0` on `create_lease`. +- **`InvalidLeasedAmount`** - `leased_amount == 0` on `create_lease`. +- **`InvalidCollateralAmount`** - `required_collateral_amount == 0` on `create_lease`; `amount == 0` on `top_up_collateral`. +- **`InvalidLeaseFeePerSecond`** - `lease_fee_per_second == 0` on `create_lease`. +- **`InvalidMaintenanceMargin`** - `maintenance_margin_basis_points == 0` or `> 50_000` on `create_lease`. +- **`InvalidLiquidationBounty`** - `liquidation_bounty_basis_points > 2_000` on `create_lease`. +- **`LeaseExpired`** - reserved; not currently used (lease fee accrual naturally caps at `end_timestamp`). +- **`LeaseNotExpired`** - `close_expired` called on an `Active` lease before `end_timestamp`. +- **`PositionHealthy`** - `liquidate` called on a lease that passes the maintenance-margin check. +- **`StalePrice`** - Pyth price update older than 60 s, or has a future `publish_time`, or fails discriminator / length check. +- **`NonPositivePrice`** - Pyth price is `<= 0`. +- **`MathOverflow`** - any of the `checked_*` arithmetic returned `None`. +- **`Unauthorised`** - lease-modifying handler called by someone who is not the registered short seller (`top_up_collateral`, `return_lease`). +- **`LeasedMintEqualsCollateralMint`** - `create_lease` called with the same mint for both sides. +- **`PriceFeedMismatch`** - `liquidate` called with a Pyth update whose `feed_id` does not match `lease.feed_id`. + +### Guarded design choices worth knowing + +- **Leased tokens are locked up-front.** `create_lease` moves the tokens + into the `leased_vault` immediately, so a short seller calling + `take_lease` cannot fail because the holder spent the funds + elsewhere in the meantime. + +- **Leased mint โ‰  collateral mint.** If both sides used the same + mint, the two vaults would hold the same asset and the + "what-do-I-owe-vs-what-do-I-hold" accounting would collapse. The + guard is cheap and the error message is explicit. + +- **Feed pinning.** The Pyth `feed_id` is stored on the `Lease` at + creation and enforced on every `liquidate`. A keeper cannot pass in a + random unrelated price feed (like a volatile pair that happens to be + dipping) to force a spurious liquidation. + +- **Staleness window.** Pyth `publish_time` older than 60 seconds is + rejected, and `publish_time > now` is rejected too (keepers must not + front-run the validator clock). + +- **Integer-only math.** Every percentage and price calculation folds + into a `checked_mul` / `checked_div` of `u128` - no floats, no + surprising NaN. `BASIS_POINTS_DENOMINATOR = 10 000` is the only + "percentage denominator" anywhere; cross-check against `constants.rs` + if you're porting the math. + +- **Authority-is-self vaults.** `leased_vault.authority == + leased_vault.key()` (and likewise for `collateral_vault`). The + program signs as the vault using its own seeds, which means the + `Lease` account is not involved in signing any of the token moves. + Authority-is-self keeps the signer-seed array small (one seed list, + not two). + +- **Max maintenance margin = 500%.** Without an upper bound a holder + could set a margin that is unreachable on day one and liquidate the + short seller instantly. 50 000 basis points is generous - enough + for truly speculative leases - while still blocking the pathological + 10 000ร— trap. + +- **Max liquidation bounty = 20%.** Higher than 20% and the keeper's + cut would dwarf the holder's recovery on default. The cap keeps + liquidation economics roughly in line with holder-first semantics. + +### Things the program does *not* guard against + +A production version of the program would want more: + +- **Price feed correctness.** The program verifies the owner + (`PYTH_RECEIVER_PROGRAM_ID`), the discriminator, the layout and the + feed id, but the program cannot know whether the feed the holder + pinned quotes the right pair. Supplying the wrong feed at creation + is the holder's problem - the wrong feed won't cause a liquidation + to succeed against a truly healthy position (the feed id check + would fail), but it will mean *no* liquidation can succeed, so a + short seller could drain the collateral via lease fees and walk + away. A production version would cross-check the price feed's + `feed_id` against a program-maintained registry. + +- **Lease-fee dust accumulation.** Lease fees are paid in whole base + units per second of `lease_fee_per_second`. Choose a small + `lease_fee_per_second` and short-lived leases can settle 0 lease + fees if no-one calls `pay_lease_fee` for a very short period. Not a + security issue - the accrual timestamp only moves forward when the + lease fee is actually settled - but worth knowing. + +- **Griefing on `init_if_needed`.** `take_lease`, `pay_lease_fee`, + `liquidate`, `return_lease` and `close_expired` all do + `init_if_needed` on one or more associated token accounts. If the + caller does not fund the rent-exempt reserve for those accounts, + the transaction fails. This is the intended behaviour (the caller + pays for the state they require) but can surprise a short seller + on a tight SOL budget. + +- **No partial lease-fee refund on default.** When `close_expired` + runs on an `Active` lease, the holder takes the entire collateral + regardless of how many lease fees had actually accrued by then. + This is a deliberate simplification - the `last_paid_timestamp` + bookkeeping is in place precisely so a future version can split the + pot correctly. + +- **No pause / upgrade authority.** The program has no admin and no + upgrade-authority-bound feature flags. The program runs or it doesn't. + +--- + +## Running the tests + +All the tests are LiteSVM-based Rust integration tests under +[`programs/asset-leasing/tests/`](programs/asset-leasing/tests/). They +exercise every instruction handler through `include_bytes!("../../../target/deploy/asset_leasing.so")`, +so a fresh build must produce the `.so` first. + +### Prerequisites + +- Anchor 1.0.0 (`anchor --version`) +- Solana CLI (`solana -V`) +- Rust stable (the `rust-toolchain.toml` at the repo root pins the + compiler) + +### Commands + +From this directory (`defi/asset-leasing/anchor/`): + +```bash +# 1. Build the BPF .so - writes to target/deploy/asset_leasing.so +anchor build + +# 2. Run the LiteSVM tests (just cargo under the hood; `anchor test` +# also works because Anchor.toml scripts.test = "cargo test") +cargo test --manifest-path programs/asset-leasing/Cargo.toml + +# Or, equivalently: +anchor test --skip-local-validator +``` + +Expected output: + +``` +running 11 tests +test close_expired_cancels_listed_lease ... ok +test close_expired_reclaims_collateral_after_end_timestamp ... ok +test create_lease_locks_tokens_and_lists ... ok +test create_lease_rejects_same_mint_for_leased_and_collateral ... ok +test liquidate_rejects_healthy_position ... ok +test liquidate_rejects_mismatched_price_feed ... ok +test liquidate_seizes_collateral_on_price_drop ... ok +test pay_lease_fee_streams_collateral_by_elapsed_time ... ok +test return_lease_refunds_unused_collateral ... ok +test take_lease_posts_collateral_and_delivers_tokens ... ok +test top_up_collateral_increases_vault_balance ... ok +``` + +### What each test exercises + +- **`create_lease_locks_tokens_and_lists`** - holder funds vault, `Lease` created, collateral vault empty. +- **`create_lease_rejects_same_mint_for_leased_and_collateral`** - guard against `leased_mint == collateral_mint`. +- **`take_lease_posts_collateral_and_delivers_tokens`** - collateral deposit + leased-token payout in one instruction. +- **`pay_lease_fee_streams_collateral_by_elapsed_time`** - lease fee math: `elapsed * lease_fee_per_second`, lease fee transferred to holder. +- **`top_up_collateral_increases_vault_balance`** - collateral balance after `top_up` equals deposit + top-up. +- **`return_lease_refunds_unused_collateral`** - happy path round-trip; leased tokens returned, residual collateral refunded, accounts closed. +- **`liquidate_seizes_collateral_on_price_drop`** - price-induced underwater position; lease fee + bounty + holder share paid, accounts closed. +- **`liquidate_rejects_healthy_position`** - program refuses to liquidate a position that passes the margin check. +- **`liquidate_rejects_mismatched_price_feed`** - program refuses a `PriceUpdateV2` whose `feed_id` does not match `lease.feed_id`. +- **`close_expired_reclaims_collateral_after_end_timestamp`** - default path; holder seizes the collateral. +- **`close_expired_cancels_listed_lease`** - holder-initiated cancel of an unrented lease. + +### Note on CI + +The repo's `.github/workflows/anchor.yml` runs `anchor build` before +`anchor test` for every changed anchor project. That's important for +this project: the Rust integration tests include the BPF artefact via +`include_bytes!`, so a stale or missing `.so` would break the tests. +CI is already covered. + +--- + +## Quasar port + +A parallel implementation of the same program using +[Quasar](https://github.com/blueshift-gg/quasar) lives in +[`../quasar/`](../quasar/). Quasar is a lightweight alternative to +Anchor that compiles to bare Solana program binaries without pulling in +`anchor-lang` - useful when you care about compute-unit budget, binary +size, or simply want fewer layers between your code and the runtime. + +The port implements the same seven instruction handlers, the same +`Lease` state account, the same program-derived address seed +conventions, and produces the same onchain behaviour for every +happy-path and adversarial test in this README. + +### Building and testing + +From [`../quasar/`](../quasar/): + +```bash +# Build the .so using the quasar CLI. +quasar build + +# Run the LiteSVM-style tests directly with cargo. The tests call the +# compiled program from `target/deploy/quasar_asset_leasing.so`. +cargo test +``` + +The Quasar example in this repo's CI workflow +(`.github/workflows/quasar.yml`) runs exactly those two commands. + +### What differs from the Anchor version + +- **No Anchor account-validation macros.** In Quasar, account structs + use `#[derive(Accounts)]` with an almost-identical attribute + vocabulary (`seeds`, `bump`, `has_one`, `constraint`, + `init_if_needed`) but the checks are lowered to plain Rust, not + inserted by a procedural macro that calls into a support crate. + +- **Explicit instruction discriminators.** Each instruction handler + carries `#[instruction(discriminator = N)]` with `N` an explicit + integer - Quasar uses one-byte discriminators by default rather than + Anchor's 8-byte sha256 prefix. The wire format for every call is + `[discriminator: u8][borsh-serialised args]`. + +- **Tests talk to `QuasarSvm` directly.** Instead of the Anchor + `Instruction { ... }.data()` / `accounts::Foo { ... }.to_account_metas()` + helpers, the Quasar tests build each `Instruction` by hand with + `solana_instruction::AccountMeta` entries and a manually-assembled + byte payload. Account state is pre-populated on the SVM with + `QuasarSvm::new().with_program(...).with_token_program()` and + helpers from `quasar_svm::token` that synthesise `Mint` and + `TokenAccount` bytes without running the real token-program + initialisation instruction handlers. This keeps the tests fast but + means the setup code is more explicit. + +- **No cross-program-invocation into an associated-token-account + program for associated token account creation.** The Anchor version + uses `init_if_needed` + `associated_token::...` to let callers pass + in a holder/short-seller wallet and get the token account created + on demand. The Quasar port accepts pre-created token accounts for + the user side of every flow, since doing `init_if_needed` correctly + for associated token accounts in Quasar requires wiring in the + associated token account program manually and adds noise that + distracts from the lease mechanics. Production code would want the + associated token account convenience back. + +- **Classic Token only, not Token-2022.** The Anchor version declares + its token accounts as `InterfaceAccount` + `token_program: + Interface`, which accepts mints owned by either the + classic Token program or the Token-2022 program. The Quasar port + uses `Account` + `Program`, matching the simpler + pattern used by the other Quasar examples in this repo. Adding + Token-2022 support is a type-parameter swap away. + +- **State layout is the same, byte for byte.** The `Lease` discriminator + and field order match the Anchor version, so an offchain indexer + that already decodes Anchor `Lease` accounts would also decode the + Quasar ones after adjusting for the one-byte discriminator. + +- **One lease per holder at a time.** The Anchor version keys its + `Lease` program-derived address on `[LEASE_SEED, holder, lease_id]` + so one holder can run many leases in parallel. Quasar's `seeds = [...]` + macro embeds raw references into generated code and does not (yet) + have a borrow-safe way to splice instruction args like + `lease_id.to_le_bytes()` into the seed list, so the Quasar port + keys its program-derived address on `[LEASE_SEED, holder]` alone - + one active lease per holder. The `lease_id` is still stored on the + `Lease` account for book-keeping and is a caller-supplied u64 in + `create_lease`; the offchain client just has to ensure the previous + lease from the same holder is `Closed` or `Liquidated` (i.e. its + program-derived address account is gone) before creating a new one. + Swapping in a multi-lease seed is a mechanical change once Quasar + grows support for dynamic-byte seeds. + +The code layout mirrors this directory: `src/lib.rs` registers the +entrypoint and re-exports handlers, `src/state.rs` defines `Lease` and +`LeaseStatus`, and `src/instructions/*.rs` contains one file per +handler. Tests are in `src/tests.rs`. + +--- + +## Extending the program + +Directions a real-world version of the program would consider, +grouped by effort: + +### Easy + +- **Add a `lease_view` read-only helper.** An offchain indexer-style + struct that returns `{ collateral_value, debt_value, ratio_basis_points, + is_underwater }` given the same inputs `is_underwater` uses. Useful + for UIs that want to show "you are 15% away from liquidation". + +- **Cap lease fees at collateral.** Currently `pay_lease_fee` pays + `min(lease_fee_due, collateral_amount)` and silently leaves a debt. + Add an explicit `LeaseFeeDebtOutstanding` error so the caller is + warned when the stream has stalled, rather than inferring it from a + non-zero `lease_fee_due` after settlement. + +### Moderate + +- **Partial-refund default.** In `close_expired` on `Active`, instead + of giving the holder the entire collateral, split it: `lease_fee_due` + to the holder, the rest stays with the short seller up to some + `default_haircut_basis_points`. `last_paid_timestamp` is already + bumped after a default close, so the timestamp invariants are ready. + +- **Multiple outstanding leases per `(holder, short_seller)` pair with + the same mint pair.** Already supported via `lease_id`, but add an + instruction-level index account that lists open lease ids for a + given holder so offchain tools don't have to `getProgramAccounts` + scan. + +- **Quote asset โ‰  collateral mint.** Rent and liquidation math assume + debt is priced in *collateral units*. Generalise to a third "quote" + mint by taking the price pair at creation and carrying a + `quote_mint` pubkey on `Lease`. Requires updates to + `is_underwater` and a second oracle feed. + +### Harder + +- **Keeper auction.** Replace the fixed `liquidation_bounty_basis_points` with a + Dutch auction that grows the bounty linearly over some window after + the position first becomes underwater. Keeps liquidators honest on + tight feeds and gives short sellers a chance to `top_up_collateral` + before a keeper has an economic reason to move. + +- **Flash liquidation.** Let the keeper settle the debt in the same + transaction as the liquidation - borrow the leased amount from a + separate liquidity pool, hand it to the holder, take the full + collateral, repay the pool, keep the spread. Requires integrating a + second program. + +- **Token-2022 support.** The program already uses the `TokenInterface` + trait so it accepts mints owned by either the classic Token program + or the Token-2022 program. A real extension would test against + Token-2022 mint extensions (transfer-fee, interest-bearing) and + document which are compatible with the lease-fee / collateral flows. + +--- + +## Code layout + +``` +defi/asset-leasing/anchor/ +โ”œโ”€โ”€ Anchor.toml +โ”œโ”€โ”€ Cargo.toml +โ”œโ”€โ”€ README.md (this file) +โ””โ”€โ”€ programs/asset-leasing/ + โ”œโ”€โ”€ Cargo.toml + โ”œโ”€โ”€ src/ + โ”‚ โ”œโ”€โ”€ constants.rs program-derived address seeds, basis points limits, Pyth age cap + โ”‚ โ”œโ”€โ”€ errors.rs + โ”‚ โ”œโ”€โ”€ lib.rs #[program] entry points + โ”‚ โ”œโ”€โ”€ instructions/ + โ”‚ โ”‚ โ”œโ”€โ”€ mod.rs + โ”‚ โ”‚ โ”œโ”€โ”€ shared.rs transfer / close helpers + โ”‚ โ”‚ โ”œโ”€โ”€ create_lease.rs + โ”‚ โ”‚ โ”œโ”€โ”€ take_lease.rs + โ”‚ โ”‚ โ”œโ”€โ”€ pay_lease_fee.rs + โ”‚ โ”‚ โ”œโ”€โ”€ top_up_collateral.rs + โ”‚ โ”‚ โ”œโ”€โ”€ return_lease.rs + โ”‚ โ”‚ โ”œโ”€โ”€ liquidate.rs + โ”‚ โ”‚ โ””โ”€โ”€ close_expired.rs + โ”‚ โ””โ”€โ”€ state/ + โ”‚ โ”œโ”€โ”€ mod.rs + โ”‚ โ””โ”€โ”€ lease.rs + โ””โ”€โ”€ tests/ + โ””โ”€โ”€ test_asset_leasing.rs LiteSVM tests +``` diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml b/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml new file mode 100644 index 000000000..465fd8ed2 --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/Cargo.toml @@ -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"))'] } diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs new file mode 100644 index 000000000..6d8f54e60 --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/constants.rs @@ -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; diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs new file mode 100644 index 000000000..149b6acef --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs @@ -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, +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs new file mode 100644 index 000000000..954cdbee9 --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/close_expired.rs @@ -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>, + pub collateral_mint: Box>, + + #[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>, + + #[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>, + + #[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>, + + #[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>, + + 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) -> 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(()) +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs new file mode 100644 index 000000000..3ba7947e0 --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs @@ -0,0 +1,151 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::{ + constants::{ + COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED, MAX_LIQUIDATION_BOUNTY_BASIS_POINTS, + MAX_MAINTENANCE_MARGIN_BASIS_POINTS, + }, + errors::AssetLeasingError, + instructions::shared::transfer_tokens_from_user, + state::{Lease, LeaseStatus}, +}; + +#[derive(Accounts)] +#[instruction(lease_id: u64)] +pub struct CreateLease<'info> { + #[account(mut)] + pub holder: Signer<'info>, + + #[account(mint::token_program = token_program)] + pub leased_mint: InterfaceAccount<'info, Mint>, + + #[account(mint::token_program = token_program)] + pub collateral_mint: InterfaceAccount<'info, Mint>, + + #[account( + mut, + associated_token::mint = leased_mint, + associated_token::authority = holder, + associated_token::token_program = token_program, + )] + pub holder_leased_account: Box>, + + #[account( + init, + payer = holder, + space = Lease::DISCRIMINATOR.len() + Lease::INIT_SPACE, + seeds = [LEASE_SEED, holder.key().as_ref(), &lease_id.to_le_bytes()], + bump, + )] + pub lease: Account<'info, Lease>, + + /// program-derived address-owned vault holding the leased tokens while `Listed`. Authority is + /// the vault program-derived address itself so the lease account does not need to sign for + /// returns / liquidation; any handler just signs with the vault seeds. + #[account( + init, + payer = holder, + seeds = [LEASED_VAULT_SEED, lease.key().as_ref()], + bump, + token::mint = leased_mint, + token::authority = leased_vault, + token::token_program = token_program, + )] + pub leased_vault: Box>, + + #[account( + init, + payer = holder, + seeds = [COLLATERAL_VAULT_SEED, lease.key().as_ref()], + bump, + token::mint = collateral_mint, + token::authority = collateral_vault, + token::token_program = token_program, + )] + pub collateral_vault: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +#[allow(clippy::too_many_arguments)] +pub fn handle_create_lease( + context: Context, + lease_id: u64, + leased_amount: u64, + required_collateral_amount: u64, + lease_fee_per_second: u64, + duration_seconds: i64, + maintenance_margin_basis_points: u16, + liquidation_bounty_basis_points: u16, + feed_id: [u8; 32], +) -> Result<()> { + // Reject leased_mint == collateral_mint. Allowing both to be the same + // mint would collapse the two vaults' seed derivations into one shared + // token-balance pool, making lease-fee-vs-collateral accounting ambiguous and + // enabling griefing paths where the short_seller's "collateral" is the same + // asset they already hold as the lease principal. + require!( + context.accounts.leased_mint.key() != context.accounts.collateral_mint.key(), + AssetLeasingError::LeasedMintEqualsCollateralMint + ); + + require!(leased_amount > 0, AssetLeasingError::InvalidLeasedAmount); + require!( + required_collateral_amount > 0, + AssetLeasingError::InvalidCollateralAmount + ); + require!(lease_fee_per_second > 0, AssetLeasingError::InvalidLeaseFeePerSecond); + require!(duration_seconds > 0, AssetLeasingError::InvalidDuration); + require!( + maintenance_margin_basis_points > 0 && maintenance_margin_basis_points <= MAX_MAINTENANCE_MARGIN_BASIS_POINTS, + AssetLeasingError::InvalidMaintenanceMargin + ); + require!( + liquidation_bounty_basis_points <= MAX_LIQUIDATION_BOUNTY_BASIS_POINTS, + AssetLeasingError::InvalidLiquidationBounty + ); + + // Lock the leased tokens into the program-owned vault up-front. Doing this + // here (not on take_lease) guarantees a short_seller can never accept a lease + // the holder no longer has the funds to deliver. + transfer_tokens_from_user( + &context.accounts.holder_leased_account, + &context.accounts.leased_vault, + leased_amount, + &context.accounts.leased_mint, + &context.accounts.holder, + &context.accounts.token_program, + )?; + + let lease = &mut context.accounts.lease; + lease.set_inner(Lease { + lease_id, + holder: context.accounts.holder.key(), + // No short_seller yet โ€” will be populated by take_lease. + short_seller: Pubkey::default(), + leased_mint: context.accounts.leased_mint.key(), + leased_amount, + collateral_mint: context.accounts.collateral_mint.key(), + // No collateral yet โ€” posted on take_lease. + collateral_amount: 0, + required_collateral_amount, + lease_fee_per_second, + duration_seconds, + // start_timestamp / end_timestamp / last_paid_timestamp are set when the lease + // activates in `take_lease`. + start_timestamp: 0, + end_timestamp: 0, + last_paid_timestamp: 0, + maintenance_margin_basis_points, + liquidation_bounty_basis_points, + feed_id, + status: LeaseStatus::Listed, + bump: context.bumps.lease, + leased_vault_bump: context.bumps.leased_vault, + collateral_vault_bump: context.bumps.collateral_vault, + }); + + Ok(()) +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs new file mode 100644 index 000000000..1a06259db --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs @@ -0,0 +1,323 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::{ + constants::{ + BASIS_POINTS_DENOMINATOR, COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED, + PYTH_MAX_AGE_SECONDS, + }, + errors::AssetLeasingError, + instructions::{ + pay_lease_fee::compute_lease_fee_due, + shared::{close_vault, transfer_tokens_from_vault}, + }, + state::{Lease, LeaseStatus}, +}; + +/// Pyth Solana Receiver program ID on mainnet (also used on devnet by the +/// canonical Pyth integrations). Declared here as a string so the tests can +/// mint mock `PriceUpdateV2` accounts owned by the same id. +pub const PYTH_RECEIVER_PROGRAM_ID: Pubkey = + anchor_lang::pubkey!("rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ"); + +/// Anchor discriminator for `PriceUpdateV2`. Equal to the first 8 bytes of +/// `sha256("account:PriceUpdateV2")`. Hard-coded because we parse the account +/// by hand rather than pulling in `pyth-solana-receiver-sdk` (see Cargo.toml). +pub const PRICE_UPDATE_V2_DISCRIMINATOR: [u8; 8] = [34, 241, 35, 99, 157, 126, 244, 205]; + +#[derive(Accounts)] +pub struct Liquidate<'info> { + /// Keeper who calls the instruction โ€” they receive the bounty. + #[account(mut)] + pub keeper: Signer<'info>, + + /// CHECK: program-derived address seed + lease-fee / collateral destination. + #[account(mut)] + pub holder: UncheckedAccount<'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 = lease.status == LeaseStatus::Active @ AssetLeasingError::InvalidLeaseStatus, + close = holder, + )] + pub lease: Account<'info, Lease>, + + pub leased_mint: Box>, + pub collateral_mint: Box>, + + #[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>, + + #[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>, + + #[account( + init_if_needed, + payer = keeper, + associated_token::mint = collateral_mint, + associated_token::authority = holder, + associated_token::token_program = token_program, + )] + pub holder_collateral_account: Box>, + + #[account( + init_if_needed, + payer = keeper, + associated_token::mint = collateral_mint, + associated_token::authority = keeper, + associated_token::token_program = token_program, + )] + pub keeper_collateral_account: Box>, + + /// CHECK: We verify the account is owned by the Pyth Receiver program and + /// carries the expected `PriceUpdateV2` discriminator before decoding. + /// The price feed must quote *one leased token in collateral units* โ€” + /// keepers are responsible for supplying an appropriate feed, the program + /// cannot know which pair is correct for a given lease. + #[account(owner = PYTH_RECEIVER_PROGRAM_ID)] + pub price_update: UncheckedAccount<'info>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +/// Minimal projection of `PriceUpdateV2` โ€” only the fields we actually need. +/// Layout: `[discriminator(8) | write_authority(32) | verification_level(1) | +/// feed_id(32) | price(i64) | conf(u64) | exponent(i32) | publish_time(i64) | +/// prev_publish_time(i64) | ema_price(i64) | ema_conf(u64) | posted_slot(u64)]`. +pub struct DecodedPriceUpdate { + pub feed_id: [u8; 32], + pub price: i64, + pub exponent: i32, + pub publish_time: i64, +} + +pub fn decode_price_update(data: &[u8]) -> Result { + // Discriminator (8) + write_authority (32) + verification_level (1) = 41. + const FEED_ID_OFFSET: usize = 41; + // feed_id (32) starts at 41, price i64 at 41 + 32 = 73. + const PRICE_OFFSET: usize = FEED_ID_OFFSET + 32; + const EXPONENT_OFFSET: usize = PRICE_OFFSET + 8 + 8; // price + conf + const PUBLISH_TIME_OFFSET: usize = EXPONENT_OFFSET + 4; // exponent + const MIN_LEN: usize = PUBLISH_TIME_OFFSET + 8; + + require!(data.len() >= MIN_LEN, AssetLeasingError::StalePrice); + require!( + data[..8] == PRICE_UPDATE_V2_DISCRIMINATOR, + AssetLeasingError::StalePrice + ); + + let mut feed_id = [0u8; 32]; + feed_id.copy_from_slice(&data[FEED_ID_OFFSET..FEED_ID_OFFSET + 32]); + + let price = i64::from_le_bytes(data[PRICE_OFFSET..PRICE_OFFSET + 8].try_into().unwrap()); + let exponent = i32::from_le_bytes( + data[EXPONENT_OFFSET..EXPONENT_OFFSET + 4] + .try_into() + .unwrap(), + ); + let publish_time = i64::from_le_bytes( + data[PUBLISH_TIME_OFFSET..PUBLISH_TIME_OFFSET + 8] + .try_into() + .unwrap(), + ); + + Ok(DecodedPriceUpdate { + feed_id, + price, + exponent, + publish_time, + }) +} + +pub fn handle_liquidate(context: Context) -> Result<()> { + let now = Clock::get()?.unix_timestamp; + let price_data = context.accounts.price_update.try_borrow_data()?; + let decoded = decode_price_update(&price_data)?; + drop(price_data); + + // Feed pinning: reject any `PriceUpdateV2` whose feed_id does not match + // the one the holder committed to at `create_lease`. Without this guard, + // a keeper could pass in any feed the Pyth Receiver program owns โ€” e.g. + // a wildly volatile pair that dips enough to flag the position as + // underwater โ€” and trigger a spurious liquidation. + require!( + decoded.feed_id == context.accounts.lease.feed_id, + AssetLeasingError::PriceFeedMismatch + ); + + require!( + is_underwater(&context.accounts.lease, &decoded, now)?, + AssetLeasingError::PositionHealthy + ); + + // Settle accrued lease fees first (up to end_timestamp) so the holder is paid for the + // time the short_seller actually used. Only then slice off bounty + remainder. + let lease_fee_due = compute_lease_fee_due(&context.accounts.lease, now)?; + let lease_fee_payable = lease_fee_due.min(context.accounts.lease.collateral_amount); + + let lease_key = context.accounts.lease.key(); + 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), + ]; + 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), + ]; + + if lease_fee_payable > 0 { + transfer_tokens_from_vault( + &context.accounts.collateral_vault, + &context.accounts.holder_collateral_account, + lease_fee_payable, + &context.accounts.collateral_mint, + &context.accounts.collateral_vault.to_account_info(), + &context.accounts.token_program, + &[collateral_vault_seeds], + )?; + } + + let remaining = context + .accounts + .lease + .collateral_amount + .checked_sub(lease_fee_payable) + .ok_or(AssetLeasingError::MathOverflow)?; + + // Bounty is a percentage of the collateral *after* lease fees โ€” guarantees we + // never try to pay out more than what actually sits in the vault. + let bounty = (remaining as u128) + .checked_mul(context.accounts.lease.liquidation_bounty_basis_points as u128) + .ok_or(AssetLeasingError::MathOverflow)? + .checked_div(BASIS_POINTS_DENOMINATOR as u128) + .ok_or(AssetLeasingError::MathOverflow)? as u64; + + if bounty > 0 { + transfer_tokens_from_vault( + &context.accounts.collateral_vault, + &context.accounts.keeper_collateral_account, + bounty, + &context.accounts.collateral_mint, + &context.accounts.collateral_vault.to_account_info(), + &context.accounts.token_program, + &[collateral_vault_seeds], + )?; + } + + let holder_share = remaining + .checked_sub(bounty) + .ok_or(AssetLeasingError::MathOverflow)?; + if holder_share > 0 { + transfer_tokens_from_vault( + &context.accounts.collateral_vault, + &context.accounts.holder_collateral_account, + holder_share, + &context.accounts.collateral_mint, + &context.accounts.collateral_vault.to_account_info(), + &context.accounts.token_program, + &[collateral_vault_seeds], + )?; + } + + // The leased vault is empty (short_seller kept the tokens on default) but was + // rent-exempt funded at creation. Close both vaults so the holder recoups + // the rent-exempt lamports. + 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], + )?; + + context.accounts.lease.collateral_amount = 0; + context.accounts.lease.last_paid_timestamp = now.min(context.accounts.lease.end_timestamp); + context.accounts.lease.status = LeaseStatus::Liquidated; + + Ok(()) +} + +/// Liquidatable when collateral value < debt value * maintenance margin. +/// All math stays in integers by folding the Pyth exponent into whichever +/// side of the inequality does not already have a power of ten applied. +pub fn is_underwater(lease: &Lease, price: &DecodedPriceUpdate, now: i64) -> Result { + // Staleness guard. `publish_time` coming from the future is treated as + // stale too โ€” the keeper must not front-run the clock. + require!(price.publish_time <= now, AssetLeasingError::StalePrice); + let age = (now - price.publish_time) as u64; + require!(age <= PYTH_MAX_AGE_SECONDS, AssetLeasingError::StalePrice); + + require!(price.price > 0, AssetLeasingError::NonPositivePrice); + let price_raw = price.price as u128; + + let leased_amount = lease.leased_amount as u128; + let collateral_amount = lease.collateral_amount as u128; + let margin_basis_points = lease.maintenance_margin_basis_points as u128; + let denominator = BASIS_POINTS_DENOMINATOR as u128; + + let (collateral_scaled, debt_scaled) = if price.exponent >= 0 { + let scale = ten_pow(price.exponent as u32)?; + let debt = leased_amount + .checked_mul(price_raw) + .and_then(|product| product.checked_mul(scale)) + .ok_or(AssetLeasingError::MathOverflow)?; + (collateral_amount, debt) + } else { + let scale = ten_pow((-price.exponent) as u32)?; + let collateral = collateral_amount + .checked_mul(scale) + .ok_or(AssetLeasingError::MathOverflow)?; + let debt = leased_amount + .checked_mul(price_raw) + .ok_or(AssetLeasingError::MathOverflow)?; + (collateral, debt) + }; + + let lhs = collateral_scaled + .checked_mul(denominator) + .ok_or(AssetLeasingError::MathOverflow)?; + let rhs = debt_scaled + .checked_mul(margin_basis_points) + .ok_or(AssetLeasingError::MathOverflow)?; + + Ok(lhs < rhs) +} + +fn ten_pow(exponent: u32) -> Result { + 10u128 + .checked_pow(exponent) + .ok_or(AssetLeasingError::MathOverflow.into()) +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/mod.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/mod.rs new file mode 100644 index 000000000..39033e435 --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/mod.rs @@ -0,0 +1,17 @@ +pub mod close_expired; +pub mod create_lease; +pub mod liquidate; +pub mod pay_lease_fee; +pub mod return_lease; +pub mod shared; +pub mod take_lease; +pub mod top_up_collateral; + +pub use close_expired::*; +pub use create_lease::*; +pub use liquidate::*; +pub use pay_lease_fee::*; +pub use return_lease::*; +pub use shared::*; +pub use take_lease::*; +pub use top_up_collateral::*; diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_lease_fee.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_lease_fee.rs new file mode 100644 index 000000000..4c576b56f --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/pay_lease_fee.rs @@ -0,0 +1,135 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASE_SEED}, + errors::AssetLeasingError, + instructions::shared::transfer_tokens_from_vault, + state::{Lease, LeaseStatus}, +}; + +#[derive(Accounts)] +pub struct PayLeaseFee<'info> { + /// Anyone may settle the lease fee โ€” the short_seller has every incentive to keep the + /// lease current, but a keeper bot could also push a lease fee payment before a + /// liquidation check so healthy leases stay healthy. + #[account(mut)] + pub payer: Signer<'info>, + + /// CHECK: Referenced only for program-derived address derivation + has_one check on `lease`. + pub holder: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [LEASE_SEED, holder.key().as_ref(), &lease.lease_id.to_le_bytes()], + bump = lease.bump, + has_one = holder, + has_one = collateral_mint, + constraint = lease.status == LeaseStatus::Active @ AssetLeasingError::InvalidLeaseStatus, + )] + pub lease: Account<'info, Lease>, + + pub collateral_mint: InterfaceAccount<'info, Mint>, + + #[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>, + + /// Holder's collateral-mint associated token account, created on demand so the holder does not + /// need to pre-fund it with the lease fee. + #[account( + init_if_needed, + payer = payer, + associated_token::mint = collateral_mint, + associated_token::authority = holder, + associated_token::token_program = token_program, + )] + pub holder_collateral_account: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +pub fn handle_pay_lease_fee(context: Context) -> Result<()> { + let now = Clock::get()?.unix_timestamp; + + let lease_fee_amount = compute_lease_fee_due(&context.accounts.lease, now)?; + + // No time has passed (or already capped at end_timestamp). Nothing to do. + if lease_fee_amount == 0 { + update_last_paid_timestamp(&mut context.accounts.lease, now); + return Ok(()); + } + + // Cap lease fees at whatever collateral actually sits in the vault. If the + // short_seller under-collateralised we cannot magically create funds; the + // remainder is their debt and can trigger liquidation. + let payable = lease_fee_amount.min(context.accounts.collateral_amount_available()); + + if payable > 0 { + let lease_key = context.accounts.lease.key(); + 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), + ]; + let signer_seeds = [collateral_vault_seeds]; + + transfer_tokens_from_vault( + &context.accounts.collateral_vault, + &context.accounts.holder_collateral_account, + payable, + &context.accounts.collateral_mint, + &context.accounts.collateral_vault.to_account_info(), + &context.accounts.token_program, + &signer_seeds, + )?; + + context.accounts.lease.collateral_amount = context + .accounts + .lease + .collateral_amount + .checked_sub(payable) + .ok_or(AssetLeasingError::MathOverflow)?; + } + + update_last_paid_timestamp(&mut context.accounts.lease, now); + Ok(()) +} + +/// Lease fee accrues linearly: `(min(now, end_timestamp) - last_paid_timestamp) * rate`. +/// Extracted so it can be re-used by `return_lease` and `liquidate` for a +/// final settlement before closing the lease. +pub fn compute_lease_fee_due(lease: &Lease, now: i64) -> Result { + let cutoff = now.min(lease.end_timestamp); + if cutoff <= lease.last_paid_timestamp { + return Ok(0); + } + let elapsed = (cutoff - lease.last_paid_timestamp) as u64; + elapsed + .checked_mul(lease.lease_fee_per_second) + .ok_or(AssetLeasingError::MathOverflow.into()) +} + +/// Advance `last_paid_timestamp` but never past the lease end โ€” after end_timestamp +/// the lease is settled and extra Lease fees do not accrue. +pub fn update_last_paid_timestamp(lease: &mut Lease, now: i64) { + lease.last_paid_timestamp = now.min(lease.end_timestamp); +} + +impl<'info> PayLeaseFee<'info> { + fn collateral_amount_available(&self) -> u64 { + self.lease.collateral_amount + } +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs new file mode 100644 index 000000000..736397c4d --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/return_lease.rs @@ -0,0 +1,202 @@ +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::{compute_lease_fee_due, update_last_paid_timestamp}, + shared::{close_vault, transfer_tokens_from_user, transfer_tokens_from_vault}, + }, + state::{Lease, LeaseStatus}, +}; + +#[derive(Accounts)] +pub struct ReturnLease<'info> { + #[account(mut)] + pub short_seller: Signer<'info>, + + /// CHECK: Reference only โ€” receives the lease fee + closed-vault rent-exempt-lamport refund. + #[account(mut)] + pub holder: UncheckedAccount<'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 = lease.short_seller == short_seller.key() @ AssetLeasingError::Unauthorised, + constraint = lease.status == LeaseStatus::Active @ AssetLeasingError::InvalidLeaseStatus, + close = holder, + )] + pub lease: Account<'info, Lease>, + + pub leased_mint: Box>, + pub collateral_mint: Box>, + + /// Leased tokens flow back into this vault from the short_seller, then out to + /// the holder in the same instruction. Closed at the end to reclaim rent-exempt lamports. + #[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>, + + #[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>, + + #[account( + mut, + associated_token::mint = leased_mint, + associated_token::authority = short_seller, + associated_token::token_program = token_program, + )] + pub short_seller_leased_account: Box>, + + #[account( + mut, + associated_token::mint = collateral_mint, + associated_token::authority = short_seller, + associated_token::token_program = token_program, + )] + pub short_seller_collateral_account: Box>, + + /// Holder's leased-mint associated token account, created on demand. They may have sent the + /// original tokens from a different account. + #[account( + init_if_needed, + payer = short_seller, + associated_token::mint = leased_mint, + associated_token::authority = holder, + associated_token::token_program = token_program, + )] + pub holder_leased_account: Box>, + + #[account( + init_if_needed, + payer = short_seller, + associated_token::mint = collateral_mint, + associated_token::authority = holder, + associated_token::token_program = token_program, + )] + pub holder_collateral_account: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +pub fn handle_return_lease(context: Context) -> Result<()> { + let now = Clock::get()?.unix_timestamp; + let lease_key = context.accounts.lease.key(); + + // 1. ShortSeller returns leased tokens to the leased vault (full amount). + let leased_amount = context.accounts.lease.leased_amount; + transfer_tokens_from_user( + &context.accounts.short_seller_leased_account, + &context.accounts.leased_vault, + leased_amount, + &context.accounts.leased_mint, + &context.accounts.short_seller, + &context.accounts.token_program, + )?; + + // 2. Forward leased tokens from the vault to the holder. + 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), + ]; + transfer_tokens_from_vault( + &context.accounts.leased_vault, + &context.accounts.holder_leased_account, + leased_amount, + &context.accounts.leased_mint, + &context.accounts.leased_vault.to_account_info(), + &context.accounts.token_program, + &[leased_vault_seeds], + )?; + + // 3. Settle accrued lease fees: collateral vault -> holder. + let lease_fee_due = compute_lease_fee_due(&context.accounts.lease, now)?; + let lease_fee_payable = lease_fee_due.min(context.accounts.lease.collateral_amount); + + 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), + ]; + + if lease_fee_payable > 0 { + transfer_tokens_from_vault( + &context.accounts.collateral_vault, + &context.accounts.holder_collateral_account, + lease_fee_payable, + &context.accounts.collateral_mint, + &context.accounts.collateral_vault.to_account_info(), + &context.accounts.token_program, + &[collateral_vault_seeds], + )?; + } + + // 4. Refund remaining collateral to the short_seller. Returning early does not + // entitle the short_seller to a future-lease-fee refund โ€” Lease fees only accrue for time + // actually used, so `compute_lease_fee_due` already excludes the unused tail. + let collateral_after_lease_fees = context + .accounts + .lease + .collateral_amount + .checked_sub(lease_fee_payable) + .ok_or(AssetLeasingError::MathOverflow)?; + + if collateral_after_lease_fees > 0 { + transfer_tokens_from_vault( + &context.accounts.collateral_vault, + &context.accounts.short_seller_collateral_account, + collateral_after_lease_fees, + &context.accounts.collateral_mint, + &context.accounts.collateral_vault.to_account_info(), + &context.accounts.token_program, + &[collateral_vault_seeds], + )?; + } + + // 5. Close both vaults so the rent-exempt lamports come back to the + // holder โ€” the short_seller only pays for the temporary state they held. + 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], + )?; + + update_last_paid_timestamp(&mut context.accounts.lease, now); + context.accounts.lease.collateral_amount = 0; + context.accounts.lease.status = LeaseStatus::Closed; + + Ok(()) +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs new file mode 100644 index 000000000..a1f94c28c --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/shared.rs @@ -0,0 +1,77 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + close_account, transfer_checked, CloseAccount, Mint, TokenAccount, TokenInterface, + TransferChecked, +}; + +/// Transfer tokens from a user-controlled account to a program-controlled +/// vault (or any other account the signer owns). Authority is a plain signer. +pub fn transfer_tokens_from_user<'info>( + from: &InterfaceAccount<'info, TokenAccount>, + to: &InterfaceAccount<'info, TokenAccount>, + amount: u64, + mint: &InterfaceAccount<'info, Mint>, + authority: &Signer<'info>, + token_program: &Interface<'info, TokenInterface>, +) -> Result<()> { + let accounts = TransferChecked { + from: from.to_account_info(), + mint: mint.to_account_info(), + to: to.to_account_info(), + authority: authority.to_account_info(), + }; + transfer_checked( + CpiContext::new(token_program.key(), accounts), + amount, + mint.decimals, + ) +} + +/// Transfer tokens out of a program-derived address-owned vault using the supplied signer seeds. +/// Used by the program when moving tokens held under its authority. +pub fn transfer_tokens_from_vault<'info>( + from: &InterfaceAccount<'info, TokenAccount>, + to: &InterfaceAccount<'info, TokenAccount>, + amount: u64, + mint: &InterfaceAccount<'info, Mint>, + authority: &AccountInfo<'info>, + token_program: &Interface<'info, TokenInterface>, + signer_seeds: &[&[&[u8]]], +) -> Result<()> { + let accounts = TransferChecked { + from: from.to_account_info(), + mint: mint.to_account_info(), + to: to.to_account_info(), + authority: authority.clone(), + }; + transfer_checked( + CpiContext::new_with_signer(token_program.key(), accounts, signer_seeds), + amount, + mint.decimals, + ) +} + +/// Close a program-derived address-owned token vault and forward its rent-exempt lamports to +/// `destination`. The vault is its own token-account authority, so the caller +/// just passes the same vault `AccountInfo` as both the account and the +/// authority, with the vault's signer seeds for the cross-program invocation. +/// +/// `destination` is an `AccountInfo` so callers can pass whichever wrapper +/// they hold (`Signer`, `UncheckedAccount`, etc.) via `.to_account_info()`. +pub fn close_vault<'info>( + vault: &InterfaceAccount<'info, TokenAccount>, + destination: &AccountInfo<'info>, + token_program: &Interface<'info, TokenInterface>, + signer_seeds: &[&[&[u8]]], +) -> Result<()> { + let accounts = CloseAccount { + account: vault.to_account_info(), + destination: destination.clone(), + authority: vault.to_account_info(), + }; + close_account(CpiContext::new_with_signer( + token_program.key(), + accounts, + signer_seeds, + )) +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs new file mode 100644 index 000000000..d604327fd --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/take_lease.rs @@ -0,0 +1,134 @@ +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::shared::{transfer_tokens_from_user, transfer_tokens_from_vault}, + state::{Lease, LeaseStatus}, +}; + +#[derive(Accounts)] +pub struct TakeLease<'info> { + #[account(mut)] + pub short_seller: Signer<'info>, + + /// CHECK: Only used as a reference for the program-derived address seeds; no data accessed. + pub holder: UncheckedAccount<'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 = lease.status == LeaseStatus::Listed @ AssetLeasingError::InvalidLeaseStatus, + )] + pub lease: Account<'info, Lease>, + + pub leased_mint: Box>, + pub collateral_mint: Box>, + + #[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>, + + #[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>, + + /// ShortSeller's existing collateral account โ€” they must already hold the + /// required collateral before calling. + #[account( + mut, + associated_token::mint = collateral_mint, + associated_token::authority = short_seller, + associated_token::token_program = token_program, + )] + pub short_seller_collateral_account: Box>, + + /// ShortSeller's associated token account for the leased mint. Created on-demand if missing so the + /// UI only has to hand over a short_seller keypair plus the two mints. + #[account( + init_if_needed, + payer = short_seller, + associated_token::mint = leased_mint, + associated_token::authority = short_seller, + associated_token::token_program = token_program, + )] + pub short_seller_leased_account: Box>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +pub fn handle_take_lease(context: Context) -> Result<()> { + let now = Clock::get()?.unix_timestamp; + + // Bindings for values we still need after `&mut lease` is borrowed. + let required_collateral_amount = context.accounts.lease.required_collateral_amount; + let leased_amount = context.accounts.lease.leased_amount; + let duration_seconds = context.accounts.lease.duration_seconds; + + // ShortSeller deposits collateral first so a failed leased-token transfer + // rolls back their deposit atomically. + transfer_tokens_from_user( + &context.accounts.short_seller_collateral_account, + &context.accounts.collateral_vault, + required_collateral_amount, + &context.accounts.collateral_mint, + &context.accounts.short_seller, + &context.accounts.token_program, + )?; + + // Pay out leased tokens from the vault program-derived address. + let lease_key = context.accounts.lease.key(); + 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 signer_seeds = [leased_vault_seeds]; + + transfer_tokens_from_vault( + &context.accounts.leased_vault, + &context.accounts.short_seller_leased_account, + leased_amount, + &context.accounts.leased_mint, + &context.accounts.leased_vault.to_account_info(), + &context.accounts.token_program, + &signer_seeds, + )?; + + let end_timestamp = now + .checked_add(duration_seconds) + .ok_or(AssetLeasingError::MathOverflow)?; + + let lease = &mut context.accounts.lease; + lease.short_seller = context.accounts.short_seller.key(); + lease.collateral_amount = required_collateral_amount; + lease.start_timestamp = now; + lease.end_timestamp = end_timestamp; + lease.last_paid_timestamp = now; + lease.status = LeaseStatus::Active; + + Ok(()) +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/top_up_collateral.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/top_up_collateral.rs new file mode 100644 index 000000000..86eb4af33 --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/top_up_collateral.rs @@ -0,0 +1,73 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; + +use crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASE_SEED}, + errors::AssetLeasingError, + instructions::shared::transfer_tokens_from_user, + state::{Lease, LeaseStatus}, +}; + +#[derive(Accounts)] +pub struct TopUpCollateral<'info> { + #[account(mut)] + pub short_seller: Signer<'info>, + + /// CHECK: program-derived address seed reference; no reads. + pub holder: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [LEASE_SEED, holder.key().as_ref(), &lease.lease_id.to_le_bytes()], + bump = lease.bump, + has_one = holder, + has_one = collateral_mint, + constraint = lease.short_seller == short_seller.key() @ AssetLeasingError::Unauthorised, + constraint = lease.status == LeaseStatus::Active @ AssetLeasingError::InvalidLeaseStatus, + )] + pub lease: Account<'info, Lease>, + + pub collateral_mint: InterfaceAccount<'info, Mint>, + + #[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>, + + #[account( + mut, + associated_token::mint = collateral_mint, + associated_token::authority = short_seller, + associated_token::token_program = token_program, + )] + pub short_seller_collateral_account: Box>, + + pub token_program: Interface<'info, TokenInterface>, +} + +pub fn handle_top_up_collateral(context: Context, amount: u64) -> Result<()> { + require!(amount > 0, AssetLeasingError::InvalidCollateralAmount); + + transfer_tokens_from_user( + &context.accounts.short_seller_collateral_account, + &context.accounts.collateral_vault, + amount, + &context.accounts.collateral_mint, + &context.accounts.short_seller, + &context.accounts.token_program, + )?; + + context.accounts.lease.collateral_amount = context + .accounts + .lease + .collateral_amount + .checked_add(amount) + .ok_or(AssetLeasingError::MathOverflow)?; + + Ok(()) +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs new file mode 100644 index 000000000..7aa24fe40 --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs @@ -0,0 +1,80 @@ +pub mod constants; +pub mod errors; +pub mod instructions; +pub mod state; + +use anchor_lang::prelude::*; + +pub use constants::*; +pub use instructions::*; +pub use state::*; + +declare_id!("HHKEhLk6dyzG4mK1isPyZiHcEMW4J1CRKryzyQ3JFtnF"); + +#[program] +pub mod asset_leasing { + use super::*; + + /// Holder lists a lease: deposits leased tokens into the leased vault and + /// publishes the rental terms. The lease sits in `Listed` until a short_seller + /// takes it. + pub fn create_lease( + context: Context, + lease_id: u64, + leased_amount: u64, + required_collateral_amount: u64, + lease_fee_per_second: u64, + duration_seconds: i64, + maintenance_margin_basis_points: u16, + liquidation_bounty_basis_points: u16, + feed_id: [u8; 32], + ) -> Result<()> { + instructions::create_lease::handle_create_lease( + context, + lease_id, + leased_amount, + required_collateral_amount, + lease_fee_per_second, + duration_seconds, + maintenance_margin_basis_points, + liquidation_bounty_basis_points, + feed_id, + ) + } + + /// ShortSeller takes the lease: posts collateral into the collateral vault and + /// receives the leased tokens. Lease transitions to `Active`. + pub fn take_lease(context: Context) -> Result<()> { + instructions::take_lease::handle_take_lease(context) + } + + /// Stream the lease fee from the collateral vault to the holder, up to `end_timestamp`. + /// Anyone may call this to keep the lease current. + pub fn pay_lease_fee(context: Context) -> Result<()> { + instructions::pay_lease_fee::handle_pay_lease_fee(context) + } + + /// ShortSeller adds more collateral to stay above the maintenance margin. + pub fn top_up_collateral(context: Context, amount: u64) -> Result<()> { + instructions::top_up_collateral::handle_top_up_collateral(context, amount) + } + + /// ShortSeller returns the leased tokens (at or before `end_timestamp`). Accrued lease fees + /// is settled and the remaining collateral is refunded. + pub fn return_lease(context: Context) -> Result<()> { + instructions::return_lease::handle_return_lease(context) + } + + /// Keeper liquidates an undercollateralised lease using a Pyth price + /// update. Collateral goes to the holder, minus the keeper bounty. + pub fn liquidate(context: Context) -> Result<()> { + instructions::liquidate::handle_liquidate(context) + } + + /// After `end_timestamp`, if the short_seller never returned the tokens, the holder + /// reclaims the collateral as compensation and closes the lease. Also + /// used by the holder to cancel an unrented (`Listed`) lease. + pub fn close_expired(context: Context) -> Result<()> { + instructions::close_expired::handle_close_expired(context) + } +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs new file mode 100644 index 000000000..1409de744 --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs @@ -0,0 +1,75 @@ +use anchor_lang::prelude::*; + +/// Lifecycle of a `Lease`. Transitions: +/// Listed --take_lease--> Active +/// Active --return_lease--> Closed +/// Active --liquidate--> Liquidated +/// Listed --close_expired--> Closed (holder cancels unrented lease) +/// Active --close_expired--> Closed (after end_timestamp, defaulted short_seller) +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, Debug, InitSpace)] +pub enum LeaseStatus { + Listed, + Active, + Liquidated, + Closed, +} + +#[account] +#[derive(InitSpace)] +pub struct Lease { + /// Caller-supplied id so one holder can run many leases in parallel. The + /// program-derived address is seeded by (LEASE_SEED, holder, lease_id). + pub lease_id: u64, + /// Account that listed the lease and receives the lease fee. Always set. + pub holder: Pubkey, + /// Account that took the lease. `Pubkey::default()` while `Listed`. + pub short_seller: Pubkey, + + /// Mint of the tokens being leased out. + pub leased_mint: Pubkey, + /// Amount of leased tokens locked at creation. Used for repayment checks. + pub leased_amount: u64, + + /// Mint of the collateral posted by the short_seller. + pub collateral_mint: Pubkey, + /// Collateral the short_seller posted (increases on top-up). Decreases as the lease fee + /// is streamed out of the collateral vault. + pub collateral_amount: u64, + /// Collateral the short_seller must deposit up-front when taking the lease. + pub required_collateral_amount: u64, + + /// Lease fee charged per second, denominated in collateral tokens and paid + /// from the collateral vault to the holder on each `pay_lease_fee`. + pub lease_fee_per_second: u64, + /// Length of the lease, in seconds. Set at creation, used to compute + /// `end_timestamp` when the lease activates. + pub duration_seconds: i64, + /// Unix timestamp when the lease becomes active (set on `take_lease`). + pub start_timestamp: i64, + /// Unix timestamp after which the lease expires. 0 while `Listed`. + pub end_timestamp: i64, + /// Last time the lease fee was settled. Lease fee accrues from here to `now.min(end_timestamp)`. + pub last_paid_timestamp: i64, + + /// Required collateral value as a percentage of the leased value, + /// expressed in basis points. 12_000 basis points = 120%. + pub maintenance_margin_basis_points: u16, + /// Share of the seized collateral paid to the keeper that liquidates the + /// lease, expressed in basis points of `collateral_amount`. + pub liquidation_bounty_basis_points: u16, + + /// Pyth `PriceUpdateV2.feed_id` that this lease is pinned to. The + /// liquidation handler refuses price updates whose on-account `feed_id` + /// does not match this value, so a keeper cannot swap in an unrelated + /// feed (e.g. a cheaper or more volatile pair) to force a liquidation. + /// Chosen by the holder at `create_lease`. + pub feed_id: [u8; 32], + + /// Current lifecycle state. + pub status: LeaseStatus, + + /// Bump seeds โ€” stored so cross-program invocations can sign without re-deriving. + pub bump: u8, + pub leased_vault_bump: u8, + pub collateral_vault_bump: u8, +} diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/src/state/mod.rs b/defi/asset-leasing/anchor/programs/asset-leasing/src/state/mod.rs new file mode 100644 index 000000000..d7ad671aa --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/src/state/mod.rs @@ -0,0 +1,2 @@ +pub mod lease; +pub use lease::*; diff --git a/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs b/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs new file mode 100644 index 000000000..aab2aaa02 --- /dev/null +++ b/defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs @@ -0,0 +1,1022 @@ +//! LiteSVM tests for the asset-leasing program. +//! +//! Covers the full lifecycle: listing, taking, lease fee streaming, top-ups, +//! early return, keeper liquidation via a mocked Pyth `PriceUpdateV2` +//! account, and holder-initiated default recovery after expiry. + +use { + anchor_lang::{ + solana_program::{instruction::Instruction, pubkey::Pubkey, system_program}, + InstructionData, ToAccountMetas, + }, + anchor_lang::solana_program::clock::Clock, + litesvm::LiteSVM, + solana_keypair::Keypair, + solana_kite::{ + create_associated_token_account, create_token_mint, create_wallet, + get_token_account_balance, mint_tokens_to_token_account, + send_transaction_from_instructions, + }, + solana_signer::Signer, +}; + +// Keep test-side seeds in sync with `programs/asset-leasing/src/constants.rs`. +// Duplicated rather than imported so tests stay self-contained. +const LEASE_SEED: &[u8] = b"lease"; +const LEASED_VAULT_SEED: &[u8] = b"leased_vault"; +const COLLATERAL_VAULT_SEED: &[u8] = b"collateral_vault"; + +// Pyth Receiver program id โ€” matches `PYTH_RECEIVER_PROGRAM_ID` in the +// program. Kept as a &str so we can parse it once at the top of liquidation +// tests without pulling in extra crate types. +const PYTH_RECEIVER_PROGRAM_ID_STR: &str = "rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ"; + +// Matches `PRICE_UPDATE_V2_DISCRIMINATOR` in liquidate.rs โ€” sha256 of +// "account:PriceUpdateV2" taken from the Pyth receiver IDL. +const PRICE_UPDATE_V2_DISCRIMINATOR: [u8; 8] = [34, 241, 35, 99, 157, 126, 244, 205]; + +fn token_program_id() -> Pubkey { + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + .parse() + .unwrap() +} + +fn associated_token_account_program_id() -> Pubkey { + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + .parse() + .unwrap() +} + +fn derive_associated_token_account(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { + let (associated_token_account, _bump) = Pubkey::find_program_address( + &[wallet.as_ref(), token_program_id().as_ref(), mint.as_ref()], + &associated_token_account_program_id(), + ); + associated_token_account +} + +fn lease_program_derived_addresses(program_id: &Pubkey, holder: &Pubkey, lease_id: u64) -> (Pubkey, Pubkey, Pubkey) { + let (lease, _) = Pubkey::find_program_address( + &[LEASE_SEED, holder.as_ref(), &lease_id.to_le_bytes()], + program_id, + ); + let (leased_vault, _) = + Pubkey::find_program_address(&[LEASED_VAULT_SEED, lease.as_ref()], program_id); + let (collateral_vault, _) = + Pubkey::find_program_address(&[COLLATERAL_VAULT_SEED, lease.as_ref()], program_id); + (lease, leased_vault, collateral_vault) +} + +struct Scenario { + svm: LiteSVM, + program_id: Pubkey, + // `payer` funds the mint authority + associated token account creations during setup but is + // not used directly by the tests afterwards. + #[allow(dead_code)] + payer: Keypair, + holder: Keypair, + short_seller: Keypair, + keeper: Keypair, + leased_mint: Pubkey, + collateral_mint: Pubkey, + holder_leased_associated_token_account: Pubkey, + short_seller_collateral_associated_token_account: Pubkey, +} + +fn full_setup() -> Scenario { + let program_id = asset_leasing::id(); + let mut svm = LiteSVM::new(); + let program_bytes = include_bytes!("../../../target/deploy/asset_leasing.so"); + svm.add_program(program_id, program_bytes).unwrap(); + + let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); + let holder = create_wallet(&mut svm, 10_000_000_000).unwrap(); + let short_seller = create_wallet(&mut svm, 10_000_000_000).unwrap(); + let keeper = create_wallet(&mut svm, 10_000_000_000).unwrap(); + + // 6 decimals matches USDC and keeps test arithmetic readable. + let decimals = 6u8; + let leased_mint = create_token_mint(&mut svm, &payer, decimals, None).unwrap(); + let collateral_mint = create_token_mint(&mut svm, &payer, decimals, None).unwrap(); + + let holder_leased_associated_token_account = + create_associated_token_account(&mut svm, &holder.pubkey(), &leased_mint, &payer).unwrap(); + mint_tokens_to_token_account( + &mut svm, + &leased_mint, + &holder_leased_associated_token_account, + 1_000_000_000, + &payer, + ) + .unwrap(); + + let short_seller_collateral_associated_token_account = + create_associated_token_account(&mut svm, &short_seller.pubkey(), &collateral_mint, &payer) + .unwrap(); + mint_tokens_to_token_account( + &mut svm, + &collateral_mint, + &short_seller_collateral_associated_token_account, + 1_000_000_000, + &payer, + ) + .unwrap(); + + // Anchor macros init the Lease + vault accounts โ€” LiteSVM's default clock + // is epoch 0 which makes the first `take_lease` have start_timestamp=0 and look + // identical to a Listed lease. Advance once so lease fee math has signal. + advance_clock_to(&mut svm, 1_700_000_000); + + Scenario { + svm, + program_id, + payer, + holder, + short_seller, + keeper, + leased_mint, + collateral_mint, + holder_leased_associated_token_account, + short_seller_collateral_associated_token_account, + } +} + +fn advance_clock_to(svm: &mut LiteSVM, unix_timestamp: i64) { + let mut clock = svm.get_sysvar::(); + clock.unix_timestamp = unix_timestamp; + svm.set_sysvar::(&clock); +} + +fn advance_clock_by(svm: &mut LiteSVM, delta_seconds: i64) { + let mut clock = svm.get_sysvar::(); + clock.unix_timestamp += delta_seconds; + svm.set_sysvar::(&clock); +} + +fn current_clock(svm: &LiteSVM) -> i64 { + svm.get_sysvar::().unix_timestamp +} + +// --------------------------------------------------------------------------- +// Instruction builders +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn build_create_lease_instruction( + scenario: &Scenario, + lease_id: u64, + leased_amount: u64, + required_collateral_amount: u64, + lease_fee_per_second: u64, + duration_seconds: i64, + maintenance_margin_basis_points: u16, + liquidation_bounty_basis_points: u16, + feed_id: [u8; 32], +) -> Instruction { + let (lease, leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + Instruction::new_with_bytes( + scenario.program_id, + &asset_leasing::instruction::CreateLease { + lease_id, + leased_amount, + required_collateral_amount, + lease_fee_per_second, + duration_seconds, + maintenance_margin_basis_points, + liquidation_bounty_basis_points, + feed_id, + } + .data(), + asset_leasing::accounts::CreateLease { + holder: scenario.holder.pubkey(), + leased_mint: scenario.leased_mint, + collateral_mint: scenario.collateral_mint, + holder_leased_account: scenario.holder_leased_associated_token_account, + lease, + leased_vault, + collateral_vault, + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +fn build_take_lease_instruction(scenario: &Scenario, lease_id: u64) -> Instruction { + let (lease, leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + let short_seller_leased_associated_token_account = derive_associated_token_account(&scenario.short_seller.pubkey(), &scenario.leased_mint); + Instruction::new_with_bytes( + scenario.program_id, + &asset_leasing::instruction::TakeLease {}.data(), + asset_leasing::accounts::TakeLease { + short_seller: scenario.short_seller.pubkey(), + holder: scenario.holder.pubkey(), + lease, + leased_mint: scenario.leased_mint, + collateral_mint: scenario.collateral_mint, + leased_vault, + collateral_vault, + short_seller_collateral_account: scenario.short_seller_collateral_associated_token_account, + short_seller_leased_account: short_seller_leased_associated_token_account, + token_program: token_program_id(), + associated_token_program: associated_token_account_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +fn build_pay_lease_fee_instruction(scenario: &Scenario, lease_id: u64) -> Instruction { + let (lease, _leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + let holder_collateral_associated_token_account = derive_associated_token_account(&scenario.holder.pubkey(), &scenario.collateral_mint); + Instruction::new_with_bytes( + scenario.program_id, + &asset_leasing::instruction::PayLeaseFee {}.data(), + asset_leasing::accounts::PayLeaseFee { + payer: scenario.short_seller.pubkey(), + holder: scenario.holder.pubkey(), + lease, + collateral_mint: scenario.collateral_mint, + collateral_vault, + holder_collateral_account: holder_collateral_associated_token_account, + token_program: token_program_id(), + associated_token_program: associated_token_account_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +fn build_top_up_instruction(scenario: &Scenario, lease_id: u64, amount: u64) -> Instruction { + let (lease, _leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + Instruction::new_with_bytes( + scenario.program_id, + &asset_leasing::instruction::TopUpCollateral { amount }.data(), + asset_leasing::accounts::TopUpCollateral { + short_seller: scenario.short_seller.pubkey(), + holder: scenario.holder.pubkey(), + lease, + collateral_mint: scenario.collateral_mint, + collateral_vault, + short_seller_collateral_account: scenario.short_seller_collateral_associated_token_account, + token_program: token_program_id(), + } + .to_account_metas(None), + ) +} + +fn build_return_lease_instruction(scenario: &Scenario, lease_id: u64) -> Instruction { + let (lease, leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + let short_seller_leased_associated_token_account = derive_associated_token_account(&scenario.short_seller.pubkey(), &scenario.leased_mint); + let holder_collateral_associated_token_account = derive_associated_token_account(&scenario.holder.pubkey(), &scenario.collateral_mint); + Instruction::new_with_bytes( + scenario.program_id, + &asset_leasing::instruction::ReturnLease {}.data(), + asset_leasing::accounts::ReturnLease { + short_seller: scenario.short_seller.pubkey(), + holder: scenario.holder.pubkey(), + lease, + leased_mint: scenario.leased_mint, + collateral_mint: scenario.collateral_mint, + leased_vault, + collateral_vault, + short_seller_leased_account: short_seller_leased_associated_token_account, + short_seller_collateral_account: scenario.short_seller_collateral_associated_token_account, + holder_leased_account: scenario.holder_leased_associated_token_account, + holder_collateral_account: holder_collateral_associated_token_account, + token_program: token_program_id(), + associated_token_program: associated_token_account_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +fn build_liquidate_instruction(scenario: &Scenario, lease_id: u64, price_update: Pubkey) -> Instruction { + let (lease, leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + let holder_collateral_associated_token_account = derive_associated_token_account(&scenario.holder.pubkey(), &scenario.collateral_mint); + let keeper_collateral_associated_token_account = derive_associated_token_account(&scenario.keeper.pubkey(), &scenario.collateral_mint); + Instruction::new_with_bytes( + scenario.program_id, + &asset_leasing::instruction::Liquidate {}.data(), + asset_leasing::accounts::Liquidate { + keeper: scenario.keeper.pubkey(), + holder: scenario.holder.pubkey(), + lease, + leased_mint: scenario.leased_mint, + collateral_mint: scenario.collateral_mint, + leased_vault, + collateral_vault, + holder_collateral_account: holder_collateral_associated_token_account, + keeper_collateral_account: keeper_collateral_associated_token_account, + price_update, + token_program: token_program_id(), + associated_token_program: associated_token_account_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +fn build_close_expired_instruction(scenario: &Scenario, lease_id: u64) -> Instruction { + let (lease, leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + let holder_collateral_associated_token_account = derive_associated_token_account(&scenario.holder.pubkey(), &scenario.collateral_mint); + Instruction::new_with_bytes( + scenario.program_id, + &asset_leasing::instruction::CloseExpired {}.data(), + asset_leasing::accounts::CloseExpired { + holder: scenario.holder.pubkey(), + lease, + leased_mint: scenario.leased_mint, + collateral_mint: scenario.collateral_mint, + leased_vault, + collateral_vault, + holder_leased_account: scenario.holder_leased_associated_token_account, + holder_collateral_account: holder_collateral_associated_token_account, + token_program: token_program_id(), + associated_token_program: associated_token_account_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +/// Build a minimal `PriceUpdateV2` account body with the requested price and +/// exponent, timestamped `publish_time`. Fields not used by the program are +/// filled with zero bytes. +fn build_price_update_data( + feed_id: [u8; 32], + price: i64, + exponent: i32, + publish_time: i64, +) -> Vec { + // Size layout: + // 8 discriminator + 32 write_authority + 1 verification_level + 32 feed_id + + // 8 price + 8 conf + 4 exponent + 8 publish_time + 8 prev_publish_time + + // 8 ema_price + 8 ema_conf + 8 posted_slot = 141 bytes. + const TOTAL_LEN: usize = 141; + let mut data = Vec::with_capacity(TOTAL_LEN); + data.extend_from_slice(&PRICE_UPDATE_V2_DISCRIMINATOR); + // write_authority โ€” irrelevant for liquidation logic. + data.extend_from_slice(&[0u8; 32]); + // verification_level = Full (1). + data.push(1); + data.extend_from_slice(&feed_id); + data.extend_from_slice(&price.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); // conf + data.extend_from_slice(&exponent.to_le_bytes()); + data.extend_from_slice(&publish_time.to_le_bytes()); + data.extend_from_slice(&publish_time.to_le_bytes()); // prev_publish_time + data.extend_from_slice(&0i64.to_le_bytes()); // ema_price + data.extend_from_slice(&0u64.to_le_bytes()); // ema_conf + data.extend_from_slice(&0u64.to_le_bytes()); // posted_slot + data +} + +/// Install a mock `PriceUpdateV2` account owned by the Pyth receiver program. +fn mock_price_update( + svm: &mut LiteSVM, + address: Pubkey, + feed_id: [u8; 32], + price: i64, + exponent: i32, + publish_time: i64, +) { + let data = build_price_update_data(feed_id, price, exponent, publish_time); + let lamports = svm.minimum_balance_for_rent_exemption(data.len()); + let owner: Pubkey = PYTH_RECEIVER_PROGRAM_ID_STR.parse().unwrap(); + svm.set_account( + address, + solana_account::Account { + lamports, + data, + owner, + executable: false, + rent_epoch: 0, + }, + ) + .unwrap(); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +// Shared lease parameters so the sanity assertions line up across tests. +const LEASED_AMOUNT: u64 = 100_000_000; // 100 "leased" tokens (6 decimal places) +const REQUIRED_COLLATERAL: u64 = 200_000_000; // 200 collateral tokens +const LEASE_FEE_PER_SECOND: u64 = 10; // 10 base-units / sec +const DURATION_SECONDS: i64 = 60 * 60 * 24; // 24h +const MAINTENANCE_MARGIN_BASIS_POINTS: u16 = 12_000; // 120% +const LIQUIDATION_BOUNTY_BASIS_POINTS: u16 = 500; // 5% +// Arbitrary 32-byte Pyth feed id the tests pin their leases to. The +// mocked `PriceUpdateV2` accounts carry the same id so the feed-pinning +// check in liquidate passes. `liquidate_rejects_mismatched_price_feed` +// flips one byte of this to prove the check rejects foreign feeds. +const FEED_ID: [u8; 32] = [0xAB; 32]; + +#[test] +fn create_lease_locks_tokens_and_lists() { + let mut scenario = full_setup(); + + let lease_id = 1u64; + let instruction = build_create_lease_instruction( + &scenario, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + LEASE_FEE_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, + FEED_ID, + ); + send_transaction_from_instructions(&mut scenario.svm, vec![instruction], &[&scenario.holder], &scenario.holder.pubkey()) + .unwrap(); + + let (lease_program_derived_address, leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + + // Leased tokens escrowed. + assert_eq!( + get_token_account_balance(&scenario.svm, &leased_vault).unwrap(), + LEASED_AMOUNT + ); + // Collateral vault exists but has no collateral yet. + assert_eq!( + get_token_account_balance(&scenario.svm, &collateral_vault).unwrap(), + 0 + ); + // Holder's leased balance dropped by the escrowed amount. + assert_eq!( + get_token_account_balance(&scenario.svm, &scenario.holder_leased_associated_token_account).unwrap(), + 1_000_000_000 - LEASED_AMOUNT + ); + + // Lease account exists and is owned by the program. + let lease_account = scenario.svm.get_account(&lease_program_derived_address).expect("lease program-derived address missing"); + assert_eq!(lease_account.owner, scenario.program_id); + assert!(!lease_account.data.is_empty()); +} + +#[test] +fn take_lease_posts_collateral_and_delivers_tokens() { + let mut scenario = full_setup(); + let lease_id = 2u64; + + let create_instruction = build_create_lease_instruction( + &scenario, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + LEASE_FEE_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, + FEED_ID, + ); + send_transaction_from_instructions( + &mut scenario.svm, + vec![create_instruction], + &[&scenario.holder], + &scenario.holder.pubkey(), + ) + .unwrap(); + + let take_instruction = build_take_lease_instruction(&scenario, lease_id); + send_transaction_from_instructions( + &mut scenario.svm, + vec![take_instruction], + &[&scenario.short_seller], + &scenario.short_seller.pubkey(), + ) + .unwrap(); + + let (_, leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + let short_seller_leased_associated_token_account = derive_associated_token_account(&scenario.short_seller.pubkey(), &scenario.leased_mint); + + // Leased vault drained into the short_seller. + assert_eq!(get_token_account_balance(&scenario.svm, &leased_vault).unwrap(), 0); + assert_eq!( + get_token_account_balance(&scenario.svm, &short_seller_leased_associated_token_account).unwrap(), + LEASED_AMOUNT + ); + // Collateral moved from the short_seller into the collateral vault. + assert_eq!( + get_token_account_balance(&scenario.svm, &collateral_vault).unwrap(), + REQUIRED_COLLATERAL + ); + assert_eq!( + get_token_account_balance(&scenario.svm, &scenario.short_seller_collateral_associated_token_account).unwrap(), + 1_000_000_000 - REQUIRED_COLLATERAL + ); +} + +#[test] +fn pay_lease_fee_streams_collateral_by_elapsed_time() { + let mut scenario = full_setup(); + let lease_id = 3u64; + + let create_instruction = build_create_lease_instruction( + &scenario, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + LEASE_FEE_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, + FEED_ID, + ); + let take_instruction = build_take_lease_instruction(&scenario, lease_id); + send_transaction_from_instructions( + &mut scenario.svm, + vec![create_instruction, take_instruction], + &[&scenario.holder, &scenario.short_seller], + &scenario.holder.pubkey(), + ) + .unwrap(); + + let elapsed: i64 = 120; // 2 minutes + advance_clock_by(&mut scenario.svm, elapsed); + + let pay_instruction = build_pay_lease_fee_instruction(&scenario, lease_id); + send_transaction_from_instructions( + &mut scenario.svm, + vec![pay_instruction], + &[&scenario.short_seller], + &scenario.short_seller.pubkey(), + ) + .unwrap(); + + let expected_lease_fees = (elapsed as u64) * LEASE_FEE_PER_SECOND; + let holder_collateral_associated_token_account = derive_associated_token_account(&scenario.holder.pubkey(), &scenario.collateral_mint); + assert_eq!( + get_token_account_balance(&scenario.svm, &holder_collateral_associated_token_account).unwrap(), + expected_lease_fees + ); + let (_, _, collateral_vault) = lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + assert_eq!( + get_token_account_balance(&scenario.svm, &collateral_vault).unwrap(), + REQUIRED_COLLATERAL - expected_lease_fees + ); +} + +#[test] +fn top_up_collateral_increases_vault_balance() { + let mut scenario = full_setup(); + let lease_id = 4u64; + + let create_instruction = build_create_lease_instruction( + &scenario, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + LEASE_FEE_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, + FEED_ID, + ); + let take_instruction = build_take_lease_instruction(&scenario, lease_id); + send_transaction_from_instructions( + &mut scenario.svm, + vec![create_instruction, take_instruction], + &[&scenario.holder, &scenario.short_seller], + &scenario.holder.pubkey(), + ) + .unwrap(); + + let top_up_amount: u64 = 50_000_000; + let top_up_instruction = build_top_up_instruction(&scenario, lease_id, top_up_amount); + send_transaction_from_instructions( + &mut scenario.svm, + vec![top_up_instruction], + &[&scenario.short_seller], + &scenario.short_seller.pubkey(), + ) + .unwrap(); + + let (_, _, collateral_vault) = lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + assert_eq!( + get_token_account_balance(&scenario.svm, &collateral_vault).unwrap(), + REQUIRED_COLLATERAL + top_up_amount + ); +} + +#[test] +fn return_lease_refunds_unused_collateral() { + let mut scenario = full_setup(); + let lease_id = 5u64; + + let create_instruction = build_create_lease_instruction( + &scenario, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + LEASE_FEE_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, + FEED_ID, + ); + let take_instruction = build_take_lease_instruction(&scenario, lease_id); + send_transaction_from_instructions( + &mut scenario.svm, + vec![create_instruction, take_instruction], + &[&scenario.holder, &scenario.short_seller], + &scenario.holder.pubkey(), + ) + .unwrap(); + + // ShortSeller returns early โ€” 10 minutes in, for a 24h lease. + let elapsed: i64 = 600; + advance_clock_by(&mut scenario.svm, elapsed); + + let return_instruction = build_return_lease_instruction(&scenario, lease_id); + send_transaction_from_instructions( + &mut scenario.svm, + vec![return_instruction], + &[&scenario.short_seller], + &scenario.short_seller.pubkey(), + ) + .unwrap(); + + let lease_fee_paid = (elapsed as u64) * LEASE_FEE_PER_SECOND; + let refund_expected = REQUIRED_COLLATERAL - lease_fee_paid; + + // Holder got their leased tokens back. + assert_eq!( + get_token_account_balance(&scenario.svm, &scenario.holder_leased_associated_token_account).unwrap(), + 1_000_000_000 + ); + // Holder also received the accrued lease fees. + let holder_collateral_associated_token_account = derive_associated_token_account(&scenario.holder.pubkey(), &scenario.collateral_mint); + assert_eq!( + get_token_account_balance(&scenario.svm, &holder_collateral_associated_token_account).unwrap(), + lease_fee_paid + ); + // ShortSeller got the unused-time portion of their collateral back. + assert_eq!( + get_token_account_balance(&scenario.svm, &scenario.short_seller_collateral_associated_token_account).unwrap(), + 1_000_000_000 - REQUIRED_COLLATERAL + refund_expected + ); + + // Lease + vault program-derived addresses are closed. + let (lease_program_derived_address, leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + assert!(scenario.svm.get_account(&lease_program_derived_address).is_none()); + assert!(scenario.svm.get_account(&leased_vault).is_none()); + assert!(scenario.svm.get_account(&collateral_vault).is_none()); +} + +#[test] +fn liquidate_seizes_collateral_on_price_drop() { + let mut scenario = full_setup(); + let lease_id = 6u64; + + let create_instruction = build_create_lease_instruction( + &scenario, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + LEASE_FEE_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, + FEED_ID, + ); + let take_instruction = build_take_lease_instruction(&scenario, lease_id); + send_transaction_from_instructions( + &mut scenario.svm, + vec![create_instruction, take_instruction], + &[&scenario.holder, &scenario.short_seller], + &scenario.holder.pubkey(), + ) + .unwrap(); + + // A bit of Lease fee accrues before the liquidation call so the handler has to + // settle the lease fee *and* bounty on the same vault balance. + let elapsed: i64 = 300; + advance_clock_by(&mut scenario.svm, elapsed); + + // Install a Pyth price that quotes leased-in-collateral at 4.0 per unit + // with exponent 0. At 100 leased units the debt is 400 collateral units + // vs. the 200 collateral we hold โ€” ratio 50%, well below 120% margin. + let price_update_key = Keypair::new(); + let now = current_clock(&scenario.svm); + mock_price_update( + &mut scenario.svm, + price_update_key.pubkey(), + FEED_ID, + 4, + 0, + now, // fresh publish_time + ); + + let liquidate_instruction = build_liquidate_instruction(&scenario, lease_id, price_update_key.pubkey()); + send_transaction_from_instructions( + &mut scenario.svm, + vec![liquidate_instruction], + &[&scenario.keeper], + &scenario.keeper.pubkey(), + ) + .unwrap(); + + let lease_fee_paid = (elapsed as u64) * LEASE_FEE_PER_SECOND; + let remaining_after_lease_fees = REQUIRED_COLLATERAL - lease_fee_paid; + let bounty = remaining_after_lease_fees * (LIQUIDATION_BOUNTY_BASIS_POINTS as u64) / 10_000; + let holder_share = remaining_after_lease_fees - bounty; + + let holder_collateral_associated_token_account = derive_associated_token_account(&scenario.holder.pubkey(), &scenario.collateral_mint); + let keeper_collateral_associated_token_account = derive_associated_token_account(&scenario.keeper.pubkey(), &scenario.collateral_mint); + + assert_eq!( + get_token_account_balance(&scenario.svm, &holder_collateral_associated_token_account).unwrap(), + lease_fee_paid + holder_share + ); + assert_eq!( + get_token_account_balance(&scenario.svm, &keeper_collateral_associated_token_account).unwrap(), + bounty + ); + + // Vaults and lease account closed. + let (lease_program_derived_address, leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + assert!(scenario.svm.get_account(&lease_program_derived_address).is_none()); + assert!(scenario.svm.get_account(&leased_vault).is_none()); + assert!(scenario.svm.get_account(&collateral_vault).is_none()); +} + +#[test] +fn liquidate_rejects_healthy_position() { + let mut scenario = full_setup(); + let lease_id = 7u64; + + let create_instruction = build_create_lease_instruction( + &scenario, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + LEASE_FEE_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, + FEED_ID, + ); + let take_instruction = build_take_lease_instruction(&scenario, lease_id); + send_transaction_from_instructions( + &mut scenario.svm, + vec![create_instruction, take_instruction], + &[&scenario.holder, &scenario.short_seller], + &scenario.holder.pubkey(), + ) + .unwrap(); + + // Price of 1.0 per leased token โ†’ debt = 100 collateral units, collateral + // = 200 โ†’ ratio 200% โ‰ฅ 120% maintenance margin. Expect the transaction + // to fail with `PositionHealthy`. + let price_update_key = Keypair::new(); + let now = current_clock(&scenario.svm); + mock_price_update(&mut scenario.svm, price_update_key.pubkey(), FEED_ID, 1, 0, now); + + let liquidate_instruction = build_liquidate_instruction(&scenario, lease_id, price_update_key.pubkey()); + let result = send_transaction_from_instructions( + &mut scenario.svm, + vec![liquidate_instruction], + &[&scenario.keeper], + &scenario.keeper.pubkey(), + ); + assert!(result.is_err(), "healthy liquidation must fail"); +} + +#[test] +fn liquidate_rejects_mismatched_price_feed() { + // The holder pinned FEED_ID; we hand the handler a price update whose + // internal feed_id is different. Even when the price would push the + // position underwater, the liquidate call must bail with + // `PriceFeedMismatch` before running the undercollateralisation check. + let mut scenario = full_setup(); + let lease_id = 100u64; + + let create_instruction = build_create_lease_instruction( + &scenario, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + LEASE_FEE_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, + FEED_ID, + ); + let take_instruction = build_take_lease_instruction(&scenario, lease_id); + send_transaction_from_instructions( + &mut scenario.svm, + vec![create_instruction, take_instruction], + &[&scenario.holder, &scenario.short_seller], + &scenario.holder.pubkey(), + ) + .unwrap(); + + // Flip every byte โ€” any 32-byte feed id other than FEED_ID should do. + let wrong_feed_id = [0xCD; 32]; + + // Price that *would* trigger liquidation (debt 400 vs 200 collateral, + // same as `liquidate_seizes_collateral_on_price_drop`) โ€” except this + // update carries the wrong feed id. + let price_update_key = Keypair::new(); + let now = current_clock(&scenario.svm); + mock_price_update( + &mut scenario.svm, + price_update_key.pubkey(), + wrong_feed_id, + 4, + 0, + now, + ); + + let liquidate_instruction = build_liquidate_instruction(&scenario, lease_id, price_update_key.pubkey()); + let result = send_transaction_from_instructions( + &mut scenario.svm, + vec![liquidate_instruction], + &[&scenario.keeper], + &scenario.keeper.pubkey(), + ); + let err = result.expect_err("liquidate must reject foreign price feeds"); + let rendered = format!("{err:?}"); + // PriceFeedMismatch is the 16th error in the enum (index 15) โ†’ 0x177f. + assert!( + rendered.contains("PriceFeedMismatch") || rendered.contains("0x177f"), + "unexpected failure mode: {rendered}" + ); +} + +#[test] +fn close_expired_reclaims_collateral_after_end_timestamp() { + let mut scenario = full_setup(); + let lease_id = 8u64; + + let create_instruction = build_create_lease_instruction( + &scenario, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + LEASE_FEE_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, + FEED_ID, + ); + let take_instruction = build_take_lease_instruction(&scenario, lease_id); + send_transaction_from_instructions( + &mut scenario.svm, + vec![create_instruction, take_instruction], + &[&scenario.holder, &scenario.short_seller], + &scenario.holder.pubkey(), + ) + .unwrap(); + + // Jump past the lease end. + advance_clock_by(&mut scenario.svm, DURATION_SECONDS + 1); + + let close_instruction = build_close_expired_instruction(&scenario, lease_id); + send_transaction_from_instructions( + &mut scenario.svm, + vec![close_instruction], + &[&scenario.holder], + &scenario.holder.pubkey(), + ) + .unwrap(); + + // Full collateral forfeited to the holder. Leased tokens are gone (the + // short_seller kept them on default) so the holder's leased balance is only + // what they had after the initial escrow minus the leased amount. + let holder_collateral_associated_token_account = derive_associated_token_account(&scenario.holder.pubkey(), &scenario.collateral_mint); + assert_eq!( + get_token_account_balance(&scenario.svm, &holder_collateral_associated_token_account).unwrap(), + REQUIRED_COLLATERAL + ); + assert_eq!( + get_token_account_balance(&scenario.svm, &scenario.holder_leased_associated_token_account).unwrap(), + 1_000_000_000 - LEASED_AMOUNT + ); + + let (lease_program_derived_address, leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + assert!(scenario.svm.get_account(&lease_program_derived_address).is_none()); + assert!(scenario.svm.get_account(&leased_vault).is_none()); + assert!(scenario.svm.get_account(&collateral_vault).is_none()); +} + +#[test] +fn close_expired_cancels_listed_lease() { + let mut scenario = full_setup(); + let lease_id = 9u64; + + let create_instruction = build_create_lease_instruction( + &scenario, + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + LEASE_FEE_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, + FEED_ID, + ); + send_transaction_from_instructions( + &mut scenario.svm, + vec![create_instruction], + &[&scenario.holder], + &scenario.holder.pubkey(), + ) + .unwrap(); + + // Holder bails before anyone takes the lease โ€” allowed immediately. + let close_instruction = build_close_expired_instruction(&scenario, lease_id); + send_transaction_from_instructions( + &mut scenario.svm, + vec![close_instruction], + &[&scenario.holder], + &scenario.holder.pubkey(), + ) + .unwrap(); + + // Holder recovered the full leased amount. No collateral was ever posted. + assert_eq!( + get_token_account_balance(&scenario.svm, &scenario.holder_leased_associated_token_account).unwrap(), + 1_000_000_000 + ); + let (lease_program_derived_address, leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + assert!(scenario.svm.get_account(&lease_program_derived_address).is_none()); + assert!(scenario.svm.get_account(&leased_vault).is_none()); + assert!(scenario.svm.get_account(&collateral_vault).is_none()); +} + +#[test] +fn create_lease_rejects_same_mint_for_leased_and_collateral() { + // Collapsing leased_mint and collateral_mint into a single mint would + // also collapse the two vaults into one token-balance pool (same mint, + // same authority seed pattern) and make lease-fee-vs-collateral accounting + // ambiguous. The program rejects this up-front with + // `LeasedMintEqualsCollateralMint`. + let mut scenario = full_setup(); + let lease_id = 42u64; + + // Build a `create_lease` instruction where the collateral_mint field + // carries the same mint as leased_mint. We bypass `build_create_lease_instruction` + // because that helper always wires the two mints from the scenario. + let (lease, leased_vault, collateral_vault) = + lease_program_derived_addresses(&scenario.program_id, &scenario.holder.pubkey(), lease_id); + let instruction = Instruction::new_with_bytes( + scenario.program_id, + &asset_leasing::instruction::CreateLease { + lease_id, + leased_amount: LEASED_AMOUNT, + required_collateral_amount: REQUIRED_COLLATERAL, + lease_fee_per_second: LEASE_FEE_PER_SECOND, + duration_seconds: DURATION_SECONDS, + maintenance_margin_basis_points: MAINTENANCE_MARGIN_BASIS_POINTS, + liquidation_bounty_basis_points: LIQUIDATION_BOUNTY_BASIS_POINTS, + feed_id: FEED_ID, + } + .data(), + asset_leasing::accounts::CreateLease { + holder: scenario.holder.pubkey(), + leased_mint: scenario.leased_mint, + // Same mint on both sides โ€” should be rejected. + collateral_mint: scenario.leased_mint, + holder_leased_account: scenario.holder_leased_associated_token_account, + lease, + leased_vault, + collateral_vault, + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + + let result = send_transaction_from_instructions( + &mut scenario.svm, + vec![instruction], + &[&scenario.holder], + &scenario.holder.pubkey(), + ); + + let err = result.expect_err("create_lease must reject identical leased/collateral mints"); + let rendered = format!("{err:?}"); + assert!( + rendered.contains("LeasedMintEqualsCollateralMint") || rendered.contains("0x177e"), + "unexpected failure mode: {rendered}" + ); +} diff --git a/defi/asset-leasing/quasar/Cargo.toml b/defi/asset-leasing/quasar/Cargo.toml new file mode 100644 index 000000000..79d44c426 --- /dev/null +++ b/defi/asset-leasing/quasar/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "quasar-asset-leasing" +version = "0.1.0" +edition = "2021" + +# Standalone workspace โ€” not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree from the Anchor +# projects, so it must declare its own [workspace] root. +[workspace] + +[lints.rust.unexpected_cfgs] +level = "warn" +check-cfg = [ + 'cfg(target_os, values("solana"))', +] + +[lib] +# `cdylib` for the onchain .so; `lib` so `cargo test` can link the Rust +# code as a regular library and exercise handlers against QuasarSvm. +crate-type = ["cdylib", "lib"] + +[features] +alloc = [] +client = [] +debug = [] + +[dependencies] +quasar-lang = "0.0" +quasar-spl = "0.0" +solana-address = { version = "2.2.0" } +solana-instruction = { version = "3.2.0" } + +[dev-dependencies] +quasar-svm = { version = "0.1" } +spl-token-interface = { version = "2.0.0" } +solana-program-pack = { version = "3.1.0" } diff --git a/defi/asset-leasing/quasar/Quasar.toml b/defi/asset-leasing/quasar/Quasar.toml new file mode 100644 index 000000000..41588a4f2 --- /dev/null +++ b/defi/asset-leasing/quasar/Quasar.toml @@ -0,0 +1,22 @@ +[project] +name = "quasar_asset_leasing" + +[toolchain] +type = "solana" + +[testing] +language = "rust" + +[testing.rust] +framework = "quasar-svm" + +[testing.rust.test] +program = "cargo" +args = [ + "test", + "tests::", +] + +[clients] +path = "target/client" +languages = ["rust"] diff --git a/defi/asset-leasing/quasar/src/constants.rs b/defi/asset-leasing/quasar/src/constants.rs new file mode 100644 index 000000000..c4204af8e --- /dev/null +++ b/defi/asset-leasing/quasar/src/constants.rs @@ -0,0 +1,28 @@ +/// program-derived address seed for the `Lease` account. Combined with the lessor pubkey and a +/// u64 `lease_id` so one lessor 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 lessee'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 lessor +/// 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 lessor 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; diff --git a/defi/asset-leasing/quasar/src/errors.rs b/defi/asset-leasing/quasar/src/errors.rs new file mode 100644 index 000000000..2326e1d2f --- /dev/null +++ b/defi/asset-leasing/quasar/src/errors.rs @@ -0,0 +1,26 @@ +use quasar_lang::prelude::*; + +/// Program-specific errors. Codes start at 6000 (Quasar's default +/// `#[error_code]` offset, matching Anchor), so they never collide with +/// Solana's built-in `ProgramError` codes or the framework's +/// `QuasarError` codes. +#[error_code] +pub enum AssetLeasingError { + InvalidLeaseStatus, + InvalidDuration, + InvalidLeasedAmount, + InvalidCollateralAmount, + InvalidLeaseFeePerSecond, + InvalidMaintenanceMargin, + InvalidLiquidationBounty, + LeaseExpired, + LeaseNotExpired, + PositionHealthy, + StalePrice, + NonPositivePrice, + MathOverflow, + Unauthorised, + LeasedMintEqualsCollateralMint, + PriceFeedMismatch, + InvalidStatusByte, +} diff --git a/defi/asset-leasing/quasar/src/instructions/close_expired.rs b/defi/asset-leasing/quasar/src/instructions/close_expired.rs new file mode 100644 index 000000000..f4d6b7c91 --- /dev/null +++ b/defi/asset-leasing/quasar/src/instructions/close_expired.rs @@ -0,0 +1,156 @@ +use { + crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, + errors::AssetLeasingError, + instructions::pay_lease_fee::update_last_paid_timestamp, + state::{Lease, LeaseStatus}, + }, + quasar_lang::prelude::*, + quasar_spl::{Mint, Token, TokenCpi}, +}; + +/// Lessor-only recovery path. Two situations collapse into this handler: +/// +/// - The lease sat in `Listed` and the lessor wants to cancel it, +/// recovering the leased tokens they pre-funded. Allowed any time. +/// - The lease was `Active` but the lessee ghosted past `end_timestamp`. The +/// lessor takes the collateral as compensation and closes the books. +#[derive(Accounts)] +pub struct CloseExpired<'info> { + #[account(mut)] + pub lessor: &'info Signer, + + #[account( + mut, + seeds = [LEASE_SEED, lessor], + bump = lease.bump, + has_one = lessor, + has_one = leased_mint, + has_one = collateral_mint, + constraint = { + let s = LeaseStatus::from_u8(lease.status); + s == Some(LeaseStatus::Listed) || s == Some(LeaseStatus::Active) + } @ AssetLeasingError::InvalidLeaseStatus, + close = lessor, + )] + pub lease: &'info mut Account, + + pub leased_mint: &'info Account, + pub collateral_mint: &'info Account, + + #[account( + mut, + seeds = [LEASED_VAULT_SEED, lease], + bump = lease.leased_vault_bump, + )] + pub leased_vault: &'info mut Account, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease], + bump = lease.collateral_vault_bump, + )] + pub collateral_vault: &'info mut Account, + + #[account(mut)] + pub lessor_leased_account: &'info mut Account, + + #[account(mut)] + pub lessor_collateral_account: &'info mut Account, + + pub token_program: &'info Program, +} + +#[inline(always)] +pub fn handle_close_expired(accounts: &mut CloseExpired) -> Result<(), ProgramError> { + let now = ::get()?.unix_timestamp.get(); + let lease_address = *accounts.lease.address(); + let status = LeaseStatus::from_u8(accounts.lease.status) + .ok_or(AssetLeasingError::InvalidStatusByte)?; + + // Active leases can only be closed after they expire. Listed leases + // have no start/end so the check is skipped. + if status == LeaseStatus::Active { + let end_timestamp = accounts.lease.end_timestamp.get(); + if now < end_timestamp { + return Err(AssetLeasingError::LeaseNotExpired.into()); + } + } + + let leased_vault_bump = [accounts.lease.leased_vault_bump]; + let leased_vault_seeds: &[Seed] = &[ + Seed::from(LEASED_VAULT_SEED), + Seed::from(lease_address.as_ref()), + Seed::from(&leased_vault_bump as &[u8]), + ]; + let collateral_vault_bump = [accounts.lease.collateral_vault_bump]; + let collateral_vault_seeds: &[Seed] = &[ + Seed::from(COLLATERAL_VAULT_SEED), + Seed::from(lease_address.as_ref()), + Seed::from(&collateral_vault_bump as &[u8]), + ]; + + // Drain whatever is in the leased vault back to the lessor. For a + // Listed lease this is the full leased_amount; for a defaulted + // Active lease the vault is empty (the lessee never returned) so + // this is a no-op. + let leased_vault_balance = accounts.leased_vault.amount(); + if leased_vault_balance > 0 { + accounts + .token_program + .transfer( + accounts.leased_vault, + accounts.lessor_leased_account, + accounts.leased_vault, + leased_vault_balance, + ) + .invoke_signed(leased_vault_seeds)?; + } + + // Drain the collateral vault to the lessor. For a Listed lease this + // is 0. For a defaulted Active lease this is the lessee's forfeited + // collateral. + let collateral_vault_balance = accounts.collateral_vault.amount(); + if collateral_vault_balance > 0 { + accounts + .token_program + .transfer( + accounts.collateral_vault, + accounts.lessor_collateral_account, + accounts.collateral_vault, + collateral_vault_balance, + ) + .invoke_signed(collateral_vault_seeds)?; + } + + accounts + .token_program + .close_account( + accounts.leased_vault, + accounts.lessor, + accounts.leased_vault, + ) + .invoke_signed(leased_vault_seeds)?; + accounts + .token_program + .close_account( + accounts.collateral_vault, + accounts.lessor, + accounts.collateral_vault, + ) + .invoke_signed(collateral_vault_seeds)?; + + // Keep the lease-fee-settlement invariant intact even on default: the + // lessor takes the whole collateral vault as compensation here, but + // any future version of the program that wants to split the + // collateral differently (pro-rata lease fees, partial refund on default) + // can read `last_paid_timestamp` and trust that everything up to + // `now` is already settled. + if status == LeaseStatus::Active { + update_last_paid_timestamp(accounts.lease, now); + } + accounts.lease.collateral_amount = 0u64.into(); + accounts.lease.status = LeaseStatus::Closed as u8; + + Ok(()) +} diff --git a/defi/asset-leasing/quasar/src/instructions/create_lease.rs b/defi/asset-leasing/quasar/src/instructions/create_lease.rs new file mode 100644 index 000000000..e9715b622 --- /dev/null +++ b/defi/asset-leasing/quasar/src/instructions/create_lease.rs @@ -0,0 +1,155 @@ +use { + crate::{ + constants::{ + COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED, MAX_LIQUIDATION_BOUNTY_BASIS_POINTS, + MAX_MAINTENANCE_MARGIN_BASIS_POINTS, + }, + errors::AssetLeasingError, + state::{Lease, LeaseStatus}, + }, + quasar_lang::prelude::*, + quasar_spl::{Mint, Token, TokenCpi}, +}; + +/// Accounts needed to create a new `Listed` lease. The lessor funds the +/// lease state account and both program-derived address-owned token vaults up front, then +/// transfers the leased tokens into the leased vault in the same +/// transaction so a lessee can never accept a lease the lessor has not +/// pre-funded. +#[derive(Accounts)] +pub struct CreateLease<'info> { + #[account(mut)] + pub lessor: &'info Signer, + + pub leased_mint: &'info Account, + pub collateral_mint: &'info Account, + + /// Lessor's existing token account for the leased mint. Pre-created by + /// the caller โ€” the Quasar port does not do `init_if_needed` associated token accounts + /// (the Anchor version does, via cross-program invocation to the Associated Token Account + /// program; see the Quasar section of the README for the rationale). + #[account(mut)] + pub lessor_leased_account: &'info mut Account, + + #[account( + mut, + init, + payer = lessor, + seeds = [LEASE_SEED, lessor], + bump, + )] + pub lease: &'info mut Account, + + /// Leased-token vault. Authority is the vault program-derived address itself โ€” signing + /// with the vault seeds is the only way to move tokens out. + #[account( + mut, + init, + payer = lessor, + seeds = [LEASED_VAULT_SEED, lease], + bump, + token::mint = leased_mint, + token::authority = leased_vault, + )] + pub leased_vault: &'info mut Account, + + /// Collateral vault. Empty while `Listed`; filled on `take_lease`. + #[account( + mut, + init, + payer = lessor, + seeds = [COLLATERAL_VAULT_SEED, lease], + bump, + token::mint = collateral_mint, + token::authority = collateral_vault, + )] + pub collateral_vault: &'info mut Account, + + pub rent: &'info Sysvar, + pub token_program: &'info Program, + pub system_program: &'info Program, +} + +#[allow(clippy::too_many_arguments)] +#[inline(always)] +pub fn handle_create_lease( + accounts: &mut CreateLease, + lease_id: u64, + leased_amount: u64, + required_collateral_amount: u64, + lease_fee_per_second: u64, + duration_seconds: i64, + maintenance_margin_basis_points: u16, + liquidation_bounty_basis_points: u16, + feed_id: [u8; 32], + bumps: &CreateLeaseBumps, +) -> Result<(), ProgramError> { + // Two vaults keyed on the same mint would collide on the shared + // token-balance pool and make lease-fee-vs-collateral accounting + // ambiguous. Reject up-front. + require!( + accounts.leased_mint.address() != accounts.collateral_mint.address(), + AssetLeasingError::LeasedMintEqualsCollateralMint + ); + + require!(leased_amount > 0, AssetLeasingError::InvalidLeasedAmount); + require!( + required_collateral_amount > 0, + AssetLeasingError::InvalidCollateralAmount + ); + require!( + lease_fee_per_second > 0, + AssetLeasingError::InvalidLeaseFeePerSecond + ); + require!(duration_seconds > 0, AssetLeasingError::InvalidDuration); + require!( + maintenance_margin_basis_points > 0 && maintenance_margin_basis_points <= MAX_MAINTENANCE_MARGIN_BASIS_POINTS, + AssetLeasingError::InvalidMaintenanceMargin + ); + require!( + liquidation_bounty_basis_points <= MAX_LIQUIDATION_BOUNTY_BASIS_POINTS, + AssetLeasingError::InvalidLiquidationBounty + ); + + // Lock the leased tokens into the vault up-front. Doing this here โ€” + // rather than on `take_lease` โ€” guarantees that by the time a lessee + // sees a `Listed` lease the lessor cannot have moved the funds + // elsewhere. + accounts + .token_program + .transfer( + accounts.lessor_leased_account, + accounts.leased_vault, + accounts.lessor, + leased_amount, + ) + .invoke()?; + + accounts.lease.set_inner( + lease_id, + *accounts.lessor.address(), + // No lessee yet โ€” populated by `take_lease`. + Address::new_from_array([0u8; 32]), + *accounts.leased_mint.address(), + leased_amount, + *accounts.collateral_mint.address(), + // No collateral yet โ€” posted on `take_lease`. + 0, + required_collateral_amount, + lease_fee_per_second, + duration_seconds, + // start_timestamp / end_timestamp / last_paid_timestamp set on `take_lease`. + 0, + 0, + 0, + maintenance_margin_basis_points, + liquidation_bounty_basis_points, + feed_id, + LeaseStatus::Listed as u8, + bumps.lease, + bumps.leased_vault, + bumps.collateral_vault, + ); + + Ok(()) +} diff --git a/defi/asset-leasing/quasar/src/instructions/liquidate.rs b/defi/asset-leasing/quasar/src/instructions/liquidate.rs new file mode 100644 index 000000000..c922e4a86 --- /dev/null +++ b/defi/asset-leasing/quasar/src/instructions/liquidate.rs @@ -0,0 +1,325 @@ +use { + crate::{ + constants::{ + BASIS_POINTS_DENOMINATOR, COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED, + PYTH_MAX_AGE_SECONDS, + }, + errors::AssetLeasingError, + instructions::pay_lease_fee::compute_lease_fee_due, + state::{Lease, LeaseStatus}, + }, + quasar_lang::prelude::*, + quasar_spl::{Mint, Token, TokenCpi}, +}; + +/// Pyth Solana Receiver program id on mainnet/devnet. Liquidation +/// rejects any `price_update` account not owned by this program. +// Base58: rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ +pub const PYTH_RECEIVER_PROGRAM_ID: Address = Address::new_from_array([ + 12, 183, 250, 187, 82, 247, 166, 72, 187, 91, 49, 125, 154, 1, 139, 144, 87, 203, 2, 71, 116, + 250, 254, 1, 230, 196, 223, 152, 204, 56, 88, 129, +]); + +/// 8-byte Anchor discriminator for `PriceUpdateV2`. Equal to the first +/// 8 bytes of `sha256("account:PriceUpdateV2")`. Hard-coded because the +/// Pyth SDK pulls in a large dependency tree we don't need for the two +/// numeric fields we actually read. +pub const PRICE_UPDATE_V2_DISCRIMINATOR: [u8; 8] = [34, 241, 35, 99, 157, 126, 244, 205]; + +/// Accounts for the keeper-driven liquidation of an underwater lease. +#[derive(Accounts)] +pub struct Liquidate<'info> { + #[account(mut)] + pub keeper: &'info Signer, + + /// Receives the lease fee + the post-bounty remainder. Also the destination + /// for the closed-vault rent-exempt lamports. + #[account(mut)] + pub lessor: &'info UncheckedAccount, + + #[account( + mut, + seeds = [LEASE_SEED, lessor], + bump = lease.bump, + has_one = lessor, + has_one = leased_mint, + has_one = collateral_mint, + constraint = LeaseStatus::from_u8(lease.status) == Some(LeaseStatus::Active) + @ AssetLeasingError::InvalidLeaseStatus, + close = lessor, + )] + pub lease: &'info mut Account, + + pub leased_mint: &'info Account, + pub collateral_mint: &'info Account, + + #[account( + mut, + seeds = [LEASED_VAULT_SEED, lease], + bump = lease.leased_vault_bump, + )] + pub leased_vault: &'info mut Account, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease], + bump = lease.collateral_vault_bump, + )] + pub collateral_vault: &'info mut Account, + + /// Lessor's collateral-mint token account. Pre-created by the caller. + #[account(mut)] + pub lessor_collateral_account: &'info mut Account, + + /// Keeper's collateral-mint token account โ€” bounty destination. + /// Pre-created by the caller. + #[account(mut)] + pub keeper_collateral_account: &'info mut Account, + + /// Pyth `PriceUpdateV2` account. Must be owned by the Pyth receiver + /// program and carry the expected discriminator; the `feed_id` + /// inside must match the one pinned on the `Lease` at creation so a + /// keeper cannot swap in an unrelated feed. + pub price_update: &'info UncheckedAccount, + + pub token_program: &'info Program, +} + +/// Minimal projection of `PriceUpdateV2` โ€” only the fields we read. +/// Layout: `[discriminator(8) | write_authority(32) | verification_level(1) +/// | feed_id(32) | price(i64) | conf(u64) | exponent(i32) | +/// publish_time(i64) | ...]`. +pub struct DecodedPriceUpdate { + pub feed_id: [u8; 32], + pub price: i64, + pub exponent: i32, + pub publish_time: i64, +} + +pub fn decode_price_update(data: &[u8]) -> Result { + // Discriminator (8) + write_authority (32) + verification_level (1) = 41. + const FEED_ID_OFFSET: usize = 41; + const PRICE_OFFSET: usize = FEED_ID_OFFSET + 32; + const EXPONENT_OFFSET: usize = PRICE_OFFSET + 8 /* price */ + 8 /* conf */; + const PUBLISH_TIME_OFFSET: usize = EXPONENT_OFFSET + 4 /* exponent */; + const MIN_LEN: usize = PUBLISH_TIME_OFFSET + 8; + + if data.len() < MIN_LEN { + return Err(AssetLeasingError::StalePrice.into()); + } + if data[..8] != PRICE_UPDATE_V2_DISCRIMINATOR { + return Err(AssetLeasingError::StalePrice.into()); + } + + let mut feed_id = [0u8; 32]; + feed_id.copy_from_slice(&data[FEED_ID_OFFSET..FEED_ID_OFFSET + 32]); + + let price = i64::from_le_bytes(data[PRICE_OFFSET..PRICE_OFFSET + 8].try_into().unwrap()); + let exponent = i32::from_le_bytes( + data[EXPONENT_OFFSET..EXPONENT_OFFSET + 4] + .try_into() + .unwrap(), + ); + let publish_time = i64::from_le_bytes( + data[PUBLISH_TIME_OFFSET..PUBLISH_TIME_OFFSET + 8] + .try_into() + .unwrap(), + ); + + Ok(DecodedPriceUpdate { + feed_id, + price, + exponent, + publish_time, + }) +} + +#[inline(always)] +pub fn handle_liquidate(accounts: &mut Liquidate) -> Result<(), ProgramError> { + // Owner check: the price update must come from the Pyth receiver + // program. Without this a keeper could forge an arbitrary account. + let price_view = accounts.price_update.to_account_view(); + if price_view.owner() != &PYTH_RECEIVER_PROGRAM_ID { + return Err(ProgramError::IllegalOwner); + } + + let now = ::get()?.unix_timestamp.get(); + let decoded = { + let price_data = unsafe { price_view.borrow_unchecked() }; + decode_price_update(price_data)? + }; + + // Feed pinning: reject any `PriceUpdateV2` whose feed_id does not + // match the one the lessor committed to at `create_lease`. Without + // this guard, a keeper could pass in any feed the Pyth receiver + // owns โ€” e.g. an unrelated volatile pair that happens to dip โ€” + // and trigger a spurious liquidation. + if decoded.feed_id != accounts.lease.feed_id { + return Err(AssetLeasingError::PriceFeedMismatch.into()); + } + + if !is_underwater(accounts.lease, &decoded, now)? { + return Err(AssetLeasingError::PositionHealthy.into()); + } + + // Settle accrued lease fees first (up to end_timestamp) so the lessor is paid for + // the time the lessee actually used. Only then slice off bounty + + // remainder. + let lease_fee_due = compute_lease_fee_due(accounts.lease, now)?; + let collateral_amount = accounts.lease.collateral_amount.get(); + let lease_fee_payable = lease_fee_due.min(collateral_amount); + + let lease_address = *accounts.lease.address(); + let collateral_vault_bump = [accounts.lease.collateral_vault_bump]; + let collateral_vault_seeds: &[Seed] = &[ + Seed::from(COLLATERAL_VAULT_SEED), + Seed::from(lease_address.as_ref()), + Seed::from(&collateral_vault_bump as &[u8]), + ]; + let leased_vault_bump = [accounts.lease.leased_vault_bump]; + let leased_vault_seeds: &[Seed] = &[ + Seed::from(LEASED_VAULT_SEED), + Seed::from(lease_address.as_ref()), + Seed::from(&leased_vault_bump as &[u8]), + ]; + + if lease_fee_payable > 0 { + accounts + .token_program + .transfer( + accounts.collateral_vault, + accounts.lessor_collateral_account, + accounts.collateral_vault, + lease_fee_payable, + ) + .invoke_signed(collateral_vault_seeds)?; + } + + let remaining = collateral_amount + .checked_sub(lease_fee_payable) + .ok_or(AssetLeasingError::MathOverflow)?; + + // Bounty is a percentage of the collateral *after* lease fees โ€” guarantees + // we never try to pay out more than what actually sits in the vault. + let bounty = (remaining as u128) + .checked_mul(accounts.lease.liquidation_bounty_basis_points.get() as u128) + .ok_or(AssetLeasingError::MathOverflow)? + .checked_div(BASIS_POINTS_DENOMINATOR as u128) + .ok_or(AssetLeasingError::MathOverflow)? as u64; + + if bounty > 0 { + accounts + .token_program + .transfer( + accounts.collateral_vault, + accounts.keeper_collateral_account, + accounts.collateral_vault, + bounty, + ) + .invoke_signed(collateral_vault_seeds)?; + } + + let lessor_share = remaining + .checked_sub(bounty) + .ok_or(AssetLeasingError::MathOverflow)?; + if lessor_share > 0 { + accounts + .token_program + .transfer( + accounts.collateral_vault, + accounts.lessor_collateral_account, + accounts.collateral_vault, + lessor_share, + ) + .invoke_signed(collateral_vault_seeds)?; + } + + // Close both vaults. The leased vault is empty on the default path + // (lessee kept the tokens) but was rent-exempt funded at creation, + // so closing it still returns lamports to the lessor. + accounts + .token_program + .close_account( + accounts.leased_vault, + accounts.lessor, + accounts.leased_vault, + ) + .invoke_signed(leased_vault_seeds)?; + accounts + .token_program + .close_account( + accounts.collateral_vault, + accounts.lessor, + accounts.collateral_vault, + ) + .invoke_signed(collateral_vault_seeds)?; + + accounts.lease.collateral_amount = 0u64.into(); + let end_timestamp = accounts.lease.end_timestamp.get(); + accounts.lease.last_paid_timestamp = now.min(end_timestamp).into(); + accounts.lease.status = LeaseStatus::Liquidated as u8; + + Ok(()) +} + +/// Liquidatable when collateral value < debt value * maintenance margin. +/// All math stays in integers by folding the Pyth exponent into whichever +/// side of the inequality does not already have a power of ten applied. +pub fn is_underwater( + lease: &Lease, + price: &DecodedPriceUpdate, + now: i64, +) -> Result { + // Staleness guard. `publish_time` coming from the future is treated + // as stale โ€” the keeper must not front-run the clock. + if price.publish_time > now { + return Err(AssetLeasingError::StalePrice.into()); + } + let age = (now - price.publish_time) as u64; + if age > PYTH_MAX_AGE_SECONDS { + return Err(AssetLeasingError::StalePrice.into()); + } + + if price.price <= 0 { + return Err(AssetLeasingError::NonPositivePrice.into()); + } + let price_raw = price.price as u128; + + let leased_amount = lease.leased_amount.get() as u128; + let collateral_amount = lease.collateral_amount.get() as u128; + let margin_basis_points = lease.maintenance_margin_basis_points.get() as u128; + let denom = BASIS_POINTS_DENOMINATOR as u128; + + let (collateral_scaled, debt_scaled) = if price.exponent >= 0 { + let scale = ten_pow(price.exponent as u32)?; + let debt = leased_amount + .checked_mul(price_raw) + .and_then(|product| product.checked_mul(scale)) + .ok_or(AssetLeasingError::MathOverflow)?; + (collateral_amount, debt) + } else { + let scale = ten_pow((-price.exponent) as u32)?; + let collateral = collateral_amount + .checked_mul(scale) + .ok_or(AssetLeasingError::MathOverflow)?; + let debt = leased_amount + .checked_mul(price_raw) + .ok_or(AssetLeasingError::MathOverflow)?; + (collateral, debt) + }; + + let lhs = collateral_scaled + .checked_mul(denom) + .ok_or(AssetLeasingError::MathOverflow)?; + let rhs = debt_scaled + .checked_mul(margin_basis_points) + .ok_or(AssetLeasingError::MathOverflow)?; + + Ok(lhs < rhs) +} + +fn ten_pow(exponent: u32) -> Result { + 10u128 + .checked_pow(exponent) + .ok_or_else(|| AssetLeasingError::MathOverflow.into()) +} diff --git a/defi/asset-leasing/quasar/src/instructions/mod.rs b/defi/asset-leasing/quasar/src/instructions/mod.rs new file mode 100644 index 000000000..67f7715c4 --- /dev/null +++ b/defi/asset-leasing/quasar/src/instructions/mod.rs @@ -0,0 +1,20 @@ +pub mod create_lease; +pub use create_lease::*; + +pub mod take_lease; +pub use take_lease::*; + +pub mod pay_lease_fee; +pub use pay_lease_fee::*; + +pub mod top_up_collateral; +pub use top_up_collateral::*; + +pub mod return_lease; +pub use return_lease::*; + +pub mod liquidate; +pub use liquidate::*; + +pub mod close_expired; +pub use close_expired::*; diff --git a/defi/asset-leasing/quasar/src/instructions/pay_lease_fee.rs b/defi/asset-leasing/quasar/src/instructions/pay_lease_fee.rs new file mode 100644 index 000000000..cbb10b24a --- /dev/null +++ b/defi/asset-leasing/quasar/src/instructions/pay_lease_fee.rs @@ -0,0 +1,115 @@ +use { + crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASE_SEED}, + errors::AssetLeasingError, + state::{Lease, LeaseStatus}, + }, + quasar_lang::prelude::*, + quasar_spl::{Mint, Token, TokenCpi}, +}; + +/// Accounts for settling the lease fee on an `Active` lease. Permissionless: the +/// lessee has every incentive to keep the lease current, but a keeper bot +/// could also push a lease fee payment before a liquidation check. +#[derive(Accounts)] +pub struct PayLeaseFee<'info> { + #[account(mut)] + pub payer: &'info Signer, + + /// program-derived address seed + `has_one` target. Not read directly. + pub lessor: &'info UncheckedAccount, + + #[account( + mut, + seeds = [LEASE_SEED, lessor], + bump = lease.bump, + has_one = lessor, + has_one = collateral_mint, + constraint = LeaseStatus::from_u8(lease.status) == Some(LeaseStatus::Active) + @ AssetLeasingError::InvalidLeaseStatus, + )] + pub lease: &'info mut Account, + + pub collateral_mint: &'info Account, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease], + bump = lease.collateral_vault_bump, + )] + pub collateral_vault: &'info mut Account, + + /// Lessor's collateral token account. Pre-created by the caller. + #[account(mut)] + pub lessor_collateral_account: &'info mut Account, + + pub token_program: &'info Program, +} + +#[inline(always)] +pub fn handle_pay_lease_fee(accounts: &mut PayLeaseFee) -> Result<(), ProgramError> { + let now = ::get()?.unix_timestamp.get(); + + let lease_fee_amount = compute_lease_fee_due(accounts.lease, now)?; + + if lease_fee_amount == 0 { + update_last_paid_timestamp(accounts.lease, now); + return Ok(()); + } + + // Cap lease fees at whatever collateral actually sits in the vault. If the + // lessee under-collateralised we cannot magically create funds; the + // remainder is their debt and can trigger liquidation. + let collateral_amount = accounts.lease.collateral_amount.get(); + let payable = lease_fee_amount.min(collateral_amount); + + if payable > 0 { + let lease_address = *accounts.lease.address(); + let collateral_vault_bump = [accounts.lease.collateral_vault_bump]; + let vault_seeds: &[Seed] = &[ + Seed::from(COLLATERAL_VAULT_SEED), + Seed::from(lease_address.as_ref()), + Seed::from(&collateral_vault_bump as &[u8]), + ]; + accounts + .token_program + .transfer( + accounts.collateral_vault, + accounts.lessor_collateral_account, + accounts.collateral_vault, + payable, + ) + .invoke_signed(vault_seeds)?; + + let new_collateral = collateral_amount + .checked_sub(payable) + .ok_or(AssetLeasingError::MathOverflow)?; + accounts.lease.collateral_amount = new_collateral.into(); + } + + update_last_paid_timestamp(accounts.lease, now); + Ok(()) +} + +/// Lease fee accrues linearly: `(min(now, end_timestamp) - last_paid_timestamp) * rate`. +/// Shared with `return_lease` and `liquidate` for final settlement. +pub fn compute_lease_fee_due(lease: &Lease, now: i64) -> Result { + let end_timestamp = lease.end_timestamp.get(); + let last_paid = lease.last_paid_timestamp.get(); + let cutoff = now.min(end_timestamp); + if cutoff <= last_paid { + return Ok(0); + } + let elapsed = (cutoff - last_paid) as u64; + elapsed + .checked_mul(lease.lease_fee_per_second.get()) + .ok_or_else(|| AssetLeasingError::MathOverflow.into()) +} + +/// Advance `last_paid_timestamp`, but never past `end_timestamp` โ€” once the lease +/// is over, extra Lease fees do not accrue. +pub fn update_last_paid_timestamp(lease: &mut Lease, now: i64) { + let end_timestamp = lease.end_timestamp.get(); + let capped = now.min(end_timestamp); + lease.last_paid_timestamp = capped.into(); +} diff --git a/defi/asset-leasing/quasar/src/instructions/return_lease.rs b/defi/asset-leasing/quasar/src/instructions/return_lease.rs new file mode 100644 index 000000000..bf535e5d2 --- /dev/null +++ b/defi/asset-leasing/quasar/src/instructions/return_lease.rs @@ -0,0 +1,177 @@ +use { + crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, + errors::AssetLeasingError, + instructions::pay_lease_fee::{compute_lease_fee_due, update_last_paid_timestamp}, + state::{Lease, LeaseStatus}, + }, + quasar_lang::prelude::*, + quasar_spl::{Mint, Token, TokenCpi}, +}; + +/// Accounts for the happy-path return. Lessee hands the leased tokens +/// back, pays accrued lease fees out of their collateral, and receives whatever +/// collateral is left. Both vaults are closed so the lessor recoups the +/// rent-exempt lamports. +#[derive(Accounts)] +pub struct ReturnLease<'info> { + #[account(mut)] + pub lessee: &'info Signer, + + /// Receives the leased tokens + any accrued lease fees + the vaults' + /// rent-exempt lamports. + #[account(mut)] + pub lessor: &'info UncheckedAccount, + + #[account( + mut, + seeds = [LEASE_SEED, lessor], + bump = lease.bump, + has_one = lessor, + has_one = leased_mint, + has_one = collateral_mint, + constraint = lease.lessee == *lessee.address() @ AssetLeasingError::Unauthorised, + constraint = LeaseStatus::from_u8(lease.status) == Some(LeaseStatus::Active) + @ AssetLeasingError::InvalidLeaseStatus, + close = lessor, + )] + pub lease: &'info mut Account, + + pub leased_mint: &'info Account, + pub collateral_mint: &'info Account, + + #[account( + mut, + seeds = [LEASED_VAULT_SEED, lease], + bump = lease.leased_vault_bump, + )] + pub leased_vault: &'info mut Account, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease], + bump = lease.collateral_vault_bump, + )] + pub collateral_vault: &'info mut Account, + + #[account(mut)] + pub lessee_leased_account: &'info mut Account, + + #[account(mut)] + pub lessee_collateral_account: &'info mut Account, + + /// Lessor's leased-mint token account. Pre-created by the caller. + #[account(mut)] + pub lessor_leased_account: &'info mut Account, + + /// Lessor's collateral-mint token account. Pre-created by the caller. + #[account(mut)] + pub lessor_collateral_account: &'info mut Account, + + pub token_program: &'info Program, +} + +#[inline(always)] +pub fn handle_return_lease(accounts: &mut ReturnLease) -> Result<(), ProgramError> { + let now = ::get()?.unix_timestamp.get(); + let lease_address = *accounts.lease.address(); + let leased_amount = accounts.lease.leased_amount.get(); + + // 1. Lessee returns leased tokens to the leased vault (full amount). + accounts + .token_program + .transfer( + accounts.lessee_leased_account, + accounts.leased_vault, + accounts.lessee, + leased_amount, + ) + .invoke()?; + + // 2. Forward leased tokens from the vault to the lessor. + let leased_vault_bump = [accounts.lease.leased_vault_bump]; + let leased_vault_seeds: &[Seed] = &[ + Seed::from(LEASED_VAULT_SEED), + Seed::from(lease_address.as_ref()), + Seed::from(&leased_vault_bump as &[u8]), + ]; + accounts + .token_program + .transfer( + accounts.leased_vault, + accounts.lessor_leased_account, + accounts.leased_vault, + leased_amount, + ) + .invoke_signed(leased_vault_seeds)?; + + // 3. Settle accrued lease fees: collateral vault -> lessor. + let lease_fee_due = compute_lease_fee_due(accounts.lease, now)?; + let collateral_amount = accounts.lease.collateral_amount.get(); + let lease_fee_payable = lease_fee_due.min(collateral_amount); + + let collateral_vault_bump = [accounts.lease.collateral_vault_bump]; + let collateral_vault_seeds: &[Seed] = &[ + Seed::from(COLLATERAL_VAULT_SEED), + Seed::from(lease_address.as_ref()), + Seed::from(&collateral_vault_bump as &[u8]), + ]; + + if lease_fee_payable > 0 { + accounts + .token_program + .transfer( + accounts.collateral_vault, + accounts.lessor_collateral_account, + accounts.collateral_vault, + lease_fee_payable, + ) + .invoke_signed(collateral_vault_seeds)?; + } + + // 4. Refund remaining collateral to the lessee. Returning early does + // not entitle the lessee to a future-lease-fee refund โ€” Lease fees only accrue + // for time actually used, so `compute_lease_fee_due` already excludes the + // unused tail. + let collateral_after_lease_fees = collateral_amount + .checked_sub(lease_fee_payable) + .ok_or(AssetLeasingError::MathOverflow)?; + + if collateral_after_lease_fees > 0 { + accounts + .token_program + .transfer( + accounts.collateral_vault, + accounts.lessee_collateral_account, + accounts.collateral_vault, + collateral_after_lease_fees, + ) + .invoke_signed(collateral_vault_seeds)?; + } + + // 5. Close both vaults so the rent-exempt lamports flow to the lessor + // โ€” the lessee only pays for the temporary state they held while the + // lease was active. + accounts + .token_program + .close_account( + accounts.leased_vault, + accounts.lessor, + accounts.leased_vault, + ) + .invoke_signed(leased_vault_seeds)?; + accounts + .token_program + .close_account( + accounts.collateral_vault, + accounts.lessor, + accounts.collateral_vault, + ) + .invoke_signed(collateral_vault_seeds)?; + + update_last_paid_timestamp(accounts.lease, now); + accounts.lease.collateral_amount = 0u64.into(); + accounts.lease.status = LeaseStatus::Closed as u8; + + Ok(()) +} diff --git a/defi/asset-leasing/quasar/src/instructions/take_lease.rs b/defi/asset-leasing/quasar/src/instructions/take_lease.rs new file mode 100644 index 000000000..2e007906c --- /dev/null +++ b/defi/asset-leasing/quasar/src/instructions/take_lease.rs @@ -0,0 +1,117 @@ +use { + crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, + errors::AssetLeasingError, + state::{Lease, LeaseStatus}, + }, + quasar_lang::prelude::*, + quasar_spl::{Mint, Token, TokenCpi}, +}; + +/// Accounts for accepting a `Listed` lease. The lessee posts their +/// collateral, receives the leased tokens, and the lease transitions to +/// `Active` โ€” all atomically. +#[derive(Accounts)] +pub struct TakeLease<'info> { + #[account(mut)] + pub lessee: &'info Signer, + + /// Pubkey of the lessor who created the lease. Referenced only for + /// `Lease` program-derived address derivation and the `has_one` check below. + pub lessor: &'info UncheckedAccount, + + #[account( + mut, + seeds = [LEASE_SEED, lessor], + bump = lease.bump, + has_one = lessor, + has_one = leased_mint, + has_one = collateral_mint, + constraint = LeaseStatus::from_u8(lease.status) == Some(LeaseStatus::Listed) + @ AssetLeasingError::InvalidLeaseStatus, + )] + pub lease: &'info mut Account, + + pub leased_mint: &'info Account, + pub collateral_mint: &'info Account, + + #[account( + mut, + seeds = [LEASED_VAULT_SEED, lease], + bump = lease.leased_vault_bump, + )] + pub leased_vault: &'info mut Account, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease], + bump = lease.collateral_vault_bump, + )] + pub collateral_vault: &'info mut Account, + + /// Lessee's existing collateral-mint token account โ€” must hold at least + /// `required_collateral_amount` before calling. + #[account(mut)] + pub lessee_collateral_account: &'info mut Account, + + /// Lessee's leased-mint token account. Must be pre-created by the + /// caller (see the Quasar section of the README for the rationale). + #[account(mut)] + pub lessee_leased_account: &'info mut Account, + + pub token_program: &'info Program, +} + +#[inline(always)] +pub fn handle_take_lease(accounts: &mut TakeLease) -> Result<(), ProgramError> { + let now = ::get()?.unix_timestamp.get(); + + let required_collateral_amount = accounts.lease.required_collateral_amount.get(); + let leased_amount = accounts.lease.leased_amount.get(); + let duration_seconds = accounts.lease.duration_seconds.get(); + + // Lessee deposits collateral first so a failed leased-token transfer + // (e.g. vault under-funded) rolls back their deposit atomically. + accounts + .token_program + .transfer( + accounts.lessee_collateral_account, + accounts.collateral_vault, + accounts.lessee, + required_collateral_amount, + ) + .invoke()?; + + // Pay out leased tokens from the vault program-derived address. Signer seeds reproduce the + // vault's derivation: [LEASED_VAULT_SEED, lease, bump]. + let leased_vault_bump = [accounts.lease.leased_vault_bump]; + let lease_address = *accounts.lease.address(); + let vault_seeds: &[Seed] = &[ + Seed::from(LEASED_VAULT_SEED), + Seed::from(lease_address.as_ref()), + Seed::from(&leased_vault_bump as &[u8]), + ]; + accounts + .token_program + .transfer( + accounts.leased_vault, + accounts.lessee_leased_account, + accounts.leased_vault, + leased_amount, + ) + .invoke_signed(vault_seeds)?; + + let end_timestamp = now + .checked_add(duration_seconds) + .ok_or(AssetLeasingError::MathOverflow)?; + + let lease = &mut accounts.lease; + lease.lessee = *accounts.lessee.address(); + lease.collateral_amount = required_collateral_amount.into(); + lease.start_timestamp = now.into(); + lease.end_timestamp = end_timestamp.into(); + lease.last_paid_timestamp = now.into(); + lease.status = LeaseStatus::Active as u8; + + Ok(()) +} diff --git a/defi/asset-leasing/quasar/src/instructions/top_up_collateral.rs b/defi/asset-leasing/quasar/src/instructions/top_up_collateral.rs new file mode 100644 index 000000000..5fb1292ab --- /dev/null +++ b/defi/asset-leasing/quasar/src/instructions/top_up_collateral.rs @@ -0,0 +1,75 @@ +use { + crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASE_SEED}, + errors::AssetLeasingError, + state::{Lease, LeaseStatus}, + }, + quasar_lang::prelude::*, + quasar_spl::{Mint, Token, TokenCpi}, +}; + +/// Accounts for increasing collateral on an `Active` lease. Only the +/// registered lessee may call โ€” anyone else hitting the program returns +/// `Unauthorised`. +#[derive(Accounts)] +pub struct TopUpCollateral<'info> { + #[account(mut)] + pub lessee: &'info Signer, + + /// program-derived address seed only โ€” not read directly. + pub lessor: &'info UncheckedAccount, + + #[account( + mut, + seeds = [LEASE_SEED, lessor], + bump = lease.bump, + has_one = lessor, + has_one = collateral_mint, + constraint = lease.lessee == *lessee.address() @ AssetLeasingError::Unauthorised, + constraint = LeaseStatus::from_u8(lease.status) == Some(LeaseStatus::Active) + @ AssetLeasingError::InvalidLeaseStatus, + )] + pub lease: &'info mut Account, + + pub collateral_mint: &'info Account, + + #[account( + mut, + seeds = [COLLATERAL_VAULT_SEED, lease], + bump = lease.collateral_vault_bump, + )] + pub collateral_vault: &'info mut Account, + + #[account(mut)] + pub lessee_collateral_account: &'info mut Account, + + pub token_program: &'info Program, +} + +#[inline(always)] +pub fn handle_top_up_collateral( + accounts: &mut TopUpCollateral, + amount: u64, +) -> Result<(), ProgramError> { + require!(amount > 0, AssetLeasingError::InvalidCollateralAmount); + + accounts + .token_program + .transfer( + accounts.lessee_collateral_account, + accounts.collateral_vault, + accounts.lessee, + amount, + ) + .invoke()?; + + let new_collateral = accounts + .lease + .collateral_amount + .get() + .checked_add(amount) + .ok_or(AssetLeasingError::MathOverflow)?; + accounts.lease.collateral_amount = new_collateral.into(); + + Ok(()) +} diff --git a/defi/asset-leasing/quasar/src/lib.rs b/defi/asset-leasing/quasar/src/lib.rs new file mode 100644 index 000000000..ac4714852 --- /dev/null +++ b/defi/asset-leasing/quasar/src/lib.rs @@ -0,0 +1,89 @@ +#![cfg_attr(not(test), no_std)] + +use quasar_lang::prelude::*; + +mod constants; +mod errors; +mod instructions; +mod state; + +use instructions::*; +#[cfg(test)] +mod tests; + +// Same program id as the Anchor version so off-chain tooling that derives +// program-derived addresses or looks up the program onchain works against both binaries +// interchangeably. +declare_id!("Lease11111111111111111111111111111111111111"); + +/// Asset-leasing program: fixed-term token leases with a streaming lease fee +/// payment, collateral escrow, and Pyth-oracle-triggered liquidation. +/// +/// See the top-level `defi/asset-leasing/anchor/README.md` for the full +/// mechanics โ€” the Quasar and Anchor versions are functionally identical. +#[program] +mod quasar_asset_leasing { + use super::*; + + /// Discriminators are packed densely starting from 0 so the wire format + /// stays a single byte. The order matches the natural user-facing + /// lifecycle (create โ†’ take โ†’ pay/top-up โ†’ return/liquidate/close). + #[instruction(discriminator = 0)] + pub fn create_lease( + context: Ctx, + lease_id: u64, + leased_amount: u64, + required_collateral_amount: u64, + lease_fee_per_second: u64, + duration_seconds: i64, + maintenance_margin_basis_points: u16, + liquidation_bounty_basis_points: u16, + feed_id: [u8; 32], + ) -> Result<(), ProgramError> { + instructions::handle_create_lease( + &mut context.accounts, + lease_id, + leased_amount, + required_collateral_amount, + lease_fee_per_second, + duration_seconds, + maintenance_margin_basis_points, + liquidation_bounty_basis_points, + feed_id, + &context.bumps, + ) + } + + #[instruction(discriminator = 1)] + pub fn take_lease(context: Ctx) -> Result<(), ProgramError> { + instructions::handle_take_lease(&mut context.accounts) + } + + #[instruction(discriminator = 2)] + pub fn pay_lease_fee(context: Ctx) -> Result<(), ProgramError> { + instructions::handle_pay_lease_fee(&mut context.accounts) + } + + #[instruction(discriminator = 3)] + pub fn top_up_collateral( + context: Ctx, + amount: u64, + ) -> Result<(), ProgramError> { + instructions::handle_top_up_collateral(&mut context.accounts, amount) + } + + #[instruction(discriminator = 4)] + pub fn return_lease(context: Ctx) -> Result<(), ProgramError> { + instructions::handle_return_lease(&mut context.accounts) + } + + #[instruction(discriminator = 5)] + pub fn liquidate(context: Ctx) -> Result<(), ProgramError> { + instructions::handle_liquidate(&mut context.accounts) + } + + #[instruction(discriminator = 6)] + pub fn close_expired(context: Ctx) -> Result<(), ProgramError> { + instructions::handle_close_expired(&mut context.accounts) + } +} diff --git a/defi/asset-leasing/quasar/src/state.rs b/defi/asset-leasing/quasar/src/state.rs new file mode 100644 index 000000000..49f148fad --- /dev/null +++ b/defi/asset-leasing/quasar/src/state.rs @@ -0,0 +1,88 @@ +use quasar_lang::prelude::*; + +/// Lifecycle of a lease. Stored as a single byte on `Lease` and driven by +/// the program โ€” a user cannot write to it directly. +/// +/// The final `Closed` / `Liquidated` states are set *before* the account is +/// closed by its handler, so the transaction log records the terminal state +/// even though the account itself disappears at the end of the transaction. +#[repr(u8)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum LeaseStatus { + Listed = 0, + Active = 1, + Liquidated = 2, + Closed = 3, +} + +impl LeaseStatus { + pub fn from_u8(byte: u8) -> Option { + match byte { + 0 => Some(Self::Listed), + 1 => Some(Self::Active), + 2 => Some(Self::Liquidated), + 3 => Some(Self::Closed), + _ => None, + } + } +} + +/// Persistent per-lease state. Created on `create_lease`, closed on +/// `return_lease` / `liquidate` / `close_expired`. +/// +/// Field order mirrors the Anchor version; integers are promoted to their +/// `PodXX` counterparts by the `#[account]` macro so the struct stays +/// alignment-1 and the onchain bytes match Anchor's little-endian layout +/// (after the one-byte Quasar discriminator replaces Anchor's 8-byte +/// sha256 prefix). +#[account(discriminator = 1)] +pub struct Lease { + /// Caller-supplied id so one lessor can run many leases in parallel. + pub lease_id: u64, + + /// Signer of `create_lease`; paid the lease fee and any final recovery. + pub lessor: Address, + + /// Signer of `take_lease`. `Address::default()` while still `Listed`. + pub lessee: Address, + + pub leased_mint: Address, + /// Locked at creation, unchanging for the life of the lease. + pub leased_amount: u64, + + pub collateral_mint: Address, + /// Decreases as lease fee streams out; increases on `top_up_collateral`. + pub collateral_amount: u64, + /// What the lessee must post on `take_lease`. + pub required_collateral_amount: u64, + + /// Denominated in collateral-mint base units per second. + pub lease_fee_per_second: u64, + pub duration_seconds: i64, + /// `0` while `Listed`; `unix_timestamp` of `take_lease` while `Active`. + pub start_timestamp: i64, + /// `0` while `Listed`; `start_timestamp + duration_seconds` once `Active`. + pub end_timestamp: i64, + /// Lease fee accrues from here to `min(now, end_timestamp)`. + pub last_paid_timestamp: i64, + + /// Collateral-over-debt ratio in basis points. + /// `12_000` basis points = 120%. Capped at `MAX_MAINTENANCE_MARGIN_BASIS_POINTS`. + pub maintenance_margin_basis_points: u16, + /// Keeper's cut of the post-lease-fee collateral on liquidation, in basis + /// points. Capped at `MAX_LIQUIDATION_BOUNTY_BASIS_POINTS` to stop a malicious + /// lessor from draining the recovery pool via the bounty. + pub liquidation_bounty_basis_points: u16, + + /// Pyth feed id this lease is pinned to at creation. Enforced on every + /// `liquidate` so a keeper cannot swap in an unrelated feed to force an + /// underwater verdict. + pub feed_id: [u8; 32], + + /// Current lifecycle state. See [`LeaseStatus`]. + pub status: u8, + + pub bump: u8, + pub leased_vault_bump: u8, + pub collateral_vault_bump: u8, +} diff --git a/defi/asset-leasing/quasar/src/tests.rs b/defi/asset-leasing/quasar/src/tests.rs new file mode 100644 index 000000000..3b3e560be --- /dev/null +++ b/defi/asset-leasing/quasar/src/tests.rs @@ -0,0 +1,936 @@ +//! Quasar-SVM tests for the asset-leasing program. +//! +//! Covers the full lifecycle: listing, taking, lease fee streaming, top-ups, +//! early return, keeper liquidation via a mocked Pyth `PriceUpdateV2` +//! account, and lessor-initiated default recovery after expiry. +//! +//! Each test constructs a fresh `QuasarSvm`, synthesises the minimal set +//! of accounts that handler needs (mints, token accounts, the existing +//! lease state where relevant), and submits a manually-assembled +//! instruction. State updates are read straight back out of the SVM. + +extern crate std; + +use { + alloc::{vec, vec::Vec}, + quasar_svm::{Account, Instruction, Pubkey, QuasarSvm}, + solana_instruction::AccountMeta, + spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, + std::println, +}; + +use crate::{ + constants::{COLLATERAL_VAULT_SEED, LEASED_VAULT_SEED, LEASE_SEED}, + state::LeaseStatus, +}; + +// --------------------------------------------------------------------------- +// Shared test constants +// --------------------------------------------------------------------------- + +/// USDC-style decimals keep the arithmetic readable in asserts. +const DECIMALS: u8 = 6; + +/// 100 leased tokens at 6 decimals. +const LEASED_AMOUNT: u64 = 100_000_000; +/// 200 collateral tokens at 6 decimals. +const REQUIRED_COLLATERAL: u64 = 200_000_000; +const LEASE_FEE_PER_SECOND: u64 = 10; +/// 24 hours. +const DURATION_SECONDS: i64 = 60 * 60 * 24; +/// 120% maintenance margin, in basis points. +const MAINTENANCE_MARGIN_BASIS_POINTS: u16 = 12_000; +/// 5% keeper bounty, in basis points. +const LIQUIDATION_BOUNTY_BASIS_POINTS: u16 = 500; +/// Arbitrary 32-byte Pyth feed id the tests pin their leases to. +const FEED_ID: [u8; 32] = [0xAB; 32]; + +/// LiteSVM's default clock starts at epoch 0; anchoring at a recent-ish +/// real timestamp keeps lease fee math free of sign-weirdness without any +/// tests having to special-case `start_timestamp = 0`. +const DEFAULT_TIMESTAMP: i64 = 1_700_000_000; + +/// Starting wallet balance for lessor and lessee token accounts. +const STARTING_BALANCE: u64 = 1_000_000_000; + +/// Pyth receiver program id on mainnet/devnet. Matches +/// [`crate::instructions::liquidate::PYTH_RECEIVER_PROGRAM_ID`]. +fn pyth_receiver_pubkey() -> Pubkey { + Pubkey::from([ + 12, 183, 250, 187, 82, 247, 166, 72, 187, 91, 49, 125, 154, 1, 139, 144, 87, 203, 2, 71, + 116, 250, 254, 1, 230, 196, 223, 152, 204, 56, 88, 129, + ]) +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +fn setup() -> QuasarSvm { + let elf = std::fs::read("target/deploy/quasar_asset_leasing.so") + .expect("build the program with `quasar build` before running tests"); + let mut svm = QuasarSvm::new() + .with_program(&crate::ID, &elf) + .with_token_program(); + svm.warp_to_timestamp(DEFAULT_TIMESTAMP); + svm +} + +fn signer(address: Pubkey) -> Account { + quasar_svm::token::create_keyed_system_account(&address, 1_000_000_000) +} + +fn empty(address: Pubkey) -> Account { + Account { + address, + lamports: 0, + data: vec![], + owner: quasar_svm::system_program::ID, + executable: false, + } +} + +fn mint(address: Pubkey, authority: Pubkey) -> Account { + quasar_svm::token::create_keyed_mint_account( + &address, + &Mint { + mint_authority: Some(authority).into(), + supply: STARTING_BALANCE * 4, + decimals: DECIMALS, + is_initialized: true, + freeze_authority: None.into(), + }, + ) +} + +fn token(address: Pubkey, mint: Pubkey, owner: Pubkey, amount: u64) -> Account { + quasar_svm::token::create_keyed_token_account( + &address, + &TokenAccount { + mint, + owner, + amount, + state: AccountState::Initialized, + ..TokenAccount::default() + }, + ) +} + +/// Byte offsets for reading fields out of a serialised `Lease` account. +/// Layout (after the `#[account(discriminator = 1)]` macro lowers fields +/// to pod types): 1 disc + 8 lease_id + 32 lessor + 32 lessee + 32 +/// leased_mint + 8 leased_amount + 32 collateral_mint + 8 collateral_amount +/// + 8 required_collateral + 8 lease_fee_per_second + 8 duration + 8 start_timestamp + +/// 8 end_timestamp + 8 last_paid_timestamp + 2 margin_basis_points + 2 bounty_basis_points + 32 +/// feed_id + 4 status/bumps = 249 bytes. +mod lease_offsets { + pub const COLLATERAL_AMOUNT: usize = 1 + 8 + 32 + 32 + 32 + 8 + 32; + pub const LAST_PAID_TIMESTAMP: usize = COLLATERAL_AMOUNT + 8 + 8 + 8 + 8 + 8 + 8; + pub const STATUS: usize = LAST_PAID_TIMESTAMP + 8 + 2 + 2 + 32; +} + +fn read_collateral_amount(data: &[u8]) -> u64 { + u64::from_le_bytes( + data[lease_offsets::COLLATERAL_AMOUNT..lease_offsets::COLLATERAL_AMOUNT + 8] + .try_into() + .unwrap(), + ) +} + +fn read_status(data: &[u8]) -> u8 { + data[lease_offsets::STATUS] +} + +fn read_token_amount(account: &Account) -> u64 { + u64::from_le_bytes(account.data[64..72].try_into().unwrap()) +} + +// --------------------------------------------------------------------------- +// program-derived address derivations (mirror the program's `#[account(seeds = ...)]`) +// --------------------------------------------------------------------------- + +fn lease_program_derived_address(lessor: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[LEASE_SEED, lessor.as_ref()], &crate::ID) +} + +fn leased_vault_program_derived_address(lease: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[LEASED_VAULT_SEED, lease.as_ref()], &crate::ID) +} + +fn collateral_vault_program_derived_address(lease: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[COLLATERAL_VAULT_SEED, lease.as_ref()], &crate::ID) +} + +// --------------------------------------------------------------------------- +// Instruction builders +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn build_create_lease_data( + lease_id: u64, + leased_amount: u64, + required_collateral_amount: u64, + lease_fee_per_second: u64, + duration_seconds: i64, + maintenance_margin_basis_points: u16, + liquidation_bounty_basis_points: u16, + feed_id: [u8; 32], +) -> Vec { + let mut data = vec![0u8]; // discriminator for create_lease + data.extend_from_slice(&lease_id.to_le_bytes()); + data.extend_from_slice(&leased_amount.to_le_bytes()); + data.extend_from_slice(&required_collateral_amount.to_le_bytes()); + data.extend_from_slice(&lease_fee_per_second.to_le_bytes()); + data.extend_from_slice(&duration_seconds.to_le_bytes()); + data.extend_from_slice(&maintenance_margin_basis_points.to_le_bytes()); + data.extend_from_slice(&liquidation_bounty_basis_points.to_le_bytes()); + data.extend_from_slice(&feed_id); + data +} + +// --------------------------------------------------------------------------- +// Scenario โ€” a fresh SVM + the set of pubkeys every test needs +// --------------------------------------------------------------------------- + +struct Scenario { + lessor: Pubkey, + lessee: Pubkey, + keeper: Pubkey, + leased_mint: Pubkey, + collateral_mint: Pubkey, + /// Pre-created lessor token account for the leased mint, starts at + /// `STARTING_BALANCE`. + lessor_leased_token_account: Pubkey, + /// Lessor's collateral associated token account, starts empty. + lessor_collateral_token_account: Pubkey, + /// Lessee's collateral associated token account, starts at `STARTING_BALANCE`. + lessee_collateral_token_account: Pubkey, + /// Lessee's leased associated token account, starts empty. + lessee_leased_token_account: Pubkey, + /// Keeper's collateral associated token account, starts empty โ€” bounty destination. + keeper_collateral_token_account: Pubkey, + lease: Pubkey, + leased_vault: Pubkey, + collateral_vault: Pubkey, +} + +fn make_scenario() -> (QuasarSvm, Scenario) { + let svm = setup(); + let lessor = Pubkey::new_unique(); + let lessee = Pubkey::new_unique(); + let keeper = Pubkey::new_unique(); + let leased_mint = Pubkey::new_unique(); + let collateral_mint = Pubkey::new_unique(); + let lessor_leased_token_account = Pubkey::new_unique(); + let lessor_collateral_token_account = Pubkey::new_unique(); + let lessee_collateral_token_account = Pubkey::new_unique(); + let lessee_leased_token_account = Pubkey::new_unique(); + let keeper_collateral_token_account = Pubkey::new_unique(); + let (lease, _lease_bump) = lease_program_derived_address(&lessor); + let (leased_vault, _leased_vault_bump) = leased_vault_program_derived_address(&lease); + let (collateral_vault, _collateral_vault_bump) = collateral_vault_program_derived_address(&lease); + let scenario = Scenario { + lessor, + lessee, + keeper, + leased_mint, + collateral_mint, + lessor_leased_token_account, + lessor_collateral_token_account, + lessee_collateral_token_account, + lessee_leased_token_account, + keeper_collateral_token_account, + lease, + leased_vault, + collateral_vault, + }; + (svm, scenario) +} + +// --------------------------------------------------------------------------- +// Instruction assemblers โ€” one per handler, returning `(Instruction, +// Vec)` pairs ready to hand to `process_instruction`. +// +// The `accounts` vector order matches the order of fields in the matching +// `#[derive(Accounts)]` struct, which is also the order the handler reads +// them from. Off-by-one errors show up as ownership / signer failures, +// never as silent misbehaviour. +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn create_lease_call(scenario: &Scenario, lease_id: u64) -> (Instruction, Vec) { + // `init + seeds` fields self-sign via `invoke_signed` inside the + // program, so only the lessor (index 0) is a true signer here. + let instruction = Instruction { + program_id: crate::ID, + accounts: vec![ + AccountMeta::new(scenario.lessor, true), + AccountMeta::new_readonly(scenario.leased_mint, false), + AccountMeta::new_readonly(scenario.collateral_mint, false), + AccountMeta::new(scenario.lessor_leased_token_account, false), + AccountMeta::new(scenario.lease, false), + AccountMeta::new(scenario.leased_vault, false), + AccountMeta::new(scenario.collateral_vault, false), + AccountMeta::new_readonly(quasar_svm::solana_sdk_ids::sysvar::rent::ID, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(quasar_svm::system_program::ID, false), + ], + data: build_create_lease_data( + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + LEASE_FEE_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, + FEED_ID, + ), + }; + + let accounts = vec![ + signer(scenario.lessor), + mint(scenario.leased_mint, scenario.lessor), + mint(scenario.collateral_mint, scenario.lessor), + token(scenario.lessor_leased_token_account, scenario.leased_mint, scenario.lessor, STARTING_BALANCE), + empty(scenario.lease), + empty(scenario.leased_vault), + empty(scenario.collateral_vault), + ]; + + (instruction, accounts) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[test] +fn create_lease_locks_tokens_and_lists() { + let (mut svm, scenario) = make_scenario(); + let (instruction, accounts) = create_lease_call(&scenario, 1); + let result = svm.process_instruction(&instruction, &accounts); + assert!(result.is_ok(), "create_lease failed: {:?}", result.raw_result); + + // Lease created, vaults initialised. + let lease_account = result.account(&scenario.lease).expect("lease program-derived address missing"); + assert_eq!(lease_account.owner, crate::ID); + assert_eq!(read_status(&lease_account.data), LeaseStatus::Listed as u8); + + // Leased tokens escrowed; lessor balance dropped. + let leased_vault = result.account(&scenario.leased_vault).unwrap(); + assert_eq!(read_token_amount(leased_vault), LEASED_AMOUNT); + let lessor_token_account = result.account(&scenario.lessor_leased_token_account).unwrap(); + assert_eq!(read_token_amount(lessor_token_account), STARTING_BALANCE - LEASED_AMOUNT); + + // Collateral vault exists, empty. + let collateral_vault = result.account(&scenario.collateral_vault).unwrap(); + assert_eq!(read_token_amount(collateral_vault), 0); + + println!(" CREATE CU: {}", result.compute_units_consumed); +} + +/// Second form of `create_lease` that lets a test swap the mint addresses +/// โ€” used to exercise the same-mint rejection path. +#[allow(clippy::too_many_arguments)] +fn create_lease_call_with_mints( + scenario: &Scenario, + lease_id: u64, + leased_mint: Pubkey, + collateral_mint: Pubkey, +) -> (Instruction, Vec) { + let instruction = Instruction { + program_id: crate::ID, + accounts: vec![ + AccountMeta::new(scenario.lessor, true), + AccountMeta::new_readonly(leased_mint, false), + AccountMeta::new_readonly(collateral_mint, false), + AccountMeta::new(scenario.lessor_leased_token_account, false), + AccountMeta::new(scenario.lease, false), + AccountMeta::new(scenario.leased_vault, false), + AccountMeta::new(scenario.collateral_vault, false), + AccountMeta::new_readonly(quasar_svm::solana_sdk_ids::sysvar::rent::ID, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(quasar_svm::system_program::ID, false), + ], + data: build_create_lease_data( + lease_id, + LEASED_AMOUNT, + REQUIRED_COLLATERAL, + LEASE_FEE_PER_SECOND, + DURATION_SECONDS, + MAINTENANCE_MARGIN_BASIS_POINTS, + LIQUIDATION_BOUNTY_BASIS_POINTS, + FEED_ID, + ), + }; + let accounts = vec![ + signer(scenario.lessor), + mint(leased_mint, scenario.lessor), + mint(collateral_mint, scenario.lessor), + token(scenario.lessor_leased_token_account, leased_mint, scenario.lessor, STARTING_BALANCE), + empty(scenario.lease), + empty(scenario.leased_vault), + empty(scenario.collateral_vault), + ]; + (instruction, accounts) +} + +/// Pyth `PriceUpdateV2` body with only the fields liquidate actually reads +/// populated; everything else is zeroed. +fn build_price_update_data( + feed_id: [u8; 32], + price: i64, + exponent: i32, + publish_time: i64, +) -> Vec { + // 8 disc + 32 write_authority + 1 verification_level + 32 feed_id + + // 8 price + 8 conf + 4 exponent + 8 publish_time + 8 prev_publish_time + + // 8 ema_price + 8 ema_conf + 8 posted_slot = 141 bytes. + const TOTAL_LEN: usize = 141; + const PRICE_UPDATE_V2_DISCRIMINATOR: [u8; 8] = [34, 241, 35, 99, 157, 126, 244, 205]; + let mut data = Vec::with_capacity(TOTAL_LEN); + data.extend_from_slice(&PRICE_UPDATE_V2_DISCRIMINATOR); + data.extend_from_slice(&[0u8; 32]); // write_authority + data.push(1); // verification_level = Full + data.extend_from_slice(&feed_id); + data.extend_from_slice(&price.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); // conf + data.extend_from_slice(&exponent.to_le_bytes()); + data.extend_from_slice(&publish_time.to_le_bytes()); + data.extend_from_slice(&publish_time.to_le_bytes()); // prev_publish_time + data.extend_from_slice(&0i64.to_le_bytes()); // ema_price + data.extend_from_slice(&0u64.to_le_bytes()); // ema_conf + data.extend_from_slice(&0u64.to_le_bytes()); // posted_slot + data +} + +fn install_price_update( + svm: &mut QuasarSvm, + address: Pubkey, + feed_id: [u8; 32], + price: i64, + exponent: i32, + publish_time: i64, +) { + let data = build_price_update_data(feed_id, price, exponent, publish_time); + svm.set_account(Account { + address, + lamports: 10_000_000, + data, + owner: pyth_receiver_pubkey(), + executable: false, + }); +} + +fn take_lease_call(scenario: &Scenario) -> (Instruction, Vec) { + let instruction = Instruction { + program_id: crate::ID, + accounts: vec![ + AccountMeta::new(scenario.lessee, true), + AccountMeta::new_readonly(scenario.lessor, false), + AccountMeta::new(scenario.lease, false), + AccountMeta::new_readonly(scenario.leased_mint, false), + AccountMeta::new_readonly(scenario.collateral_mint, false), + AccountMeta::new(scenario.leased_vault, false), + AccountMeta::new(scenario.collateral_vault, false), + AccountMeta::new(scenario.lessee_collateral_token_account, false), + AccountMeta::new(scenario.lessee_leased_token_account, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + ], + data: vec![1u8], // discriminator = take_lease + }; + let accounts = vec![ + signer(scenario.lessee), + empty(scenario.lessor), + // `lease` is sourced from the SVM database, already pre-installed. + mint(scenario.leased_mint, scenario.lessor), + mint(scenario.collateral_mint, scenario.lessor), + // `leased_vault` and `collateral_vault` similarly pre-installed. + token( + scenario.lessee_collateral_token_account, + scenario.collateral_mint, + scenario.lessee, + STARTING_BALANCE, + ), + token(scenario.lessee_leased_token_account, scenario.leased_mint, scenario.lessee, 0), + ]; + (instruction, accounts) +} + +fn pay_lease_fee_call(scenario: &Scenario) -> (Instruction, Vec) { + let instruction = Instruction { + program_id: crate::ID, + accounts: vec![ + AccountMeta::new(scenario.lessee, true), + AccountMeta::new_readonly(scenario.lessor, false), + AccountMeta::new(scenario.lease, false), + AccountMeta::new_readonly(scenario.collateral_mint, false), + AccountMeta::new(scenario.collateral_vault, false), + AccountMeta::new(scenario.lessor_collateral_token_account, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + ], + data: vec![2u8], + }; + let accounts = vec![ + signer(scenario.lessee), + empty(scenario.lessor), + mint(scenario.collateral_mint, scenario.lessor), + token(scenario.lessor_collateral_token_account, scenario.collateral_mint, scenario.lessor, 0), + ]; + (instruction, accounts) +} + +fn top_up_call(scenario: &Scenario, amount: u64) -> (Instruction, Vec) { + let mut data = vec![3u8]; + data.extend_from_slice(&amount.to_le_bytes()); + let instruction = Instruction { + program_id: crate::ID, + accounts: vec![ + AccountMeta::new(scenario.lessee, true), + AccountMeta::new_readonly(scenario.lessor, false), + AccountMeta::new(scenario.lease, false), + AccountMeta::new_readonly(scenario.collateral_mint, false), + AccountMeta::new(scenario.collateral_vault, false), + AccountMeta::new(scenario.lessee_collateral_token_account, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + ], + data, + }; + let accounts = vec![ + signer(scenario.lessee), + empty(scenario.lessor), + mint(scenario.collateral_mint, scenario.lessor), + ]; + (instruction, accounts) +} + +fn return_lease_call(scenario: &Scenario) -> (Instruction, Vec) { + let instruction = Instruction { + program_id: crate::ID, + accounts: vec![ + AccountMeta::new(scenario.lessee, true), + AccountMeta::new(scenario.lessor, false), + AccountMeta::new(scenario.lease, false), + AccountMeta::new_readonly(scenario.leased_mint, false), + AccountMeta::new_readonly(scenario.collateral_mint, false), + AccountMeta::new(scenario.leased_vault, false), + AccountMeta::new(scenario.collateral_vault, false), + AccountMeta::new(scenario.lessee_leased_token_account, false), + AccountMeta::new(scenario.lessee_collateral_token_account, false), + AccountMeta::new(scenario.lessor_leased_token_account, false), + AccountMeta::new(scenario.lessor_collateral_token_account, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + ], + data: vec![4u8], + }; + let accounts = vec![ + signer(scenario.lessee), + empty(scenario.lessor), + mint(scenario.leased_mint, scenario.lessor), + mint(scenario.collateral_mint, scenario.lessor), + token(scenario.lessor_collateral_token_account, scenario.collateral_mint, scenario.lessor, 0), + ]; + (instruction, accounts) +} + +fn liquidate_call(scenario: &Scenario, price_update: Pubkey) -> (Instruction, Vec) { + let instruction = Instruction { + program_id: crate::ID, + accounts: vec![ + AccountMeta::new(scenario.keeper, true), + AccountMeta::new(scenario.lessor, false), + AccountMeta::new(scenario.lease, false), + AccountMeta::new_readonly(scenario.leased_mint, false), + AccountMeta::new_readonly(scenario.collateral_mint, false), + AccountMeta::new(scenario.leased_vault, false), + AccountMeta::new(scenario.collateral_vault, false), + AccountMeta::new(scenario.lessor_collateral_token_account, false), + AccountMeta::new(scenario.keeper_collateral_token_account, false), + AccountMeta::new_readonly(price_update, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + ], + data: vec![5u8], + }; + let accounts = vec![ + signer(scenario.keeper), + empty(scenario.lessor), + mint(scenario.leased_mint, scenario.lessor), + mint(scenario.collateral_mint, scenario.lessor), + token(scenario.lessor_collateral_token_account, scenario.collateral_mint, scenario.lessor, 0), + token(scenario.keeper_collateral_token_account, scenario.collateral_mint, scenario.keeper, 0), + ]; + (instruction, accounts) +} + +fn close_expired_call(scenario: &Scenario) -> (Instruction, Vec) { + let instruction = Instruction { + program_id: crate::ID, + accounts: vec![ + AccountMeta::new(scenario.lessor, true), + AccountMeta::new(scenario.lease, false), + AccountMeta::new_readonly(scenario.leased_mint, false), + AccountMeta::new_readonly(scenario.collateral_mint, false), + AccountMeta::new(scenario.leased_vault, false), + AccountMeta::new(scenario.collateral_vault, false), + AccountMeta::new(scenario.lessor_leased_token_account, false), + AccountMeta::new(scenario.lessor_collateral_token_account, false), + AccountMeta::new_readonly(quasar_svm::SPL_TOKEN_PROGRAM_ID, false), + ], + data: vec![6u8], + }; + let accounts = vec![ + signer(scenario.lessor), + mint(scenario.leased_mint, scenario.lessor), + mint(scenario.collateral_mint, scenario.lessor), + token( + scenario.lessor_leased_token_account, + scenario.leased_mint, + scenario.lessor, + STARTING_BALANCE - LEASED_AMOUNT, + ), + token(scenario.lessor_collateral_token_account, scenario.collateral_mint, scenario.lessor, 0), + ]; + (instruction, accounts) +} + +/// After a successful `create_lease`, install the resulting vault + lease +/// state in the SVM database so the next handler call has something to +/// read from. Copies the authentic onchain bytes (discriminator, token +/// amounts, lease fields) straight out of the previous execution result. +fn commit_state<'a>( + svm: &mut QuasarSvm, + result: &'a quasar_svm::ExecutionResult, + addresses: &[Pubkey], +) { + for address in addresses { + if let Some(account) = result.account(address) { + svm.set_account(Account { + address: *address, + lamports: account.lamports, + data: account.data.clone(), + owner: account.owner, + executable: account.executable, + }); + } + } +} + +#[test] +fn take_lease_posts_collateral_and_delivers_tokens() { + let (mut svm, scenario) = make_scenario(); + + // Run create_lease and commit its output (lease + both vaults). + let (create_instruction, create_accounts) = create_lease_call(&scenario, 2); + let create_result = svm.process_instruction(&create_instruction, &create_accounts); + assert!(create_result.is_ok(), "create_lease failed: {:?}", create_result.raw_result); + commit_state( + &mut svm, + &create_result, + &[scenario.lease, scenario.leased_vault, scenario.collateral_vault, scenario.lessor_leased_token_account], + ); + + // Now the lessee takes it. + let (take_instruction, take_accounts) = take_lease_call(&scenario); + let take_result = svm.process_instruction(&take_instruction, &take_accounts); + assert!(take_result.is_ok(), "take_lease failed: {:?}", take_result.raw_result); + + // Leased vault drained into the lessee. + assert_eq!( + read_token_amount(take_result.account(&scenario.leased_vault).unwrap()), + 0 + ); + assert_eq!( + read_token_amount(take_result.account(&scenario.lessee_leased_token_account).unwrap()), + LEASED_AMOUNT + ); + // Collateral moved from the lessee into the collateral vault. + assert_eq!( + read_token_amount(take_result.account(&scenario.collateral_vault).unwrap()), + REQUIRED_COLLATERAL + ); + assert_eq!( + read_token_amount(take_result.account(&scenario.lessee_collateral_token_account).unwrap()), + STARTING_BALANCE - REQUIRED_COLLATERAL + ); + // Lease transitioned Listed -> Active. + assert_eq!( + read_status(&take_result.account(&scenario.lease).unwrap().data), + LeaseStatus::Active as u8 + ); +} + +/// Helper: run create + take atomically and commit all resulting state so +/// the next call starts from an `Active` lease. +fn make_and_take(svm: &mut QuasarSvm, scenario: &Scenario) { + let (create_instruction, create_accounts) = create_lease_call(scenario, 1); + let create_result = svm.process_instruction(&create_instruction, &create_accounts); + assert!(create_result.is_ok(), "create_lease failed: {:?}", create_result.raw_result); + commit_state( + svm, + &create_result, + &[scenario.lease, scenario.leased_vault, scenario.collateral_vault, scenario.lessor_leased_token_account], + ); + + let (take_instruction, take_accounts) = take_lease_call(scenario); + let take_result = svm.process_instruction(&take_instruction, &take_accounts); + assert!(take_result.is_ok(), "take_lease failed: {:?}", take_result.raw_result); + commit_state( + svm, + &take_result, + &[ + scenario.lease, + scenario.leased_vault, + scenario.collateral_vault, + scenario.lessee_collateral_token_account, + scenario.lessee_leased_token_account, + ], + ); +} + +#[test] +fn pay_lease_fee_streams_collateral_by_elapsed_time() { + let (mut svm, scenario) = make_scenario(); + make_and_take(&mut svm, &scenario); + + // Advance clock by 2 minutes and pay the lease fee. + let elapsed: i64 = 120; + svm.warp_to_timestamp(DEFAULT_TIMESTAMP + elapsed); + let (pay_instruction, pay_accounts) = pay_lease_fee_call(&scenario); + let result = svm.process_instruction(&pay_instruction, &pay_accounts); + assert!(result.is_ok(), "pay_lease_fee failed: {:?}", result.raw_result); + + let expected_lease_fees = (elapsed as u64) * LEASE_FEE_PER_SECOND; + assert_eq!( + read_token_amount(result.account(&scenario.lessor_collateral_token_account).unwrap()), + expected_lease_fees + ); + assert_eq!( + read_token_amount(result.account(&scenario.collateral_vault).unwrap()), + REQUIRED_COLLATERAL - expected_lease_fees + ); +} + +#[test] +fn top_up_collateral_increases_vault_balance() { + let (mut svm, scenario) = make_scenario(); + make_and_take(&mut svm, &scenario); + + let top_up_amount: u64 = 50_000_000; + let (instruction, accounts) = top_up_call(&scenario, top_up_amount); + let result = svm.process_instruction(&instruction, &accounts); + assert!(result.is_ok(), "top_up failed: {:?}", result.raw_result); + + assert_eq!( + read_token_amount(result.account(&scenario.collateral_vault).unwrap()), + REQUIRED_COLLATERAL + top_up_amount + ); + // Collateral amount on the lease bumps too. + assert_eq!( + read_collateral_amount(&result.account(&scenario.lease).unwrap().data), + REQUIRED_COLLATERAL + top_up_amount + ); +} + +#[test] +fn return_lease_refunds_unused_collateral() { + let (mut svm, scenario) = make_scenario(); + make_and_take(&mut svm, &scenario); + + // Lessee returns 10 minutes in, for a 24h lease. + let elapsed: i64 = 600; + svm.warp_to_timestamp(DEFAULT_TIMESTAMP + elapsed); + + let (instruction, accounts) = return_lease_call(&scenario); + let result = svm.process_instruction(&instruction, &accounts); + assert!(result.is_ok(), "return_lease failed: {:?}", result.raw_result); + + let lease_fee_paid = (elapsed as u64) * LEASE_FEE_PER_SECOND; + let refund_expected = REQUIRED_COLLATERAL - lease_fee_paid; + + // Lessor got the full leased amount back. + assert_eq!( + read_token_amount(result.account(&scenario.lessor_leased_token_account).unwrap()), + STARTING_BALANCE + ); + // Lessor received the accrued lease fees. + assert_eq!( + read_token_amount(result.account(&scenario.lessor_collateral_token_account).unwrap()), + lease_fee_paid + ); + // Lessee got unused-time collateral back. + assert_eq!( + read_token_amount(result.account(&scenario.lessee_collateral_token_account).unwrap()), + STARTING_BALANCE - REQUIRED_COLLATERAL + refund_expected + ); + + // Both vaults closed โ€” the SVM keeps the account record but with + // lamports=0 / data empty. We check lamports drained rather than + // .is_none(), which is stricter than needed. + assert_eq!( + result.account(&scenario.leased_vault).map(|a| a.lamports).unwrap_or(0), + 0 + ); + assert_eq!( + result.account(&scenario.collateral_vault).map(|a| a.lamports).unwrap_or(0), + 0 + ); +} + +#[test] +fn liquidate_seizes_collateral_on_price_drop() { + let (mut svm, scenario) = make_scenario(); + make_and_take(&mut svm, &scenario); + + // Let 300 s of lease fees accrue so the handler settles lease fees *and* bounty + // on the same vault balance. + let elapsed: i64 = 300; + let now_timestamp = DEFAULT_TIMESTAMP + elapsed; + svm.warp_to_timestamp(now_timestamp); + + // Price 4.0 with exponent 0 โ€” debt = 400 collateral vs. 200 held, + // ratio 50% โ‰ช 120% margin. + let price_update = Pubkey::new_unique(); + install_price_update(&mut svm, price_update, FEED_ID, 4, 0, now_timestamp); + + let (instruction, accounts) = liquidate_call(&scenario, price_update); + let result = svm.process_instruction(&instruction, &accounts); + assert!(result.is_ok(), "liquidate failed: {:?}", result.raw_result); + + let lease_fee_paid = (elapsed as u64) * LEASE_FEE_PER_SECOND; + let remaining_after_lease_fees = REQUIRED_COLLATERAL - lease_fee_paid; + let bounty = remaining_after_lease_fees * (LIQUIDATION_BOUNTY_BASIS_POINTS as u64) / 10_000; + let lessor_share = remaining_after_lease_fees - bounty; + + assert_eq!( + read_token_amount(result.account(&scenario.lessor_collateral_token_account).unwrap()), + lease_fee_paid + lessor_share + ); + assert_eq!( + read_token_amount(result.account(&scenario.keeper_collateral_token_account).unwrap()), + bounty + ); + assert_eq!( + result.account(&scenario.leased_vault).map(|a| a.lamports).unwrap_or(0), + 0 + ); + assert_eq!( + result.account(&scenario.collateral_vault).map(|a| a.lamports).unwrap_or(0), + 0 + ); +} + +#[test] +fn liquidate_rejects_healthy_position() { + let (mut svm, scenario) = make_scenario(); + make_and_take(&mut svm, &scenario); + + // Price 1.0 โ†’ debt = 100 vs. 200 collateral โ†’ ratio 200% โ‰ฅ 120%. + let price_update = Pubkey::new_unique(); + install_price_update(&mut svm, price_update, FEED_ID, 1, 0, DEFAULT_TIMESTAMP); + + let (instruction, accounts) = liquidate_call(&scenario, price_update); + let result = svm.process_instruction(&instruction, &accounts); + assert!( + result.is_err(), + "healthy liquidation must fail: {:?}", + result.raw_result + ); +} + +#[test] +fn liquidate_rejects_mismatched_price_feed() { + let (mut svm, scenario) = make_scenario(); + make_and_take(&mut svm, &scenario); + + // Price that *would* trigger liquidation but with a foreign feed id โ€” + // the feed-pinning check must reject before the undercollateralisation + // math runs. + let wrong_feed_id = [0xCD; 32]; + let price_update = Pubkey::new_unique(); + install_price_update(&mut svm, price_update, wrong_feed_id, 4, 0, DEFAULT_TIMESTAMP); + + let (instruction, accounts) = liquidate_call(&scenario, price_update); + let result = svm.process_instruction(&instruction, &accounts); + assert!( + result.is_err(), + "liquidate must reject foreign price feeds: {:?}", + result.raw_result + ); +} + +#[test] +fn close_expired_reclaims_collateral_after_end_timestamp() { + let (mut svm, scenario) = make_scenario(); + make_and_take(&mut svm, &scenario); + + // Jump past end_timestamp. + svm.warp_to_timestamp(DEFAULT_TIMESTAMP + DURATION_SECONDS + 1); + + let (instruction, accounts) = close_expired_call(&scenario); + let result = svm.process_instruction(&instruction, &accounts); + assert!(result.is_ok(), "close_expired failed: {:?}", result.raw_result); + + // Full collateral forfeited to the lessor. + assert_eq!( + read_token_amount(result.account(&scenario.lessor_collateral_token_account).unwrap()), + REQUIRED_COLLATERAL + ); + // Lessor's leased balance is only what remained after the initial + // escrow (the lessee kept the tokens on default). + assert_eq!( + read_token_amount(result.account(&scenario.lessor_leased_token_account).unwrap()), + STARTING_BALANCE - LEASED_AMOUNT + ); + assert_eq!( + result.account(&scenario.leased_vault).map(|a| a.lamports).unwrap_or(0), + 0 + ); + assert_eq!( + result.account(&scenario.collateral_vault).map(|a| a.lamports).unwrap_or(0), + 0 + ); +} + +#[test] +fn close_expired_cancels_listed_lease() { + let (mut svm, scenario) = make_scenario(); + let (create_instruction, create_accounts) = create_lease_call(&scenario, 1); + let create_result = svm.process_instruction(&create_instruction, &create_accounts); + assert!(create_result.is_ok(), "create_lease failed: {:?}", create_result.raw_result); + commit_state( + &mut svm, + &create_result, + &[scenario.lease, scenario.leased_vault, scenario.collateral_vault, scenario.lessor_leased_token_account], + ); + + // Lessor bails while the lease is still `Listed` โ€” allowed immediately. + let (instruction, accounts) = close_expired_call(&scenario); + let result = svm.process_instruction(&instruction, &accounts); + assert!(result.is_ok(), "close_expired on Listed failed: {:?}", result.raw_result); + + // Lessor recovered the full leased amount. No collateral was posted. + assert_eq!( + read_token_amount(result.account(&scenario.lessor_leased_token_account).unwrap()), + STARTING_BALANCE + ); + assert_eq!( + result.account(&scenario.leased_vault).map(|a| a.lamports).unwrap_or(0), + 0 + ); + assert_eq!( + result.account(&scenario.collateral_vault).map(|a| a.lamports).unwrap_or(0), + 0 + ); +} + +#[test] +fn create_lease_rejects_same_mint_for_leased_and_collateral() { + let (mut svm, scenario) = make_scenario(); + let (instruction, accounts) = create_lease_call_with_mints(&scenario, 42, scenario.leased_mint, scenario.leased_mint); + let result = svm.process_instruction(&instruction, &accounts); + assert!( + result.is_err(), + "create_lease must reject identical mints: {:?}", + result.raw_result + ); +}