Skip to main content

percli_program/instructions/
migrate_header_v1.rs

1use anchor_lang::prelude::*;
2use percli_core::RiskEngine;
3
4use crate::error::PercolatorError;
5use crate::instructions::events;
6use crate::state::{write_header, MarketHeader};
7
8/// One-time migration from the v0 (136-byte) `MarketHeader` layout used by
9/// percli v0.9.x to the v1 (168-byte) layout used by percli v1.0+.
10///
11/// Detection uses a **version byte at offset [7]** of the discriminator:
12///   - v0: `b"percmrkt"` (last byte = `0x74`, ASCII `t`)
13///   - v1: `b"percmrk\x01"` (last byte = `0x01`)
14///
15/// We can't use absolute size comparisons because host (`size_of::<RiskEngine>()`
16/// at host compile time) and SBF (`size_of` at on-chain compile time) disagree
17/// on the size of `RiskEngine` due to platform-specific alignment of `i128`/
18/// `u128` and the large `[Account; MAX_ACCOUNTS]` array. The discrepancy is
19/// constant (~536 bytes), but it makes any "expected total size" comparison
20/// fragile across host/SBF boundaries.
21///
22/// Migration is performed **in-place without `realloc`**: we shift the engine
23/// bytes forward by 32 bytes (from `[144..)` to `[176..)`) inside the existing
24/// account buffer. This works because real v0.9 mainnet accounts were created
25/// with the v0.9 host-side constant `8 + 136 + size_host(RiskEngine)`, which
26/// is *strictly larger* than the SBF v1 size `8 + 168 + size_sbf(RiskEngine)`,
27/// so the existing buffer already has enough room.
28///
29/// This instruction:
30///   1. Verifies the account is owned by this program.
31///   2. Verifies the discriminator at `[0..7]` is `b"percmrk"`.
32///   3. Verifies the version byte at `[7]` is `0x74` (`t`, the v0 marker).
33///   4. Verifies the signer matches the authority encoded in the v0 header.
34///   5. Shifts the engine bytes forward by 32 bytes (back-to-front via
35///      `copy_within`).
36///   6. Writes a fresh v1 header at `[8..176)` with `pending_authority =
37///      Pubkey::default()` (all other fields copied from the v0 header).
38///   7. Stamps the version byte at `[7]` to `0x01`.
39///   8. Emits `HeaderMigrated`.
40///
41/// `migrate_header_v1` is idempotent-by-rejection: calling it a second time
42/// fails the version-byte check with `AlreadyMigrated`.
43#[derive(Accounts)]
44pub struct MigrateHeaderV1<'info> {
45    #[account(mut)]
46    pub authority: Signer<'info>,
47
48    /// CHECK: Manually validated — owner, discriminator/version-byte, and
49    /// authority match are all checked inside the handler. We deliberately
50    /// don't enforce a `seeds`/`bump` constraint here because we re-derive
51    /// and verify the PDA bump inside the handler against the v0 header bytes,
52    /// which is the same security guarantee with explicit error reporting.
53    #[account(
54        mut,
55        owner = crate::ID @ PercolatorError::AccountNotFound,
56    )]
57    pub market: UncheckedAccount<'info>,
58}
59
60/// Parse the fields of a v0 MarketHeader out of a byte slice.
61///
62/// The v0 layout at offset 8 (after discriminator) is:
63///   authority (32) | mint (32) | oracle (32) | matcher (32) |
64///   bump (1) | vault_bump (1) | _padding (6)
65/// …followed immediately by the engine at offset `8 + 136 = 144`.
66struct V0Fields {
67    authority: Pubkey,
68    mint: Pubkey,
69    oracle: Pubkey,
70    matcher: Pubkey,
71    bump: u8,
72    vault_bump: u8,
73}
74
75fn read_v0_fields(data: &[u8]) -> V0Fields {
76    let mut a = [0u8; 32];
77    let mut m = [0u8; 32];
78    let mut o = [0u8; 32];
79    let mut mt = [0u8; 32];
80    a.copy_from_slice(&data[8..40]);
81    m.copy_from_slice(&data[40..72]);
82    o.copy_from_slice(&data[72..104]);
83    mt.copy_from_slice(&data[104..136]);
84    V0Fields {
85        authority: Pubkey::new_from_array(a),
86        mint: Pubkey::new_from_array(m),
87        oracle: Pubkey::new_from_array(o),
88        matcher: Pubkey::new_from_array(mt),
89        bump: data[136],
90        vault_bump: data[137],
91    }
92}
93
94pub fn handler(ctx: Context<MigrateHeaderV1>) -> Result<()> {
95    let market_info = ctx.accounts.market.to_account_info();
96
97    // -----------------------------------------------------------------------
98    // 1. Validate the discriminator/version, then read & verify v0 fields.
99    // -----------------------------------------------------------------------
100    let v0 = {
101        let data = market_info.try_borrow_data()?;
102        require!(
103            data.len() >= 8 + MarketHeader::SIZE + std::mem::size_of::<RiskEngine>(),
104            PercolatorError::AccountNotFound
105        );
106        require!(
107            &data[0..7] == b"percmrk",
108            PercolatorError::AccountNotFound
109        );
110        // Version byte: v0 = 0x74 ('t'), v1 = 0x01.
111        // The order of these two checks matters for error reporting:
112        // a non-v0, non-v1 byte falls through to NotLegacyLayout (correct).
113        require!(data[7] != 0x01, PercolatorError::AlreadyMigrated);
114        require!(data[7] == 0x74, PercolatorError::NotLegacyLayout);
115        read_v0_fields(&data)
116    };
117    require!(
118        v0.authority == ctx.accounts.authority.key(),
119        PercolatorError::Unauthorized
120    );
121
122    // Re-derive the Market PDA from the v0-encoded authority and verify the
123    // bump. This catches any account whose v0 header was tampered with (or
124    // simply corrupted) such that the stored bump no longer matches the
125    // canonical PDA. We don't validate `vault_bump` here — the vault is a
126    // separate token account that isn't passed to this instruction; the next
127    // instruction that touches the vault (e.g. `deposit`) will revalidate it
128    // via the Anchor `seeds`/`bump` constraint.
129    let (expected_market, expected_bump) =
130        Pubkey::find_program_address(&[b"market", v0.authority.as_ref()], &crate::ID);
131    require!(
132        expected_market == market_info.key(),
133        PercolatorError::AccountNotFound
134    );
135    require!(
136        expected_bump == v0.bump,
137        PercolatorError::CorruptState
138    );
139
140    // -----------------------------------------------------------------------
141    // 2. Shift the engine bytes forward by 32 (in-place, no realloc).
142    //
143    // Before the shift:
144    //   [0..8)               discriminator (`percmrkt`)
145    //   [8..144)             old v0 header bytes
146    //   [144..144 + E)       engine bytes (E = SBF size_of::<RiskEngine>())
147    //   [144 + E..)          slack tail bytes left over from host create_account
148    //
149    // After the shift:
150    //   [0..8)               discriminator (we'll restamp byte [7] = 0x01)
151    //   [8..176)             stale bytes — overwritten by `write_header` next
152    //   [176..176 + E)       engine bytes in their v1 location
153    //   [176 + E..)          slack tail (unchanged)
154    // -----------------------------------------------------------------------
155    let mut data = market_info.try_borrow_mut_data()?;
156    let engine_size = std::mem::size_of::<RiskEngine>();
157    let old_engine_start: usize = 8 + MarketHeader::SIZE_V0; // 144
158    let new_engine_start: usize = 8 + MarketHeader::SIZE; // 176
159
160    // Sanity: the existing buffer must have room for the v1 layout. Real
161    // v0.9 mainnet accounts always do (host create_account size > SBF v1
162    // size by ~536 bytes due to platform alignment differences in
163    // RiskEngine), and integration tests likewise allocate a buffer of
164    // host-side `MARKET_ACCOUNT_SIZE_V0`.
165    require!(
166        data.len() >= new_engine_start + engine_size,
167        PercolatorError::AccountNotFound
168    );
169
170    // `copy_within` handles overlapping source/destination correctly. Since
171    // we're copying from a lower offset to a higher one, it iterates
172    // back-to-front internally to avoid clobbering.
173    data.copy_within(
174        old_engine_start..(old_engine_start + engine_size),
175        new_engine_start,
176    );
177
178    // -----------------------------------------------------------------------
179    // 3. Write the v1 header. `write_header` overwrites bytes [8..176).
180    //    Then stamp the version byte at [7] = 0x01.
181    // -----------------------------------------------------------------------
182    let header = MarketHeader {
183        authority: v0.authority,
184        mint: v0.mint,
185        oracle: v0.oracle,
186        matcher: v0.matcher,
187        pending_authority: Pubkey::default(),
188        bump: v0.bump,
189        vault_bump: v0.vault_bump,
190        _padding: [0; 6],
191    };
192    write_header(&mut data, &header);
193    data[7] = 0x01;
194
195    let actual_account_size = data.len() as u64;
196    drop(data);
197
198    emit!(events::HeaderMigrated {
199        authority: ctx.accounts.authority.key(),
200        market: market_info.key(),
201        mint: v0.mint,
202        oracle: v0.oracle,
203        matcher: v0.matcher,
204        account_size: actual_account_size,
205    });
206
207    Ok(())
208}