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 11 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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 709542e. Configure here.
| 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.


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
Medium Risk
Adds a brand-new DeFi example program with collateral custody and oracle-driven liquidation logic plus extensive tests; while isolated to new directories, the liquidation/oracle parsing and token movement flows are non-trivial and worth careful review.
Overview
Introduces a new Asset Leasing example: a fixed-term token lease where a lessor escrows leased tokens, a lessee posts collateral, rent streams per-second from collateral, and positions can be liquidated using a Pyth
PriceUpdateV2price check.Adds the full Anchor program (
create_lease,take_lease,pay_rent,top_up_collateral,return_lease,liquidate,close_expired) with shared token-transfer/close helpers, custom errors/constants, and LiteSVM integration tests that exercise the full lifecycle including mocked Pyth price updates.Adds a Quasar port implementing the same lifecycle (with its own instruction discriminators and test harness), and updates the repo
README.mdto list the new example and link to the Anchor implementation.Reviewed by Cursor Bugbot for commit 709542e. Bugbot is set up for automated code reviews on this repo. Configure here.