Skip to content

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

Open
mikemaccana-edwardbot wants to merge 40 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 40 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

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

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.
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.

"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(())
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Reviewed by Cursor Bugbot for commit 5f35c76. Configure here.

Comment thread README.md

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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Root README uses outdated terminology in Asset Leasing entry

Low Severity

The new Asset Leasing description in the root README contradicts the terminology cleanup explicitly described in this PR's user instructions. It uses SPL 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.

Fix in Cursor Fix in Web

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>,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Reviewed by Cursor Bugbot for commit 10a6caa. Configure here.

Edward (subagent) and others added 2 commits April 28, 2026 01:52
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)?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Reviewed by Cursor Bugbot for commit cde6b0d. Configure here.

mikemaccana-edwardbot and others added 11 commits April 28, 2026 20:09
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.
Edward (OpenClaw) added 9 commits April 28, 2026 21:32
…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
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Reviewed by Cursor Bugbot for commit ab99204. Configure here.

Edward (OpenClaw) added 2 commits April 28, 2026 22:00
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.
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.

There are 7 total unresolved issues (including 6 from previous reviews).

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 602823f. Configure here.

/// Lessor's collateral-mint token account. Pre-created by the caller.
#[account(mut)]
pub lessor_collateral_account: &'info mut Account<Token>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Reviewed by Cursor Bugbot for commit 602823f. Configure here.

Edward (OpenClaw) and others added 2 commits April 29, 2026 15:13
'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.
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