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}