Skip to content

feat(asset-leasing): add Quasar port and apply Mike's README feedback#7

Open
mikemaccana-edwardbot wants to merge 11 commits intoquiknode-labs:mainfrom
mikemaccana-edwardbot:asset-leasing
Open

feat(asset-leasing): add Quasar port and apply Mike's README feedback#7
mikemaccana-edwardbot wants to merge 11 commits intoquiknode-labs:mainfrom
mikemaccana-edwardbot:asset-leasing

Conversation

@mikemaccana-edwardbot
Copy link
Copy Markdown

@mikemaccana-edwardbot mikemaccana-edwardbot commented Apr 21, 2026

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 tests
from the Anchor version are ported. Uses the same Pyth PriceUpdateV2
layout 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

  • "Token" not "SPL Token" — tokens are the default; no qualifier unless contrasting with native SOL.
  • "Instruction handler" not "instruction" when referring to the Rust function. An instruction is the input to a program; the instruction handler is the code that processes it.
  • Dropped the Glossary. Solana already defines lamports, signers, accounts, PDAs, CPIs at https://solana.com/docs/terminology. The README now links there and inline-defines only genuinely project-specific terms (maintenance margin, liquidation bounty, keeper, rent-the-stream vs rent-the-account).
  • No Ethereum references — deleted "Solana's equivalent of an ERC-20".
  • Finance framing fixed — car rentals and pawn shops are not finance. Replaced with real tradfi analogies: leasing gold bars from a bullion dealer, and securities lending (borrowing stock to short). These are the actual finance patterns this program models.
  • Added a Quasar port section with build/test instructions alongside Anchor.

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 PriceUpdateV2 price 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.md to 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.

Edward (OpenClaw) and others added 11 commits April 18, 2026 05:50
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.
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 709542e. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant