Skip to content

Commit 29f5e00

Browse files
author
Edward (Edwardbot)
committed
fix(asset-leasing): pin Pyth feed_id on Lease and enforce at liquidate
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).
1 parent 28a715d commit 29f5e00

6 files changed

Lines changed: 122 additions & 8 deletions

File tree

defi/asset-leasing/anchor/programs/asset-leasing/src/errors.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,6 @@ pub enum AssetLeasingError {
3232
Unauthorised,
3333
#[msg("Leased mint and collateral mint must be different")]
3434
LeasedMintEqualsCollateralMint,
35+
#[msg("Price update does not match the feed pinned on this lease")]
36+
PriceFeedMismatch,
3537
}

defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/create_lease.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ pub fn handle_create_lease(
7979
duration_seconds: i64,
8080
maintenance_margin_bps: u16,
8181
liquidation_bounty_bps: u16,
82+
feed_id: [u8; 32],
8283
) -> Result<()> {
8384
// Reject leased_mint == collateral_mint. Allowing both to be the same SPL
8485
// mint would collapse the two vaults' seed derivations into one shared
@@ -139,6 +140,7 @@ pub fn handle_create_lease(
139140
last_rent_paid_ts: 0,
140141
maintenance_margin_bps,
141142
liquidation_bounty_bps,
143+
feed_id,
142144
status: LeaseStatus::Listed,
143145
bump: context.bumps.lease,
144146
leased_vault_bump: context.bumps.leased_vault,

defi/asset-leasing/anchor/programs/asset-leasing/src/instructions/liquidate.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,15 +109,17 @@ pub struct Liquidate<'info> {
109109
/// feed_id(32) | price(i64) | conf(u64) | exponent(i32) | publish_time(i64) |
110110
/// prev_publish_time(i64) | ema_price(i64) | ema_conf(u64) | posted_slot(u64)]`.
111111
pub struct DecodedPriceUpdate {
112+
pub feed_id: [u8; 32],
112113
pub price: i64,
113114
pub exponent: i32,
114115
pub publish_time: i64,
115116
}
116117

117118
pub fn decode_price_update(data: &[u8]) -> Result<DecodedPriceUpdate> {
118-
// Discriminator (8) + write_authority (32) + verification_level (1) +
119-
// feed_id (32) = 73 bytes before the fields we care about.
120-
const PRICE_OFFSET: usize = 73;
119+
// Discriminator (8) + write_authority (32) + verification_level (1) = 41.
120+
const FEED_ID_OFFSET: usize = 41;
121+
// feed_id (32) starts at 41, price i64 at 41 + 32 = 73.
122+
const PRICE_OFFSET: usize = FEED_ID_OFFSET + 32;
121123
const EXPONENT_OFFSET: usize = PRICE_OFFSET + 8 + 8; // price + conf
122124
const PUBLISH_TIME_OFFSET: usize = EXPONENT_OFFSET + 4; // exponent
123125
const MIN_LEN: usize = PUBLISH_TIME_OFFSET + 8;
@@ -128,6 +130,9 @@ pub fn decode_price_update(data: &[u8]) -> Result<DecodedPriceUpdate> {
128130
AssetLeasingError::StalePrice
129131
);
130132

133+
let mut feed_id = [0u8; 32];
134+
feed_id.copy_from_slice(&data[FEED_ID_OFFSET..FEED_ID_OFFSET + 32]);
135+
131136
let price = i64::from_le_bytes(data[PRICE_OFFSET..PRICE_OFFSET + 8].try_into().unwrap());
132137
let exponent = i32::from_le_bytes(
133138
data[EXPONENT_OFFSET..EXPONENT_OFFSET + 4]
@@ -141,6 +146,7 @@ pub fn decode_price_update(data: &[u8]) -> Result<DecodedPriceUpdate> {
141146
);
142147

143148
Ok(DecodedPriceUpdate {
149+
feed_id,
144150
price,
145151
exponent,
146152
publish_time,
@@ -153,6 +159,16 @@ pub fn handle_liquidate(context: Context<Liquidate>) -> Result<()> {
153159
let decoded = decode_price_update(&price_data)?;
154160
drop(price_data);
155161

162+
// Feed pinning: reject any `PriceUpdateV2` whose feed_id does not match
163+
// the one the lessor committed to at `create_lease`. Without this guard,
164+
// a keeper could pass in any feed the Pyth Receiver program owns — e.g.
165+
// a wildly volatile pair that dips enough to flag the position as
166+
// underwater — and trigger a spurious liquidation.
167+
require!(
168+
decoded.feed_id == context.accounts.lease.feed_id,
169+
AssetLeasingError::PriceFeedMismatch
170+
);
171+
156172
require!(
157173
is_underwater(&context.accounts.lease, &decoded, now)?,
158174
AssetLeasingError::PositionHealthy

defi/asset-leasing/anchor/programs/asset-leasing/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ pub mod asset_leasing {
2727
duration_seconds: i64,
2828
maintenance_margin_bps: u16,
2929
liquidation_bounty_bps: u16,
30+
feed_id: [u8; 32],
3031
) -> Result<()> {
3132
instructions::create_lease::handle_create_lease(
3233
context,
@@ -37,6 +38,7 @@ pub mod asset_leasing {
3738
duration_seconds,
3839
maintenance_margin_bps,
3940
liquidation_bounty_bps,
41+
feed_id,
4042
)
4143
}
4244

defi/asset-leasing/anchor/programs/asset-leasing/src/state/lease.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ pub struct Lease {
5858
/// lease, expressed in basis points of `collateral_amount`.
5959
pub liquidation_bounty_bps: u16,
6060

61+
/// Pyth `PriceUpdateV2.feed_id` that this lease is pinned to. The
62+
/// liquidation handler refuses price updates whose on-account `feed_id`
63+
/// does not match this value, so a keeper cannot swap in an unrelated
64+
/// feed (e.g. a cheaper or more volatile pair) to force a liquidation.
65+
/// Chosen by the lessor at `create_lease`.
66+
pub feed_id: [u8; 32],
67+
6168
/// Current lifecycle state.
6269
pub status: LeaseStatus,
6370

defi/asset-leasing/anchor/programs/asset-leasing/tests/test_asset_leasing.rs

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ fn build_create_lease_ix(
171171
duration_seconds: i64,
172172
maintenance_margin_bps: u16,
173173
liquidation_bounty_bps: u16,
174+
feed_id: [u8; 32],
174175
) -> Instruction {
175176
let (lease, leased_vault, collateral_vault) =
176177
lease_pdas(&sc.program_id, &sc.lessor.pubkey(), lease_id);
@@ -184,6 +185,7 @@ fn build_create_lease_ix(
184185
duration_seconds,
185186
maintenance_margin_bps,
186187
liquidation_bounty_bps,
188+
feed_id,
187189
}
188190
.data(),
189191
asset_leasing::accounts::CreateLease {
@@ -349,7 +351,12 @@ fn build_close_expired_ix(sc: &Scenario, lease_id: u64) -> Instruction {
349351
/// Build a minimal `PriceUpdateV2` account body with the requested price and
350352
/// exponent, timestamped `publish_time`. Fields not used by the program are
351353
/// filled with zero bytes.
352-
fn build_price_update_data(price: i64, exponent: i32, publish_time: i64) -> Vec<u8> {
354+
fn build_price_update_data(
355+
feed_id: [u8; 32],
356+
price: i64,
357+
exponent: i32,
358+
publish_time: i64,
359+
) -> Vec<u8> {
353360
// Size layout:
354361
// 8 disc + 32 write_authority + 1 verification_level + 32 feed_id +
355362
// 8 price + 8 conf + 4 exponent + 8 publish_time + 8 prev_publish_time +
@@ -361,8 +368,7 @@ fn build_price_update_data(price: i64, exponent: i32, publish_time: i64) -> Vec<
361368
data.extend_from_slice(&[0u8; 32]);
362369
// verification_level = Full (1).
363370
data.push(1);
364-
// feed_id — arbitrary; not checked by the program.
365-
data.extend_from_slice(&[0xAB; 32]);
371+
data.extend_from_slice(&feed_id);
366372
data.extend_from_slice(&price.to_le_bytes());
367373
data.extend_from_slice(&0u64.to_le_bytes()); // conf
368374
data.extend_from_slice(&exponent.to_le_bytes());
@@ -378,11 +384,12 @@ fn build_price_update_data(price: i64, exponent: i32, publish_time: i64) -> Vec<
378384
fn mock_price_update(
379385
svm: &mut LiteSVM,
380386
address: Pubkey,
387+
feed_id: [u8; 32],
381388
price: i64,
382389
exponent: i32,
383390
publish_time: i64,
384391
) {
385-
let data = build_price_update_data(price, exponent, publish_time);
392+
let data = build_price_update_data(feed_id, price, exponent, publish_time);
386393
let lamports = svm.minimum_balance_for_rent_exemption(data.len());
387394
let owner: Pubkey = PYTH_RECEIVER_PROGRAM_ID_STR.parse().unwrap();
388395
svm.set_account(
@@ -409,6 +416,11 @@ const RENT_PER_SECOND: u64 = 10; // 10 base-units / sec
409416
const DURATION_SECONDS: i64 = 60 * 60 * 24; // 24h
410417
const MAINTENANCE_MARGIN_BPS: u16 = 12_000; // 120%
411418
const LIQUIDATION_BOUNTY_BPS: u16 = 500; // 5%
419+
// Arbitrary 32-byte Pyth feed id the tests pin their leases to. The
420+
// mocked `PriceUpdateV2` accounts carry the same id so the feed-pinning
421+
// check in liquidate passes. `liquidate_rejects_mismatched_price_feed`
422+
// flips one byte of this to prove the check rejects foreign feeds.
423+
const FEED_ID: [u8; 32] = [0xAB; 32];
412424

413425
#[test]
414426
fn create_lease_locks_tokens_and_lists() {
@@ -424,6 +436,7 @@ fn create_lease_locks_tokens_and_lists() {
424436
DURATION_SECONDS,
425437
MAINTENANCE_MARGIN_BPS,
426438
LIQUIDATION_BOUNTY_BPS,
439+
FEED_ID,
427440
);
428441
send_transaction_from_instructions(&mut sc.svm, vec![ix], &[&sc.lessor], &sc.lessor.pubkey())
429442
.unwrap();
@@ -467,6 +480,7 @@ fn take_lease_posts_collateral_and_delivers_tokens() {
467480
DURATION_SECONDS,
468481
MAINTENANCE_MARGIN_BPS,
469482
LIQUIDATION_BOUNTY_BPS,
483+
FEED_ID,
470484
);
471485
send_transaction_from_instructions(
472486
&mut sc.svm,
@@ -520,6 +534,7 @@ fn pay_rent_streams_collateral_by_elapsed_time() {
520534
DURATION_SECONDS,
521535
MAINTENANCE_MARGIN_BPS,
522536
LIQUIDATION_BOUNTY_BPS,
537+
FEED_ID,
523538
);
524539
let take_ix = build_take_lease_ix(&sc, lease_id);
525540
send_transaction_from_instructions(
@@ -569,6 +584,7 @@ fn top_up_collateral_increases_vault_balance() {
569584
DURATION_SECONDS,
570585
MAINTENANCE_MARGIN_BPS,
571586
LIQUIDATION_BOUNTY_BPS,
587+
FEED_ID,
572588
);
573589
let take_ix = build_take_lease_ix(&sc, lease_id);
574590
send_transaction_from_instructions(
@@ -610,6 +626,7 @@ fn return_lease_refunds_unused_collateral() {
610626
DURATION_SECONDS,
611627
MAINTENANCE_MARGIN_BPS,
612628
LIQUIDATION_BOUNTY_BPS,
629+
FEED_ID,
613630
);
614631
let take_ix = build_take_lease_ix(&sc, lease_id);
615632
send_transaction_from_instructions(
@@ -675,6 +692,7 @@ fn liquidate_seizes_collateral_on_price_drop() {
675692
DURATION_SECONDS,
676693
MAINTENANCE_MARGIN_BPS,
677694
LIQUIDATION_BOUNTY_BPS,
695+
FEED_ID,
678696
);
679697
let take_ix = build_take_lease_ix(&sc, lease_id);
680698
send_transaction_from_instructions(
@@ -698,6 +716,7 @@ fn liquidate_seizes_collateral_on_price_drop() {
698716
mock_price_update(
699717
&mut sc.svm,
700718
price_update_key.pubkey(),
719+
FEED_ID,
701720
4,
702721
0,
703722
now, // fresh publish_time
@@ -751,6 +770,7 @@ fn liquidate_rejects_healthy_position() {
751770
DURATION_SECONDS,
752771
MAINTENANCE_MARGIN_BPS,
753772
LIQUIDATION_BOUNTY_BPS,
773+
FEED_ID,
754774
);
755775
let take_ix = build_take_lease_ix(&sc, lease_id);
756776
send_transaction_from_instructions(
@@ -766,7 +786,7 @@ fn liquidate_rejects_healthy_position() {
766786
// to fail with `PositionHealthy`.
767787
let price_update_key = Keypair::new();
768788
let now = current_clock(&sc.svm);
769-
mock_price_update(&mut sc.svm, price_update_key.pubkey(), 1, 0, now);
789+
mock_price_update(&mut sc.svm, price_update_key.pubkey(), FEED_ID, 1, 0, now);
770790

771791
let liq_ix = build_liquidate_ix(&sc, lease_id, price_update_key.pubkey());
772792
let result = send_transaction_from_instructions(
@@ -778,6 +798,68 @@ fn liquidate_rejects_healthy_position() {
778798
assert!(result.is_err(), "healthy liquidation must fail");
779799
}
780800

801+
#[test]
802+
fn liquidate_rejects_mismatched_price_feed() {
803+
// The lessor pinned FEED_ID; we hand the handler a price update whose
804+
// internal feed_id is different. Even when the price would push the
805+
// position underwater, the liquidate call must bail with
806+
// `PriceFeedMismatch` before running the undercollateralisation check.
807+
let mut sc = full_setup();
808+
let lease_id = 100u64;
809+
810+
let create_ix = build_create_lease_ix(
811+
&sc,
812+
lease_id,
813+
LEASED_AMOUNT,
814+
REQUIRED_COLLATERAL,
815+
RENT_PER_SECOND,
816+
DURATION_SECONDS,
817+
MAINTENANCE_MARGIN_BPS,
818+
LIQUIDATION_BOUNTY_BPS,
819+
FEED_ID,
820+
);
821+
let take_ix = build_take_lease_ix(&sc, lease_id);
822+
send_transaction_from_instructions(
823+
&mut sc.svm,
824+
vec![create_ix, take_ix],
825+
&[&sc.lessor, &sc.lessee],
826+
&sc.lessor.pubkey(),
827+
)
828+
.unwrap();
829+
830+
// Flip every byte — any 32-byte feed id other than FEED_ID should do.
831+
let wrong_feed_id = [0xCD; 32];
832+
833+
// Price that *would* trigger liquidation (debt 400 vs 200 collateral,
834+
// same as `liquidate_seizes_collateral_on_price_drop`) — except this
835+
// update carries the wrong feed id.
836+
let price_update_key = Keypair::new();
837+
let now = current_clock(&sc.svm);
838+
mock_price_update(
839+
&mut sc.svm,
840+
price_update_key.pubkey(),
841+
wrong_feed_id,
842+
4,
843+
0,
844+
now,
845+
);
846+
847+
let liq_ix = build_liquidate_ix(&sc, lease_id, price_update_key.pubkey());
848+
let result = send_transaction_from_instructions(
849+
&mut sc.svm,
850+
vec![liq_ix],
851+
&[&sc.keeper],
852+
&sc.keeper.pubkey(),
853+
);
854+
let err = result.expect_err("liquidate must reject foreign price feeds");
855+
let rendered = format!("{err:?}");
856+
// PriceFeedMismatch is the 16th error in the enum (index 15) → 0x177f.
857+
assert!(
858+
rendered.contains("PriceFeedMismatch") || rendered.contains("0x177f"),
859+
"unexpected failure mode: {rendered}"
860+
);
861+
}
862+
781863
#[test]
782864
fn close_expired_reclaims_collateral_after_end_ts() {
783865
let mut sc = full_setup();
@@ -792,6 +874,7 @@ fn close_expired_reclaims_collateral_after_end_ts() {
792874
DURATION_SECONDS,
793875
MAINTENANCE_MARGIN_BPS,
794876
LIQUIDATION_BOUNTY_BPS,
877+
FEED_ID,
795878
);
796879
let take_ix = build_take_lease_ix(&sc, lease_id);
797880
send_transaction_from_instructions(
@@ -848,6 +931,7 @@ fn close_expired_cancels_listed_lease() {
848931
DURATION_SECONDS,
849932
MAINTENANCE_MARGIN_BPS,
850933
LIQUIDATION_BOUNTY_BPS,
934+
FEED_ID,
851935
);
852936
send_transaction_from_instructions(
853937
&mut sc.svm,
@@ -904,6 +988,7 @@ fn create_lease_rejects_same_mint_for_leased_and_collateral() {
904988
duration_seconds: DURATION_SECONDS,
905989
maintenance_margin_bps: MAINTENANCE_MARGIN_BPS,
906990
liquidation_bounty_bps: LIQUIDATION_BOUNTY_BPS,
991+
feed_id: FEED_ID,
907992
}
908993
.data(),
909994
asset_leasing::accounts::CreateLease {

0 commit comments

Comments
 (0)