|
| 1 | +--- |
| 2 | +title: 'Example: Adding Optional Field' |
| 3 | +description: Safe, non-breaking schema change by adding an optional field at the end |
| 4 | +--- |
| 5 | + |
| 6 | +This example shows how to safely add a new field to your schema without breaking existing on-chain accounts. |
| 7 | + |
| 8 | +**Scenario:** Add a `nickname` field to `PlayerAccount` without requiring data migration. |
| 9 | + |
| 10 | +--- |
| 11 | + |
| 12 | +## Initial Schema (v1.0.0) |
| 13 | + |
| 14 | +**File:** `player_v1.lumos` |
| 15 | + |
| 16 | +```rust |
| 17 | +#[solana] |
| 18 | +#[account] |
| 19 | +#[version("1.0.0")] |
| 20 | +struct PlayerAccount { |
| 21 | + wallet: PublicKey, |
| 22 | + level: u16, |
| 23 | + experience: u64, |
| 24 | +} |
| 25 | +``` |
| 26 | + |
| 27 | +**Generated Rust:** |
| 28 | +```rust |
| 29 | +use anchor_lang::prelude::*; |
| 30 | + |
| 31 | +#[account] |
| 32 | +pub struct PlayerAccount { |
| 33 | + pub wallet: Pubkey, |
| 34 | + pub level: u16, |
| 35 | + pub experience: u64, |
| 36 | +} |
| 37 | + |
| 38 | +// Account size: 8 (discriminator) + 32 + 2 + 8 = 50 bytes |
| 39 | +``` |
| 40 | + |
| 41 | +**Generated TypeScript:** |
| 42 | +```typescript |
| 43 | +import { PublicKey } from '@solana/web3.js'; |
| 44 | +import * as borsh from '@coral-xyz/borsh'; |
| 45 | + |
| 46 | +export interface PlayerAccount { |
| 47 | + wallet: PublicKey; |
| 48 | + level: number; |
| 49 | + experience: number; |
| 50 | +} |
| 51 | + |
| 52 | +export const PlayerAccountBorshSchema = borsh.struct([ |
| 53 | + borsh.publicKey('wallet'), |
| 54 | + borsh.u16('level'), |
| 55 | + borsh.u64('experience'), |
| 56 | +]); |
| 57 | +``` |
| 58 | + |
| 59 | +--- |
| 60 | + |
| 61 | +## Updated Schema (v1.1.0) |
| 62 | + |
| 63 | +**File:** `player_v1.1.lumos` |
| 64 | + |
| 65 | +```rust |
| 66 | +#[solana] |
| 67 | +#[account] |
| 68 | +#[version("1.1.0")] |
| 69 | +struct PlayerAccount { |
| 70 | + wallet: PublicKey, |
| 71 | + level: u16, |
| 72 | + experience: u64, |
| 73 | + nickname: Option<String>, // ✅ New optional field at end |
| 74 | +} |
| 75 | +``` |
| 76 | + |
| 77 | +**Why This is Safe:** |
| 78 | +- ✅ **Old accounts deserialize correctly** - Missing field defaults to `None` |
| 79 | +- ✅ **No byte layout change** for existing fields |
| 80 | +- ✅ **Backward compatible** - v1.0.0 programs can read v1.1.0 data (just ignore nickname) |
| 81 | +- ✅ **No migration required** - Existing accounts work immediately |
| 82 | + |
| 83 | +--- |
| 84 | + |
| 85 | +## Verify Safety with `lumos diff` |
| 86 | + |
| 87 | +```bash |
| 88 | +lumos diff player_v1.lumos player_v1.1.lumos |
| 89 | + |
| 90 | +# Output: |
| 91 | +# Non-Breaking Changes: |
| 92 | +# + PlayerAccount.nickname: Option<String> (new field) |
| 93 | +# |
| 94 | +# Recommendation: Increment MINOR version (1.0.0 → 1.1.0) |
| 95 | +# Migration Required: No |
| 96 | +``` |
| 97 | + |
| 98 | +--- |
| 99 | + |
| 100 | +## Generated Code Changes |
| 101 | + |
| 102 | +**New Rust:** |
| 103 | +```rust |
| 104 | +#[account] |
| 105 | +pub struct PlayerAccount { |
| 106 | + pub wallet: Pubkey, |
| 107 | + pub level: u16, |
| 108 | + pub experience: u64, |
| 109 | + pub nickname: Option<String>, // New field |
| 110 | +} |
| 111 | + |
| 112 | +// New size: 8 + 32 + 2 + 8 + (1 + 4 + N) bytes |
| 113 | +// - 1 byte: Option discriminant (0 = None, 1 = Some) |
| 114 | +// - 4 bytes: String length prefix |
| 115 | +// - N bytes: String data (variable) |
| 116 | +``` |
| 117 | + |
| 118 | +**New TypeScript:** |
| 119 | +```typescript |
| 120 | +export interface PlayerAccount { |
| 121 | + wallet: PublicKey; |
| 122 | + level: number; |
| 123 | + experience: number; |
| 124 | + nickname: string | undefined; // New field |
| 125 | +} |
| 126 | + |
| 127 | +export const PlayerAccountBorshSchema = borsh.struct([ |
| 128 | + borsh.publicKey('wallet'), |
| 129 | + borsh.u16('level'), |
| 130 | + borsh.u64('experience'), |
| 131 | + borsh.option(borsh.string(), 'nickname'), // New field |
| 132 | +]); |
| 133 | +``` |
| 134 | + |
| 135 | +--- |
| 136 | + |
| 137 | +## Deserialization Behavior |
| 138 | + |
| 139 | +### Old Account (v1.0.0) Read by New Program (v1.1.0) |
| 140 | + |
| 141 | +```rust |
| 142 | +// Account data (50 bytes): [wallet][level][experience] |
| 143 | +// No nickname field present |
| 144 | + |
| 145 | +let account = PlayerAccount::try_from_slice(&data)?; |
| 146 | +// ✅ Succeeds! |
| 147 | + |
| 148 | +assert_eq!(account.nickname, None); // Defaults to None |
| 149 | +``` |
| 150 | + |
| 151 | +**How Borsh Handles This:** |
| 152 | +- `Option<T>` is encoded as: `1 byte discriminant + T data (if Some)` |
| 153 | +- If bytes don't exist, Borsh deserializes as `None` |
| 154 | +- No error, clean default behavior |
| 155 | + |
| 156 | +--- |
| 157 | + |
| 158 | +## Program Logic Updates |
| 159 | + |
| 160 | +### Creating New Accounts (v1.1.0) |
| 161 | + |
| 162 | +```rust |
| 163 | +pub fn create_player( |
| 164 | + ctx: Context<CreatePlayer>, |
| 165 | + nickname: Option<String> |
| 166 | +) -> Result<()> { |
| 167 | + let player = &mut ctx.accounts.player; |
| 168 | + |
| 169 | + player.wallet = ctx.accounts.signer.key(); |
| 170 | + player.level = 1; |
| 171 | + player.experience = 0; |
| 172 | + player.nickname = nickname; // ✅ Can be None or Some |
| 173 | + |
| 174 | + Ok(()) |
| 175 | +} |
| 176 | +``` |
| 177 | + |
| 178 | +### Reading Existing Accounts |
| 179 | + |
| 180 | +```rust |
| 181 | +pub fn get_display_name(player: &PlayerAccount) -> String { |
| 182 | + player.nickname.clone().unwrap_or_else(|| { |
| 183 | + // Fallback for old accounts without nickname |
| 184 | + format!("Player {}", &player.wallet.to_string()[..8]) |
| 185 | + }) |
| 186 | +} |
| 187 | +``` |
| 188 | + |
| 189 | +### Updating Nickname |
| 190 | + |
| 191 | +```rust |
| 192 | +pub fn set_nickname( |
| 193 | + ctx: Context<SetNickname>, |
| 194 | + nickname: String |
| 195 | +) -> Result<()> { |
| 196 | + let player = &mut ctx.accounts.player; |
| 197 | + |
| 198 | + // Works for both old and new accounts |
| 199 | + player.nickname = Some(nickname); |
| 200 | + |
| 201 | + Ok(()) |
| 202 | +} |
| 203 | +``` |
| 204 | + |
| 205 | +--- |
| 206 | + |
| 207 | +## TypeScript SDK Updates |
| 208 | + |
| 209 | +### Fetching Accounts |
| 210 | + |
| 211 | +```typescript |
| 212 | +// Old accounts (v1.0.0) |
| 213 | +const player = await program.account.playerAccount.fetch(playerPubkey); |
| 214 | +console.log(player.nickname); // undefined (old account) |
| 215 | + |
| 216 | +// New accounts (v1.1.0) |
| 217 | +const newPlayer = await program.account.playerAccount.fetch(newPlayerPubkey); |
| 218 | +console.log(newPlayer.nickname); // "CryptoKnight" (new account) |
| 219 | +``` |
| 220 | + |
| 221 | +### Display Logic |
| 222 | + |
| 223 | +```typescript |
| 224 | +function getDisplayName(player: PlayerAccount): string { |
| 225 | + return player.nickname ?? `Player ${player.wallet.toBase58().slice(0, 8)}`; |
| 226 | +} |
| 227 | +``` |
| 228 | + |
| 229 | +--- |
| 230 | + |
| 231 | +## Account Reallocation (If Needed) |
| 232 | + |
| 233 | +If you want to add data to existing accounts (e.g., set nickname for old accounts): |
| 234 | + |
| 235 | +```rust |
| 236 | +use anchor_lang::prelude::*; |
| 237 | + |
| 238 | +#[derive(Accounts)] |
| 239 | +pub struct SetNicknameWithRealloc<'info> { |
| 240 | + #[account( |
| 241 | + mut, |
| 242 | + realloc = 8 + 32 + 2 + 8 + 1 + 4 + 20, // Max nickname: 20 chars |
| 243 | + realloc::payer = payer, |
| 244 | + realloc::zero = false, |
| 245 | + )] |
| 246 | + pub player: Account<'info, PlayerAccount>, |
| 247 | + |
| 248 | + #[account(mut)] |
| 249 | + pub payer: Signer<'info>, |
| 250 | + |
| 251 | + pub system_program: Program<'info, System>, |
| 252 | +} |
| 253 | + |
| 254 | +pub fn set_nickname_with_realloc( |
| 255 | + ctx: Context<SetNicknameWithRealloc>, |
| 256 | + nickname: String, |
| 257 | +) -> Result<()> { |
| 258 | + require!(nickname.len() <= 20, ErrorCode::NicknameTooLong); |
| 259 | + |
| 260 | + let player = &mut ctx.accounts.player; |
| 261 | + player.nickname = Some(nickname); |
| 262 | + |
| 263 | + Ok(()) |
| 264 | +} |
| 265 | +``` |
| 266 | + |
| 267 | +**Rent Cost for Realloc:** |
| 268 | +```typescript |
| 269 | +// Calculate rent for 20-character nickname |
| 270 | +const additionalBytes = 1 + 4 + 20; // Option + length + data |
| 271 | +const rentPerByte = await connection.getMinimumBalanceForRentExemption(1) - |
| 272 | + await connection.getMinimumBalanceForRentExemption(0); |
| 273 | +const totalRent = rentPerByte * additionalBytes; |
| 274 | +console.log(`Rent cost: ${totalRent / LAMPORTS_PER_SOL} SOL`); |
| 275 | +``` |
| 276 | + |
| 277 | +--- |
| 278 | + |
| 279 | +## Testing |
| 280 | + |
| 281 | +### Backward Compatibility Test |
| 282 | + |
| 283 | +```rust |
| 284 | +#[test] |
| 285 | +fn test_old_account_deserializes_with_new_schema() { |
| 286 | + use borsh::{BorshSerialize, BorshDeserialize}; |
| 287 | + use solana_program::pubkey::Pubkey; |
| 288 | + |
| 289 | + // Simulate v1.0.0 account (no nickname) |
| 290 | + #[derive(BorshSerialize, BorshDeserialize)] |
| 291 | + struct PlayerAccountV1 { |
| 292 | + wallet: Pubkey, |
| 293 | + level: u16, |
| 294 | + experience: u64, |
| 295 | + } |
| 296 | + |
| 297 | + let v1_account = PlayerAccountV1 { |
| 298 | + wallet: Pubkey::new_unique(), |
| 299 | + level: 10, |
| 300 | + experience: 500, |
| 301 | + }; |
| 302 | + |
| 303 | + let bytes = borsh::to_vec(&v1_account).unwrap(); |
| 304 | + |
| 305 | + // Deserialize with v1.1.0 schema |
| 306 | + let v1_1_account = PlayerAccount::try_from_slice(&bytes).unwrap(); |
| 307 | + |
| 308 | + assert_eq!(v1_1_account.wallet, v1_account.wallet); |
| 309 | + assert_eq!(v1_1_account.level, 10); |
| 310 | + assert_eq!(v1_1_account.experience, 500); |
| 311 | + assert_eq!(v1_1_account.nickname, None); // ✅ Defaults to None |
| 312 | +} |
| 313 | +``` |
| 314 | + |
| 315 | +--- |
| 316 | + |
| 317 | +## Deployment Checklist |
| 318 | + |
| 319 | +- [x] **Version incremented** (1.0.0 → 1.1.0) |
| 320 | +- [x] **Optional field added at end** (not in middle) |
| 321 | +- [x] **`lumos diff` shows non-breaking** change |
| 322 | +- [x] **Backward compatibility tested** |
| 323 | +- [x] **Program logic handles `None` case** |
| 324 | +- [x] **TypeScript SDK regenerated** |
| 325 | +- [x] **Docs updated** with new field |
| 326 | +- [ ] **Deploy to devnet** for final testing |
| 327 | +- [ ] **Deploy to mainnet** |
| 328 | + |
| 329 | +--- |
| 330 | + |
| 331 | +## Key Takeaways |
| 332 | + |
| 333 | +✅ **Always append optional fields** - Never insert in middle |
| 334 | +✅ **Use `Option<T>` for new fields** - Allows backward compatibility |
| 335 | +✅ **Test old accounts deserialize** - Write compatibility tests |
| 336 | +✅ **Handle `None` gracefully** - Provide sensible defaults |
| 337 | +✅ **This is a MINOR version bump** - Not breaking |
| 338 | + |
| 339 | +--- |
| 340 | + |
| 341 | +## Next Steps |
| 342 | + |
| 343 | +- 📖 [Schema Versioning Guide](/guides/versioning) - Full versioning rules |
| 344 | +- 📖 [Changing Field Type Example](/examples/versioning/changing-field-type) - Breaking change |
| 345 | +- 📖 [Deprecating Field Example](/examples/versioning/deprecating-field) - Phased migration |
| 346 | + |
| 347 | +--- |
| 348 | + |
| 349 | +**This pattern is production-ready and safe for mainnet deployments.** 🚀 |
0 commit comments