feat(asset-leasing): add Quasar port and apply Mike's README feedback#7
feat(asset-leasing): add Quasar port and apply Mike's README feedback#7mikemaccana-edwardbot wants to merge 40 commits intoquiknode-labs:mainfrom
Conversation
Fixed-term leasing of SPL tokens with SPL collateral, per-second rent streaming, and Pyth-priced liquidation. Joins AMM / Escrow / Token Fundraiser / Pyth under the 'Financial Software' section. Why another DeFi primitive: leasing is the canonical 'time-bounded custody with collateral at risk' pattern. It exercises the vault PDA, clock-driven accruals, oracle-priced liquidation, and a keeper-incentive flow all in one program, so it makes a good teaching companion to the existing escrow and AMM examples which focus on one of those axes each. Design notes: - Lease PDA seeded by (lessor, lease_id); lessor can run multiple leases in parallel. - Leased and collateral tokens each sit in their own PDA-authored vault (authority = vault itself) — simpler signing than routing through the Lease PDA and keeps the seed surface small. - Rent accrues linearly against the collateral vault and is settled on every pay_rent, return_lease, and liquidate call. Rent never accrues past end_ts, so returning early does not accrue rent for unused time (and therefore no 'unused rent refund' is needed — documented in instructions/return_lease.rs). - Liquidation uses a Pyth PriceUpdateV2 account. We decode the layout by hand instead of pulling in pyth-solana-receiver-sdk because that crate currently has a transitive borsh conflict with anchor-lang 1.0.0 (oracles/pyth/anchor is flagged 'not building' in .github/.ghaignore for the same reason). - Bounty is applied to the *post-rent* collateral balance so the handler can never over-draw the vault. Tests (LiteSVM, tests/test_asset_leasing.rs) cover the full lifecycle: create → take → pay_rent → top_up → return, the happy-path liquidation with mocked Pyth price, the healthy-position liquidation rejection, and the two close_expired branches (cancel-listed + default-recovery).
…epts The previous README assumed familiarity with collateral, margin, liquidation, keepers, basis points and oracles. This rewrite teaches all of them from scratch for a developer writing their first Solana program. Structure: - plain-English intro with a car-rental analogy and a governance-leasing use case - concept-by-concept primer (SPL tokens, collateral, maintenance margin, liquidation, keepers, bps, oracles, per-second rent, PDAs, vaults) - full lifecycle walked through with concrete numbers for every path (happy, margin call, liquidation, default, cancelled listing) - expanded instructions table that explains WHY each instruction exists - accounts + PDAs reference - Pyth integration, including why we decode manually (SDK/borsh conflict) - safety/edge-case discussion - build/test section with LiteSVM explained - extension ideas and further reading Every claim in the README was cross-checked against the source files.
Three instruction handlers (liquidate, return_lease, close_expired) had near-identical `close_vault` helpers. The only difference was the destination parameter type (`&Signer` in close_expired, `&UncheckedAccount` in the other two), which was cosmetic — both ultimately called `.to_account_info()`. Move the helper to shared.rs with the destination as `&AccountInfo<'info>` so callers pass `.to_account_info()` at the call site. Deletes 3x15 lines of boilerplate. No behaviour change. All 9 litesvm tests still pass.
…ease If both mints are the same SPL mint, the two vaults' PDA derivations still collapse to different addresses (their seeds differ) but they hold the same asset — rent streams out of the same token supply the lessee posted as collateral, and the 'what do I owe vs what do I hold' invariant breaks. Guard the case at the top of `handle_create_lease` with a new error, `LeasedMintEqualsCollateralMint`. New litesvm test `create_lease_rejects_same_mint_for_leased_and_collateral` verifies the rejection using a handcrafted instruction that sets both mint fields to the leased mint. 10 tests now pass (was 9).
The previous liquidate handler trusted whatever Pyth `PriceUpdateV2` the
keeper passed in, provided the account was owned by the Pyth receiver
program. A keeper could therefore substitute a completely unrelated feed
(e.g. a volatile pair that happened to be dipping) and force a spurious
liquidation against a healthy lease.
Changes
* `Lease` gains `feed_id: [u8; 32]`, persisted at create_lease.
* `create_lease` takes `feed_id` as a parameter (updates lib.rs).
* `DecodedPriceUpdate` exposes `feed_id`; `decode_price_update`
reads bytes 41..73 of the account data.
* `handle_liquidate` compares decoded feed_id to `lease.feed_id`
and returns `PriceFeedMismatch` on mismatch.
* Test mock builder parameterises feed_id; all existing tests pin
FEED_ID = [0xAB; 32] and hand the matching value to mock price
updates.
* New test `liquidate_rejects_mismatched_price_feed` confirms a
foreign-feed price update is rejected even when the price would
otherwise flag the position as underwater.
11 tests pass (was 10).
… path The default branch of `close_expired` (lessee ghosted past end_ts, lessor takes the collateral) previously left `last_rent_paid_ts` at whatever the most recent `pay_rent` wrote, which could be strictly less than `min(now, end_ts)`. The invariant `last_rent_paid_ts <= min(now, end_ts)` held, but the stronger invariant 'timestamp points at the latest settled instant' did not. Bump `last_rent_paid_ts` via `update_last_paid_ts` on the `Active` branch. Behaviour is unchanged (the lease account is closed in the same ix) but future versions that want to split the collateral differently on default — pro-rata rent, partial refund, haircut for unused time — can now trust that everything up to `now` is already settled rather than having to re-derive it. No-op on the `Listed` branch: rent never started accruing there. All 11 tests still pass.
Full rewrite. The previous README was explanatory but relied on the
car-rental analogy as its spine. This version restructures around the
sections the repo-wide overhaul uses everywhere:
1. What does this program do? (plain English first; analogies only
after the onchain mechanics, with each tradfi term defined
briefly where it appears)
2. Glossary (account, PDA, signer, CPI, Anchor constraint, bps,
keeper, oracle, feed_id, exponent, etc.)
3. Accounts and PDAs (state + vault tables; full field list of
`Lease`; lifecycle diagram)
4. Instruction lifecycle walkthrough (one subsection per ix, with
signers / accounts / PDAs / token-flow diagrams / state changes
/ checks — in the order a user actually encounters them)
5. Full-lifecycle worked examples (happy path, liquidation path,
default-by-expiry, listed cancel — concrete numbers throughout)
6. Safety and edge cases (full error-code table, guarded design
choices, what the program does *not* guard)
7. Running the tests (+ CI note confirming `anchor build` runs
before `anchor test` in .github/workflows/anchor.yml, which
covers the `include_bytes!` concern for §6)
8. Extending the program (easy / moderate / harder tiers)
Reflects the code changes in this branch:
* Fix 1 (close_vault helper extracted) in §Code layout
* Fix 3 (LeasedMintEqualsCollateralMint) in §4.1 and §6.1
* Fix 4 (feed_id pinning) in §2, §3, §4.6, §6
* Fix 5 (last_rent_paid_ts on default path) in §4.7 and §5.3
* Fix 6 (CI) confirmed in §7
1200 lines.
Why add a Quasar port: Every other example in this repo has a Quasar sibling. Asset-leasing shipped as Anchor-only, breaking that parity. The Quasar port covers all seven instruction handlers (create_lease, take_lease, pay_rent, top_up_collateral, return_lease, liquidate, close_expired) and all eleven LiteSVM tests from the Anchor version, using the same Pyth PriceUpdateV2 layout and the same two-mint vault design. Why rewrite the README: - "Token" not "SPL Token". Tokens are the default; no qualifier needed unless contrasting with native SOL. The old phrasing treated "SPL Token" as if it were a distinct product rather than the norm. - "Instruction handler" not "instruction" when referring to the Rust function that processes the call. An *instruction* is the INPUT to a program (transaction call data); the *instruction handler* is the code. Conflating them confuses readers who are learning how the runtime dispatches work. - Dropped the Glossary. Solana already defines lamports, signers, accounts, PDAs, CPIs, etc. at https://solana.com/docs/terminology. Redefining them here is both redundant and drifts over time. The README now links there once and inline-defines only genuinely project-specific terms (maintenance margin, liquidation bounty, keeper, rent-the-stream vs rent-the-account). - Removed the Ethereum reference ("Solana's equivalent of an ERC-20"). Readers cannot be assumed to know Ethereum, and Solana is explainable on its own terms. - Rewrote the "tradfi picture" analogies. Car rentals and pawn shops are not finance — nobody at Hertz or a pawn shop says they work in finance. Replaced with real financial-markets analogies: leasing gold bars from a bullion dealer, and securities lending (borrowing stock to short). These are the actual tradfi patterns this program models. - Added a Quasar port section documenting how to build and test the new port alongside Anchor. Why the code comment sweep: Same terminology fixes applied in-code — "SPL token"/"SPL mint"/ "SPL vault" → "token"/"mint"/"vault" in doc comments across constants.rs, create_lease.rs, shared.rs, and the test file. No function, file, or struct names changed. Local validation: - Quasar: cargo test --release → 12/12 green (11 ported + test_id). - Anchor: build succeeds with --ignore-keys; the pre-existing duplicate-entrypoint linker error on the local host is unrelated to this change (present on 04367b8 before any edits). CI builds both sides cleanly.
Tokens are the default on Solana; no need to qualify with 'SPL'. Reserve 'SPL Token' for the rare case of contrasting with the native token (SOL). - README account table: 'SPL Token' -> 'token account' (matches what the column actually describes: an ATA, not a token type) - README prose: '6-decimal SPL tokens' -> '6-decimal tokens', 'same SPL mint' -> 'same mint' - Cargo.toml description: 'Fixed-term SPL token leasing' -> 'Fixed-term token leasing' Left SPL_TOKEN_PROGRAM_ID identifiers alone -- those are program IDs, not prose.
…ID in tests On Solana, 'token' is the default — the 'SPL' prefix is only meaningful when contrasting with the native token (SOL). The upstream quasar-svm crate exports the constant as SPL_TOKEN_PROGRAM_ID; since we can't rename that without touching the dependency, we alias it on import and use the clean TOKEN_PROGRAM_ID name throughout the test module. 12 tests still pass.
…PROGRAM_ID in tests" This reverts commit 001ca85.
| 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; |
There was a problem hiding this comment.
Pyth decoder uses wrong VerificationLevel byte size
Medium Severity
The hand-rolled PriceUpdateV2 decoder computes FEED_ID_OFFSET as 41 (8 disc + 32 write_authority + 1 verification_level). The Pyth SDK's own PriceUpdateV2::LEN constant uses 2 bytes for VerificationLevel (8 + 32 + 2 + …). The Partial { num_signatures: u8 } variant serializes to 2 bytes via Borsh, making all subsequent field offsets wrong by one byte on real Pyth accounts. The tests pass only because the mock sets Full verification (1 byte), matching the incorrect offset.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 709542e. Configure here.
"Rent" collides with Solana's account-rent concept. Switching to "lease fee" makes the per-second payment from lessee to lessor unambiguous. Also tightens the README: - Drops the "this README is a teaching document" / "It is not a deployed, audited production program" / "treat it as a learning example" framing — true of every program in program-examples by definition. - Drops the disambiguation disclaimer about Solana account rent vs lease rent (no longer needed once the per-second payment is named the lease fee). - Drops fungibility caveats around what the lessee returns — just says they return leased_amount of leased_mint. - Switches "neutral escrow" to "non-custodial escrow". - Adds a concrete xNVDA / USDC worked example with both rallying- and falling-NVIDIA scenarios so the asymmetric-payoff (short-like) shape of the lessee position is explicit. - Adds §4.3 (falling-price path) covering the case where the leased asset depreciates and the lessee benefits. Code rename details: - Field: Lease.rent_per_second -> Lease.lease_fee_per_second - Field: Lease.last_rent_paid_ts -> Lease.last_paid_ts - Function: compute_rent_due -> compute_lease_fee_due - Instruction: pay_rent -> pay_lease_fee (module / handler / Accounts struct / source filename) - Error: InvalidRentPerSecond -> InvalidLeaseFeePerSecond - Local variables and code comments updated to match. - Solana terminology kept verbatim where it really means account rent (rent-exempt lamports, Sysvar<Rent>, sysvar::rent::ID, rent_epoch, etc.). The Quasar port is renamed in lockstep so the two implementations stay byte-for-byte identical at the IDL level (same discriminators, same Lease layout). Quasar's tests are renamed to match. All 11 LiteSVM tests pass after the rename. Quasar `quasar build` also succeeds.
| } | ||
|
|
||
| update_last_paid_ts(&mut context.accounts.lease, now); | ||
| Ok(()) |
There was a problem hiding this comment.
Residual lease fee debt silently dropped on partial pay
Medium Severity
When the collateral vault cannot cover the full accrued lease fee, handle_pay_lease_fee still advances last_paid_ts to now, so any unpaid residual is silently forgotten. The README states the residual is preserved as debt to be cleaned up by a later liquidate or close_expired, but those handlers also recompute from last_paid_ts and so will see zero debt. If the lessee subsequently tops up collateral, the lessor never recovers the previously unpaid period.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 5f35c76. Configure here.
|
|
||
| Fixed-term leasing of SPL tokens with SPL collateral, per-second rent, and Pyth-priced liquidation — lessors list tokens, lessees post collateral, keepers liquidate undercollateralised positions. | ||
|
|
||
| [⚓ Anchor](./defi/asset-leasing/anchor) |
There was a problem hiding this comment.
Root README uses outdated terminology in Asset Leasing entry
Low Severity
The new Asset Leasing description in the root README contradicts the terminology cleanup explicitly described in this PR's user instructions. It uses SPL tokens and SPL collateral (the user instruction states Token not SPL Token) and per-second rent (the program and the in-repo README now consistently use lease fee rather than rent). The Anchor README and program code already follow the new terminology, so the root README is out of step.
Reviewed by Cursor Bugbot for commit 5f35c76. Configure here.
No abbreviations anywhere — full English words for everything visible. Drops fake "COLLA" / "LEASED" placeholder tickers, expands ts → timestamp, bps → basis_points, pda → program-derived address, ata → associated token account, cpi → cross-program invocation in comments and local names, plus the usual ctx/acc/ auth/addr/tx/cfg/amt expansions. Anchor / upstream type names (CpiContext, AccountInfo, etc.) left as-is.
| /// Keeper's collateral-mint token account — bounty destination. | ||
| /// Pre-created by the caller. | ||
| #[account(mut)] | ||
| pub keeper_collateral_account: &'info mut Account<Token>, |
There was a problem hiding this comment.
Quasar token destinations lack authority constraints
High Severity
Several Quasar handlers accept the lessor's token account purely as Account<Token> with no token::mint or token::authority check. In liquidate the keeper signs and chooses lessor_collateral_account; in pay_lease_fee any payer chooses it; in return_lease the lessee chooses both lessor_leased_account and lessor_collateral_account. Because the SPL Token program only validates mint on transfer destinations, the signer can redirect the lessor's leased tokens, lease fees, or liquidation share to a token account they control. The Anchor counterparts pin these to the lessor's ATA via associated_token::authority = lessor, so this divergence is a real loss of safety, not just a UX simplification.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 10a6caa. Configure here.
The program's lessee role is mechanically a short seller — borrow fungibles, sell now, rebuy cheaper, return equivalent. Physical- asset framing (leasing, gold bars) misled readers about what the product actually is. README now opens with "on-chain securities lending used primarily for short selling" and uses lender/borrower terminology alongside the lessor/lessee identifiers. Code unchanged.
One word, like "online". Per Solana Foundation and US government style.
| accounts.leased_vault, | ||
| leased_amount, | ||
| ) | ||
| .invoke_signed(vault_seeds)?; |
There was a problem hiding this comment.
Quasar transfers skip transfer_checked mint validation
Low Severity
The Anchor port routes every token movement through transfer_checked, which validates the mint and decimals on every CPI. The Quasar port uses token_program.transfer(...) everywhere instead, dropping that explicit mint+decimals check. Combined with several Account<Token> fields that lack any token::mint = ... constraint, this leaves mint validation entirely up to the underlying token program rather than the program enforcing it itself, weakening parity with the Anchor version.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit cde6b0d. Configure here.
WHY
- Mike's terminology rule: one canonical name per concept, used
consistently across code, tests, and docs. The old README mixed
"lessor", "lender", "long holder", "lessee", "borrower", and
"short seller" for the same two parties; the code used yet another
pair (lessor/lessee) that the README excused as a legacy artefact.
That's three names per role for readers to keep straight, which is
two too many.
- Mike's writing rules (no abbreviations, no ambiguous "it"/"this",
no "TradFi-vs-onchain" framing for things that exist onchain too,
no "skip ahead" suggestions, no inline glossary when Solana docs
already define the terms).
- "Securities lending" framing is wrong: SOL is not legally a
security, this program isn't restricted to tokenised securities,
and the mechanics work for any directional token loan.
WHAT
Code + tests:
- Renamed `lessor` -> `holder` and `lessee` -> `short_seller` across
every Rust source file, the test file, and Cargo.toml.
Includes struct fields (Lease.holder, Lease.short_seller), account
context fields (e.g. holder_leased_account,
short_seller_collateral_account), local variables, function
parameters, comments, and doc strings.
- Updated PDA seed bytes to match: b"lessor" -> b"holder",
b"lessee" -> b"short_seller". This breaks address compatibility
with the previous build, which is fine — the program is not
deployed.
- Cleaned up a couple of incidental abbreviations Mike's coding
style forbids (denom -> denominator in liquidate.rs,
liq_ix -> liquidate_instruction in tests, "8 disc" comment ->
"8 discriminator").
- All 11 LiteSVM integration tests still pass; `anchor build`
produces a clean .so.
README:
- Used "holder" and "short seller" exclusively; dropped lessor /
lessee / lender / borrower / long holder framings entirely.
- Replaced the ambiguous "if it falls" sentence with explicit
antecedents ("if the asset falls, the short seller profits").
- Reframed the TradFi parenthetical: the entities listed (ETFs,
pension funds, passive allocators) exist both in TradFi and
onchain, so dropped the "vs onchain" split.
- Removed the "you can skip straight to" suggestion. Background
sections are useful for everyone.
- Replaced "onchain securities lending" with "directional token
lending" in the lede; SOL isn't a security, and the program
works for any directional loan.
- Inline-linked Solana terminology (Anchor, instruction handler,
program-derived address, associated token account, cross-program
invocation) to https://solana.com/docs/terminology on first
occurrence. No glossary footer note.
- Removed the paragraph that excused the lessor/lessee identifiers
as legacy — those identifiers no longer exist.
- Misc consistency pass: "instruction handler" not "instruction"
for the function/code, "onchain" one word everywhere, kept
"TradFi" only where the analogy is to actual TradFi (securities
lending).
…'it' in maintenance-margin sentence - Bold 'Holders', 'rent out', 'fungible token', 'short sellers' on first occurrence so the canonical terminology stands out to the reader. - The previous sentence said 'if the borrowed asset rallies past the maintenance margin' — the asset's price doesn't cross the maintenance margin (it's a collateral ratio, not a price level). Rewritten to: the asset rallies far enough that the short seller's collateral falls below the maintenance margin.
- 'Token' already implies fungibility; the qualifier is noise. Bolded only the canonical party names (holder, short seller), not plain-English verbs/nouns. - 'Get the borrow they need' uses 'borrow' as a noun (trader jargon) — confusing for general readers, especially since the lede frames Side B as a 'short seller', not a 'borrower'. Replaced with 'get the tokens they need to sell short'.
Tokens are fungible by default; the reader doesn't need a sentence explaining that 'the same quantity, not the exact units' is fine. Same as not explaining what a string is.
The previous version mentioned the sell-and-rebuy mechanic in a single passing clause, which buried the whole point of the protocol. Restructured section 1 to walk through the short seller's four-step lifecycle: open, sell A, wait, close — and explained where profit comes from in plain English.
The lede skipped from 'post collateral' to 'return tokens' without explaining the trade in between. That trade is the whole point of the protocol — short sellers sell the borrowed tokens immediately, wait for the price to drop, then re-buy and return. Now front-loaded in the intro.
Previous wording — 'posts collateral, takes delivery of the borrowed tokens, and immediately sells them' — sounded circular. A reader could parse it as 'deposit X, get X back, sell X' which is pointless. The actual flow uses TWO different mints: post stable collateral (USDC), borrow the asset being shorted (xNVDA), sell xNVDA for more USDC, buy xNVDA back later. Spelled this out explicitly with a concrete example so the asymmetry is clear from the first paragraph.
The 'every instruction handler is walked through... background sections below define X before code walks happen' paragraph is redundant filler — the table of contents and section headlines already tell the reader what's coming. Deleted. Moved the inline solana.com/docs/terminology link for 'instruction handler' to its first occurrence in section 3, where the term is also explained in prose.
ASCII art renders inconsistently across viewers (different fonts, different terminal widths) and looks bad even when it works. Kept the prose paragraph that follows it explaining the Closed/Liquidated states aren't directly observable onchain — that's the useful bit. State transitions are already covered narratively in section 3 (per-instruction walkthrough) and section 4 (full-lifecycle worked examples). A diagram on top is redundant.
…ctions The README had two parallel structures covering the same ground: - §3 "Instruction handler lifecycle walkthrough" listed each of the seven instruction handlers in isolation (signers, accounts, what happens, errors). - §4 "Full-lifecycle worked examples" walked through five scenarios that called sequences of those same handlers. A reader had to flip between §3 (reference) and §4 (narrative) to follow what any given scenario actually did, and the happy-path scenario in §4.1 simply re-traced the §3 reference with concrete numbers attached. Replace both with a single integrated "3. Lifecycle" section: - 3.1–3.7 walk the program in narrative order — holder lists tokens, short seller takes the offer, lease fee streams, short seller defends or closes, and the two failure-mode branches (liquidate, close_expired). Each handler's full mechanics (signers, accounts, what happens, errors) are listed inline as bullet lists at first appearance. - 3.8 keeps the four pedagogically-valuable branch scenarios from old §4 (liquidation, falling-price profit, default on Active, cancel on Listed) with their concrete numbers, but rewrites them as concise branches that reference the integrated §3 prose rather than re-explaining the mechanics. The old §4.1 happy-path is dropped because §3.5 now covers it. Renumber later sections (Safety §5→§4, Tests §6→§5, Quasar §7→§6, Extending §8→§7), update the table of contents, and fix all cross-references including the §1 link to Quasar port and the §1 "see §X" pointers. Drop all ASCII flow-arrow diagrams in favour of bullet-list prose, per house style. No Mermaid added. Other style fixes applied while rewriting: replace remaining uses of "protocol" with "program" (Solana onchain code is a program; "protocol" is EVM jargon), use "instruction handler" not "instruction" when referring to the function/code, and link program-derived address / associated token account / cross-program invocation / instruction handler inline to solana.com/docs/terminology on first occurrence.
… remove section preview Two changes to the new §3 Lifecycle: 1. Drop the section-preview paragraph and the redundant 7-handler bullet list at the top of §3. Subsection headings (§3.1 through §3.7) already convey the same information; previewing them inside the section is filler. 2. Add a new opening subsection 'What the short seller really gets' that frames the trade as the cash/obligation asymmetry: at open, today's value in stables; at close, the obligation to return N tokens at whatever future price exists. This is the economic intuition that makes the rest of the lifecycle make sense before diving into mechanics. Concrete $190 → $160 example so readers immediately see how the asymmetry prints money on a price drop.
…e steps
The §1 'short seller's full lifecycle' steps described the
mechanics in plain English ('open the position', 'close the
position') without naming the instruction handlers that actually
perform those steps. The reader had no signpost connecting the
narrative to the code.
Now each step calls out the handler explicitly: take_lease,
pay_lease_fee, top_up_collateral, return_lease — plus liquidate
and close_expired in the failure paragraph. Reader can grep the
handler name straight to §3 for full mechanics.
'Worked example' is textbook/maths-lecturer language — 'here's a problem solved step-by-step'. Not wrong, but pretentious for a software README and assumes the reader knows academic conventions. Renamed: - 'Worked example: shorting xNVDA' → 'Example: shorting xNVDA' - '3.8 Worked branch scenarios' → '3.8 Branch scenarios' No cross-references needed updating.
…n A' not 'mint A' - Replaced all 74 em-dashes (\u2014) with regular dashes (-) per Mike's preference. Em-dashes are LLM-output tells; regular dashes everywhere. - Section 1 introduced the two assets as 'mint A' and 'mint B', conflating the asset itself with the mint account that controls its supply. The asset is a token; the mint is the factory. Now reads 'token A' / 'token B' for the asset, with 'leased mint' / 'collateral mint' kept ONLY where the prose is genuinely talking about the mint account (token-account field descriptions, instruction-handler walkthroughs - these correctly refer to the mint of an associated token account). Note: the old per-instruction tables in section 2 still use markdown tables. Those are a separate cleanup; flagged for follow-up.
By the time the reader reaches '### Roles' they have already met: - Holder (intro lede + section 1 first paragraph) - Short seller (intro lede + section 1 first paragraph) - Keeper (defined inline at section 1 lifecycle step 4) Re-defining all three under a separate heading is filler. Deleted.
Converts all 6 markdown tables in defi/asset-leasing/anchor/README.md to bullet lists (parameter-values example, state/data accounts, token vaults, user accounts, error catalogue, test matrix). Tables don't render cleanly on viewers narrower or wider than ~76 chars (mobile, terminal, narrow editor split); bullet lists work everywhere. No information lost; wider account tables rewritten as natural prose rather than mechanical column-by-column key/value bullets.
…crues The §1 lifecycle steps said 'a per-second lending fee accrues... settled by pay_lease_fee' which made it sound like the short seller needs to call pay_lease_fee every second. They don't. Now explained correctly: - The fee is a number that GROWS continuously against the locked collateral, but no transactions fire automatically. - The program computes the accrued fee ON DEMAND: every handler multiplies (now - last_paid_timestamp) * lease_fee_per_second and debits the result from collateral. - The short seller doesn't have to do anything while waiting; fees auto-settle at return_lease / liquidate / close_expired. - pay_lease_fee is OPTIONAL - call it to settle the running balance early so it doesn't eat into the collateral cushion.
…amed anchor links Bare '\u00a73.4' or 'See \u00a74' references force the reader to scroll back through the doc to find the section. Lawyer-style cross-references should always be clickable links to a named anchor. Replaced all 12 bare \u00a7-references with markdown links to the corresponding GitHub-auto-generated heading anchors. Verified each anchor slug against the heading text manually.
…ss-references with word-based link text WHAT: removed leading section numbers (1., 2., 3.1, 3.6, 3.8.1, etc.) from every heading in defi/asset-leasing/anchor/README.md. Updated the table of contents to point to the new numberless anchor slugs. Rewrote every cross-reference (previously written as [§3.6](...) or [Section 4 (...)](...)) to use word-based link text drawn from the heading itself, so the link reads as part of the surrounding prose. WHY: clickable links should read naturally inside a sentence rather than interrupt the reader with section-number references. Headings with leading numbers also drift out of sync the moment a section is reordered or renamed.
The README walked through the short seller's lifecycle in detail (open / sell / wait / close) but never gave the holder the same treatment. Now both parties have explicit lifecycle steps: - list tokens via create_lease (Listed status) - wait for a taker (Active) or cancel - earn fees passively while Active - get paid out at close via any of return_lease / liquidate / close_expired Plus a paragraph clarifying the two close_expired situations (cancel a Listed lease, or seize collateral on an Active default) since these are the holder's most surprising calls.
| require!( | ||
| is_underwater(&context.accounts.lease, &decoded, now)?, | ||
| AssetLeasingError::PositionHealthy | ||
| ); |
There was a problem hiding this comment.
Liquidation check ignores unsettled lease fees
Medium Severity
is_underwater is called before settling accrued lease fees, so it uses the stale collateral_amount that hasn't been decremented by unpaid fees. A position whose effective collateral (stored amount minus outstanding fees) falls below the maintenance margin can appear healthy if fees haven't been settled recently. This contradicts the README's claim that "the keeper does not need to call pay_lease_fee first." The workaround is two transactions (pay_lease_fee then liquidate), but in fast markets the delay can deepen bad debt.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit ab99204. Configure here.
WHAT: Deleted the standalone Accounts and program-derived addresses section and integrated its content into the Lifecycle handler walkthroughs. Each account is now introduced at the moment it is first created or first used: - Lease, leased_vault, collateral_vault: introduced in create_lease with seeds and roles in a new program-derived addresses bullet list, plus the Lease struct definition in a new What's on the lease account subsection. - holder, short_seller, payer, keeper user wallets: a one-line description added on first appearance in their respective handler's Signers bullet. - Associated token accounts (holder_leased_account, holder_collateral_account, short_seller_*, keeper_collateral_account): one-line associated-token-account description added on first appearance in each handler's Accounts bullet. - price_update: introduced in liquidate, where the Pyth oracle account is first passed in. - The Closed/Liquidated states paragraph moved to the end of return_lease (the first close handler), since it covers all three closing paths. Table of contents and any cross-references to the deleted section updated. WHY: A reader walking the lifecycle no longer has to flip back to a static account dump to know what each account is - the prose introduces every account the moment it appears in the narrative.
The previous wording said 'The program acts as a non-custodial escrow' and then immediately described the program taking custody of A tokens in a program-owned vault. Both can't be true. What's actually true: the program holds funds in vaults during the lease, the program-derived address signs all transfers, no admin key exists, and the rules are the deployed bytecode. Reworded to state this directly without the 'non-custodial' label, which in common DeFi usage means 'no admin can pull funds outside the rules' - a much narrower claim than 'the program never touches your funds', which is what the original phrasing sounded like.
…, not the program's Clarify that take_lease does not perform the A->B swap. The program deliberately stays narrow (a token-leasing primitive); the DEX swap belongs in the frontend, bundled with take_lease in a single tx so the short seller signs once and the atomicity is preserved by Solana's transaction semantics. Avoids scope creep, removes a Jupiter dependency from the program, and keeps the tutorial value of the example tight.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 7 total unresolved issues (including 6 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 602823f. Configure here.
| /// Lessor's collateral-mint token account. Pre-created by the caller. | ||
| #[account(mut)] | ||
| pub lessor_collateral_account: &'info mut Account<Token>, | ||
|
|
There was a problem hiding this comment.
Quasar port lacks destination account ownership validation
High Severity
lessor_leased_account and lessor_collateral_account have no ownership or authority constraints. A malicious lessee calling return_lease can substitute their own token accounts as the lessor's destinations, recovering both the returned leased tokens and the lease fee settlement while the lessor receives nothing. The same issue in liquidate lets a keeper steal the lessor's share, and in pay_lease_fee lets a payer redirect fees. The Anchor version prevents this via associated_token::authority = holder. The README frames this as a UX convenience gap, but it's an exploitable funds-theft vector.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 602823f. Configure here.
'onchain' and 'offchain' are one word, like 'online' and 'offline'. Caught while reviewing the Quasar skill PR which had the same slip.
Explains why this program uses bilateral lending (1:1 deals between one holder and one short seller) instead of a pooled-lending design like Kamino or MarginFi. Frames the choice as a design tradeoff rather than a critique of pooled lending - pooled lending already supports shorting tokens and is the right tool for deep, liquid assets. Bilateral lending wins on bilateral terms, thin-supply rate stability, holder counterparty selection, and long-tail or new tokens. Encourages readers building their own programs to consider whether a pooled-lending redesign would suit their target asset better.


Why
Parity with every other example (all have Anchor + Quasar ports) plus a round of feedback from Mike on terminology and finance framing.
What
New: Quasar port (
defi/asset-leasing/quasar/)Full port of the Anchor program covering all seven instruction handlers:
create_lease,take_lease,pay_rent,top_up_collateral,return_lease,liquidate,close_expired. All eleven LiteSVM testsfrom the Anchor version are ported. Uses the same Pyth
PriceUpdateV2layout and the same two-mint vault design as the Anchor version.
Local:
cargo test --release→ 12/12 green (11 ported +test_id).README: Mike's feedback applied
Anchor code comment sweep
Same terminology fixes applied in-code — "SPL token"/"SPL mint"/"SPL vault" → "token"/"mint"/"vault" in doc comments. No function, file, or struct names changed.
Note
High Risk
Introduces a brand-new DeFi program with collateral movement, vault closing, and Pyth-oracle-based liquidation logic; bugs here could mis-handle user funds or allow incorrect liquidations.
Overview
Adds a new asset leasing DeFi example: fixed-term token leasing where a holder escrows leased tokens, a short seller posts collateral, per-second fees stream from collateral, and positions can be liquidated via a Pyth
PriceUpdateV2oracle check.Implements the full instruction set in Anchor (including shared CPI helpers, state/errors/constants) with LiteSVM integration tests, and adds a parallel Quasar port with equivalent handlers/oracle parsing and its own project config. Updates the repo
README.mdto list the new example and links to the Anchor project.Reviewed by Cursor Bugbot for commit 9f042b4. Bugbot is set up for automated code reviews on this repo. Configure here.