From 9ef25f97976fb3a0892d394c70669a5a7935c429 Mon Sep 17 00:00:00 2001 From: Bayyan16 Date: Thu, 3 Sep 2026 08:19:27 +0700 Subject: [PATCH] fix(#456): cover legacy backing migration edge cases --- src/v16_program.rs | 36 +++++- tests/v16_cu.rs | 308 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+), 3 deletions(-) diff --git a/src/v16_program.rs b/src/v16_program.rs index 0e14d2388..6a280553b 100644 --- a/src/v16_program.rs +++ b/src/v16_program.rs @@ -10103,6 +10103,14 @@ pub mod processor { // // available = principal - (loss - recovery) ledger.total_principal_atoms = gross_principal_atoms; + + // Legacy buckets may already have outstanding provider earnings when + // the canonical ledger is created. Seed that outstanding balance into + // the migration baseline before pinning the observation watermark; + // otherwise pre-ledger earnings would never be recognized by sync. + ledger.total_earnings_atoms = bucket.utilization_fee_earnings; + ledger.last_observed_bucket_earnings_atoms = bucket.utilization_fee_earnings; + ledger.cumulative_loss_atoms = unavailable_atoms; ledger.cumulative_recovery_atoms = 0; @@ -10534,13 +10542,35 @@ pub mod processor { let (cfg, group) = state::market_view_mut(&mut market_data)?; let configured_slots = group.header.config.max_market_slots.get() as usize; let asset_index = domain_usize / 2; - if group.header.mode != 0 - || domain_usize >= configured_slots.saturating_mul(2) + if domain_usize >= configured_slots.saturating_mul(2) || asset_index >= configured_slots { return Err(PercolatorError::EngineLockActive.into()); } - require_domain_accepts_live_topup_view(&group, domain_usize)?; + + match group.header.mode { + // Normal backing deposits remain strictly Live-only. + 0 => { + require_domain_accepts_live_topup_view(&group, domain_usize)?; + } + + // #433 migration-only exception: a fully wound-down Resolved + // market may still contain backing funded before canonical + // BackingDomainLedger creation became mandatory. + // + // amount == 0 performs accounting reconciliation only. It must + // not reopen backing deposits or mutate engine backing. + 1 if amount == 0 => { + if group.header.materialized_portfolio_count.get() != 0 + || group.header.c_tot.get() != 0 + { + return Err(PercolatorError::EngineLockActive.into()); + } + } + + // Non-zero top-ups remain forbidden after resolution. + _ => return Err(PercolatorError::EngineLockActive.into()), + } let profile = read_oracle_profile_from_view(&group, &cfg, asset_index)?; let authorities = domain_authorities_from_profile(&cfg, &profile, asset_index); (cfg, authorities) diff --git a/tests/v16_cu.rs b/tests/v16_cu.rs index 2308ae9dd..658536c97 100644 --- a/tests/v16_cu.rs +++ b/tests/v16_cu.rs @@ -12186,3 +12186,311 @@ fn v16_bpf_legacy_ledgerless_nonzero_topup_does_not_book_refill_as_recovery() { ); assert_eq!(synced.last_observed_unavailable_principal_atoms, 20); } + +#[test] +fn v16_bpf_legacy_ledgerless_migration_seeds_outstanding_backing_earnings() { + const DOMAIN: u16 = 1; + + let mut env = V16CuEnv::new(); + let ledger = + state::derive_lp_backing_ledger(&env.program_id, &env.market, DOMAIN).0; + + // Recreate a legacy funded domain whose canonical ledger never existed. + seed_legacy_ledgerless_consumed_backing( + &mut env, + DOMAIN as usize, + 60, + 40, + ); + + // Historical provider earnings also existed before the ledger did. + env.mutate_market(|_cfg, group| { + group.source_backing_buckets[DOMAIN as usize].utilization_fee_earnings = 30; + group.vault = group + .vault + .checked_add(30) + .expect("#433 legacy earnings vault overflow"); + }); + + env.set_token_account_amount( + env.vault, + env.mint, + env.vault_authority, + 130, + ); + + assert!( + env.svm.get_account(&ledger).is_none(), + "legacy earnings fixture must start without a canonical ledger" + ); + + let admin = env.admin.insecure_clone(); + let payer = env.payer.insecure_clone(); + let pid = env.program_id; + let market = env.market; + let vault = env.vault; + let vault_authority = env.vault_authority; + let zero_source = env.token_account(admin.pubkey(), 0); + + // Migration must snapshot BOTH principal/loss and already-outstanding + // provider earnings. + send_tx( + &mut env.svm, + pid, + &payer, + ProgInstruction::TopUpBackingBucket { + domain: DOMAIN, + amount: 0, + expiry_slot: 10, + }, + vec![ + AccountMeta::new(admin.pubkey(), true), + AccountMeta::new(market, false), + AccountMeta::new(zero_source, false), + AccountMeta::new(vault, false), + AccountMeta::new_readonly(spl_token::ID, false), + AccountMeta::new(ledger, false), + AccountMeta::new_readonly(solana_sdk::system_program::ID, false), + ], + &[&admin], + ) + .expect("#433 migration must reconcile outstanding legacy backing earnings"); + + let ledger_account = env + .svm + .get_account(&ledger) + .expect("migration must create the canonical ledger"); + let migrated = + state::read_backing_domain_ledger(&ledger_account.data).unwrap(); + + assert_eq!(migrated.total_principal_atoms, 100); + assert_eq!(migrated.cumulative_loss_atoms, 40); + + assert_eq!( + migrated.total_earnings_atoms, 30, + "outstanding pre-ledger earnings must become the migration baseline" + ); + assert_eq!( + migrated.total_earnings_withdrawn_atoms, 0, + "migration must not invent historical earnings withdrawals" + ); + assert_eq!( + migrated.last_observed_bucket_earnings_atoms, 30, + "earnings watermark must match the same migration snapshot" + ); + + // Prove those migrated earnings can subsequently be withdrawn without + // making withdrawn earnings exceed recognized earnings. + env.svm.expire_blockhash(); + let dest = env.token_account(admin.pubkey(), 0); + + send_tx( + &mut env.svm, + pid, + &payer, + ProgInstruction::WithdrawBackingBucketEarnings { + domain: DOMAIN, + amount: 20, + }, + vec![ + AccountMeta::new(admin.pubkey(), true), + AccountMeta::new(market, false), + AccountMeta::new(ledger, false), + AccountMeta::new(dest, false), + AccountMeta::new(vault, false), + AccountMeta::new_readonly(vault_authority, false), + AccountMeta::new_readonly(spl_token::ID, false), + ], + &[&admin], + ) + .expect("migrated historical backing earnings must remain withdrawable"); + + assert_eq!(env.token_amount(dest), 20); + assert_eq!(env.token_amount(vault), 110); + + let ledger_after = env.svm.get_account(&ledger).unwrap(); + let ledger_after = + state::read_backing_domain_ledger(&ledger_after.data).unwrap(); + + assert_eq!(ledger_after.total_earnings_atoms, 30); + assert_eq!(ledger_after.total_earnings_withdrawn_atoms, 20); + assert_eq!(ledger_after.last_observed_bucket_earnings_atoms, 10); + + let (_, group_after) = env.market_state(); + assert_eq!( + group_after.source_backing_buckets[DOMAIN as usize].utilization_fee_earnings, + 10 + ); +} + +#[test] +fn v16_bpf_legacy_ledgerless_resolved_zero_topup_reconciles_without_reopening_deposits() { + const DOMAIN: u16 = 1; + + let mut env = V16CuEnv::new(); + let ledger = + state::derive_lp_backing_ledger(&env.program_id, &env.market, DOMAIN).0; + + // Legacy backing exists, but the canonical ledger does not. + seed_legacy_ledgerless_consumed_backing( + &mut env, + DOMAIN as usize, + 100, + 0, + ); + + assert!( + env.svm.get_account(&ledger).is_none(), + "resolved migration fixture must begin ledgerless" + ); + + // Enter the normal terminal wind-down state. + env.resolve(); + + let (_, resolved) = env.market_state(); + + assert_eq!( + resolved.mode, + percolator::MarketModeV16::Resolved, + "fixture must actually be resolved" + ); + assert_eq!( + resolved.materialized_portfolio_count, 0, + "resolved migration requires full user wind-down" + ); + assert_eq!( + resolved.c_tot, 0, + "resolved migration requires zero remaining user capital" + ); + + let admin = env.admin.insecure_clone(); + let payer = env.payer.insecure_clone(); + let pid = env.program_id; + let market = env.market; + let vault = env.vault; + let vault_authority = env.vault_authority; + + // Negative control: allowing reconciliation must NOT reopen real deposits. + let source = env.token_account(admin.pubkey(), 1); + let vault_before = env.token_amount(vault); + + let nonzero = send_tx( + &mut env.svm, + pid, + &payer, + ProgInstruction::TopUpBackingBucket { + domain: DOMAIN, + amount: 1, + expiry_slot: 20, + }, + vec![ + AccountMeta::new(admin.pubkey(), true), + AccountMeta::new(market, false), + AccountMeta::new(source, false), + AccountMeta::new(vault, false), + AccountMeta::new_readonly(spl_token::ID, false), + AccountMeta::new(ledger, false), + AccountMeta::new_readonly(solana_sdk::system_program::ID, false), + ], + &[&admin], + ); + + assert!( + nonzero.is_err(), + "resolved migration exception must never reopen non-zero backing deposits" + ); + assert_eq!(env.token_amount(source), 1); + assert_eq!(env.token_amount(vault), vault_before); + assert!( + env.svm.get_account(&ledger).is_none(), + "failed non-zero resolved top-up must not leave the ledger PDA behind" + ); + + // Positive case: amount == 0 is accounting migration only. + env.svm.expire_blockhash(); + + send_tx( + &mut env.svm, + pid, + &payer, + ProgInstruction::TopUpBackingBucket { + domain: DOMAIN, + amount: 0, + expiry_slot: 20, + }, + vec![ + AccountMeta::new(admin.pubkey(), true), + AccountMeta::new(market, false), + AccountMeta::new(source, false), + AccountMeta::new(vault, false), + AccountMeta::new_readonly(spl_token::ID, false), + AccountMeta::new(ledger, false), + AccountMeta::new_readonly(solana_sdk::system_program::ID, false), + ], + &[&admin], + ) + .expect( + "#433 resolved zero-capital migration must create the missing canonical ledger", + ); + + assert_eq!( + env.token_amount(source), 1, + "zero-capital migration must not consume source tokens" + ); + assert_eq!( + env.token_amount(vault), vault_before, + "zero-capital migration must not change vault balance" + ); + + let ledger_account = env + .svm + .get_account(&ledger) + .expect("resolved migration must create the canonical ledger"); + + let migrated = + state::read_backing_domain_ledger(&ledger_account.data).unwrap(); + + assert_eq!(migrated.total_principal_atoms, 100); + assert_eq!(migrated.cumulative_loss_atoms, 0); + assert_eq!(migrated.cumulative_recovery_atoms, 0); + assert_eq!( + migrated.total_deposited_atoms, 0, + "migration must not fabricate historical deposit flow" + ); + + // Existing resolved backing withdrawal must work after reconciliation. + env.svm.expire_blockhash(); + + let dest = env.token_account(admin.pubkey(), 0); + + send_tx( + &mut env.svm, + pid, + &payer, + ProgInstruction::WithdrawBackingBucket { + domain: DOMAIN, + amount: 40, + }, + vec![ + AccountMeta::new(admin.pubkey(), true), + AccountMeta::new(market, false), + AccountMeta::new(dest, false), + AccountMeta::new(vault, false), + AccountMeta::new_readonly(vault_authority, false), + AccountMeta::new_readonly(spl_token::ID, false), + AccountMeta::new(ledger, false), + ], + &[&admin], + ) + .expect("reconciled resolved legacy backing must be withdrawable"); + + assert_eq!(env.token_amount(dest), 40); + assert_eq!(env.token_amount(vault), vault_before - 40); + + let ledger_after = env.svm.get_account(&ledger).unwrap(); + let ledger_after = + state::read_backing_domain_ledger(&ledger_after.data).unwrap(); + + assert_eq!(ledger_after.total_principal_atoms, 60); + assert_eq!(ledger_after.total_principal_withdrawn_atoms, 40); +}