Conversation
There was a problem hiding this comment.
Pull request overview
This PR adjusts the Solana spoke’s CCIP settlement flow to avoid immediate intent-status PDA creation during ccip_receive by persisting settlement data in SpokeState and introducing a follow-up instruction to finalize delivery.
Changes:
- Add
pending_ccip_settlementtoSpokeStateand introduce a newsettle_ccip_deliveryinstruction to create theintent_status_pdawithDeliveredstatus later. - Add a v2 spoke-state migration (
migrate_spoke_state_v2) to realloc the state PDA for the new field. - Extend error handling and exports to support the new pending-settlement workflow.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/contracts/solana-spoke/programs/everclear_spoke/src/state.rs | Adds pending_ccip_settlement to global state and updates the SIZE constant. |
| packages/contracts/solana-spoke/programs/everclear_spoke/src/lib.rs | Exposes new settle_ccip_delivery and migrate_spoke_state_v2 entrypoints. |
| packages/contracts/solana-spoke/programs/everclear_spoke/src/instructions/state_migration.rs | Implements the v2 realloc migration and adds size-consistency tests. |
| packages/contracts/solana-spoke/programs/everclear_spoke/src/instructions/receive_message/mod.rs | Re-exports the new settle-delivery accounts context. |
| packages/contracts/solana-spoke/programs/everclear_spoke/src/instructions/receive_message/handle_cpi.rs | Stores CCIP settlement in state and adds settle_ccip_delivery to write the intent status PDA later, plus tests. |
| packages/contracts/solana-spoke/programs/everclear_spoke/src/error.rs | Adds errors for pending-settlement presence/absence. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| + 1 + 8 // everclear_ccip_chain_selector: Option<u64> | ||
| + 1 // messaging_provider: MessagingProviderType | ||
| + 32 // everclear_gateway: [u8; 32] | ||
| + 1 + 136 // pending_ccip_settlement: Option<Settlement> | ||
| ; |
There was a problem hiding this comment.
SpokeState::SIZE is updated with + 1 + 136 for pending_ccip_settlement, but Settlement’s custom deserialize reads 160 bytes (5×32). If pending_ccip_settlement can be Some, this allocation is too small and will break account deserialization. Update the sizing logic to match the actual serialized/deserialized representation (e.g., Option tag + 160 if using slot-based encoding), and adjust any migrations that depend on this constant.
| /// Migration for SpokeState: realloc account to add pending_ccip_settlement field. | ||
| /// Run once per deployment after upgrading the program. Safe to run only on accounts | ||
| /// that have the post-CCIP layout (after migrate_spoke_state) but pre-pending_ccip_settlement. | ||
| /// | ||
| /// Pre-migration account body size: 339 bytes (8 + 339 = 347 total) | ||
| /// Post-migration account body size: 476 bytes (8 + 476 = 484 total) | ||
| /// New field: pending_ccip_settlement: Option<Settlement> = 1 (None discriminant) + 136 = 137 bytes | ||
| pub fn migrate_spoke_state_v2(ctx: Context<MigrateSpokeState>) -> Result<()> { | ||
| let spoke_state_info = &ctx.accounts.spoke_state; | ||
|
|
||
| let (expected_pda, _) = Pubkey::find_program_address(&[b"spoke-state"], ctx.program_id); | ||
| require!( | ||
| spoke_state_info.key() == expected_pda, | ||
| SpokeError::InvalidArgument | ||
| ); | ||
|
|
||
| let account_data = spoke_state_info.data.borrow(); | ||
| let current_size = account_data.len(); | ||
| // Pre-CCIP-settlement layout: 8 (discriminator) + 339 (body) = 347 | ||
| let expected_old_size = 8 + 339; | ||
| let expected_new_size = 8 + SpokeState::SIZE; | ||
|
|
||
| // Idempotent: if already at new size, no-op | ||
| if current_size == expected_new_size { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| require!( | ||
| current_size == expected_old_size, | ||
| SpokeError::InvalidArgument | ||
| ); |
There was a problem hiding this comment.
This migration hard-codes pre/post sizes (339→476 body; 347→484 total) based on Option<Settlement> = 1 + 136. However Settlement’s custom AnchorDeserialize reads 160 bytes (5×32-byte slots). If pending_ccip_settlement is ever Some, the new account size needs to accommodate that representation (and any required padding), otherwise Account<SpokeState> deserialization will fail after migration. Please recompute the new sizes from the actual on-chain encoding you intend to use (ideally by unifying Settlement serialize/deserialize) and update expected_old_size/expected_new_size and these tests accordingly.
| // Store settlement in spoke_state for relay-triggered settlement | ||
| let state = &mut ctx.accounts.spoke_state; | ||
| require!( | ||
| state.pending_ccip_settlement.is_none(), | ||
| SpokeError::PendingSettlementExists | ||
| ); | ||
| state.pending_ccip_settlement = Some(batch.settlements[0].clone()); | ||
|
|
||
| emit!(MessageReceivedEvent { | ||
| origin: ctx.accounts.spoke_state.domain, | ||
| sender: h256_to_pub(crate::hyperlane::H256::from(ctx.accounts.spoke_state.everclear_gateway)), | ||
| origin: state.domain, | ||
| sender: h256_to_pub(crate::hyperlane::H256::from(state.everclear_gateway)), | ||
| }); |
There was a problem hiding this comment.
handle_ccip_receive writes Some(settlement) into spoke_state.pending_ccip_settlement. With the current Settlement implementations, AnchorSerialize writes a compact bool (1 byte) but AnchorDeserialize for Settlement reads a 32-byte slot for that field; since this is stored in-account, subsequent deserialization of Account<SpokeState> can fail once this is set (or mis-read update_virtual_balance). Before persisting Settlement on-chain, make sure its serialize/deserialize formats are consistent (or store a dedicated fixed-size representation for the pending settlement).
| @@ -185,7 +208,13 @@ fn mark_settlement_as_delivered_ccip(ctx: Context<CcipReceiveContext>, settlemen | |||
| let (_payer_pda, payer_pda_bump) = Pubkey::find_program_address(payer_seed, ctx.program_id); | |||
|
|
|||
| invoke_signed( | |||
| &inst, | |||
| &anchor_lang::solana_program::system_instruction::create_account( | |||
| &ctx.accounts.pda_payer.key(), | |||
| &intent_status_pda.key(), | |||
| lamports, | |||
| space as u64, | |||
| ctx.program_id, | |||
| ), | |||
| &[ | |||
| ctx.accounts.pda_payer.to_account_info(), | |||
| intent_status_pda.to_account_info(), | |||
| @@ -195,16 +224,9 @@ fn mark_settlement_as_delivered_ccip(ctx: Context<CcipReceiveContext>, settlemen | |||
| intent_status_pda_seeds!(settlement.intent_id, intent_status_bump), | |||
| ], | |||
| )?; | |||
There was a problem hiding this comment.
settle_ccip_delivery creates the intent-status PDA via system_instruction::create_account when deserialization fails. PDAs can be pre-funded via a plain SOL transfer (no signature needed), leaving them system-owned with lamports > 0; in that case create_account will fail with “account already in use”, permanently blocking settlement for that intent. Use the existing create_or_claim_intent_status_pda (allocate/assign/top-up) pattern here as well, or otherwise handle the “lamports present but not initialized” case to make this instruction robust and DoS-resistant.
| // The SIZE constant must account for pending_ccip_settlement: Option<Settlement> | ||
| // Option discriminant (1 byte) + Settlement (136 bytes) = 137 | ||
| let size_without_pending = SpokeState::SIZE - (1 + 136); | ||
| assert_eq!(size_without_pending, 339, "Pre-pending-settlement size should be 339"); | ||
| assert_eq!(SpokeState::SIZE, 476, "Full SpokeState::SIZE should be 476"); |
There was a problem hiding this comment.
The new size-related tests hard-code assumptions like Settlement = 136 bytes and SpokeState::SIZE = 476. Given Settlement already has different serialize vs deserialize layouts, these fixed numbers are brittle and can mask real encoding bugs. Prefer deriving expected sizes from the actual encoding used on-chain (e.g., Settlement::try_to_vec().len() / Option overhead) so failures track real serialization changes.
| // The SIZE constant must account for pending_ccip_settlement: Option<Settlement> | |
| // Option discriminant (1 byte) + Settlement (136 bytes) = 137 | |
| let size_without_pending = SpokeState::SIZE - (1 + 136); | |
| assert_eq!(size_without_pending, 339, "Pre-pending-settlement size should be 339"); | |
| assert_eq!(SpokeState::SIZE, 476, "Full SpokeState::SIZE should be 476"); | |
| // The SIZE constant must account for pending_ccip_settlement: Option<Settlement>. | |
| // Derive the actual serialized sizes instead of hard-coding byte counts, so this | |
| // test tracks real on-chain encoding changes. | |
| let settlement = make_test_settlement([0u8; 32]); | |
| // Serialized size of the bare Settlement | |
| let settlement_bytes = settlement | |
| .try_to_vec() | |
| .expect("Settlement should serialize"); | |
| let settlement_len = settlement_bytes.len(); | |
| // Serialized sizes for Option<Settlement>::Some and ::None | |
| let option_some_bytes = Option::Some(settlement) | |
| .try_to_vec() | |
| .expect("Option<Settlement>::Some should serialize"); | |
| let option_none_bytes = Option::<Settlement>::None | |
| .try_to_vec() | |
| .expect("Option<Settlement>::None should serialize"); | |
| let option_some_len = option_some_bytes.len(); | |
| let option_none_len = option_none_bytes.len(); | |
| // The Option overhead (discriminant, plus any encoding-specific padding) should be | |
| // the difference between Some and the inner Settlement, and match the None size. | |
| let option_overhead = option_some_len | |
| .checked_sub(settlement_len) | |
| .expect("Option<Settlement>::Some length must exceed inner Settlement length"); | |
| assert_eq!( | |
| option_overhead, option_none_len, | |
| "Option<Settlement> discriminant/overhead should equal None's length", | |
| ); | |
| // Ensure SpokeState::SIZE includes enough space for the pending_ccip_settlement | |
| // field encoded as an Option<Settlement>. | |
| assert!( | |
| SpokeState::SIZE >= option_some_len, | |
| "SpokeState::SIZE ({}) must be at least large enough to hold pending_ccip_settlement ({} bytes)", | |
| SpokeState::SIZE, | |
| option_some_len, | |
| ); | |
| let size_without_pending = SpokeState::SIZE - option_some_len; | |
| assert!( | |
| size_without_pending > 0, | |
| "SpokeState must have non-pending fields; size without pending_ccip_settlement should be > 0", | |
| ); |
| /// Pending CCIP settlement awaiting relay-triggered settlement | ||
| pub pending_ccip_settlement: Option<Settlement>, |
There was a problem hiding this comment.
pending_ccip_settlement: Option<Settlement> is stored inside SpokeState, but Settlement has a custom AnchorDeserialize that reads 5×32-byte slots (160 bytes) while its derived AnchorSerialize writes a compact layout (bool = 1 byte). Because this field is at the end of the account, there isn’t enough trailing padding, so once pending_ccip_settlement becomes Some, deserializing Account<SpokeState> will fail (EOF) on subsequent instructions. Align Settlement’s AnchorSerialize with its custom deserialize (or use a separate type for on-chain storage), and size the field for 1 + 160 bytes (Option tag + settlement) if keeping the 32-byte-slot layout.
🤖 Linear
Fixing evm to svm account storage issue by adjusting ccip message storage flow.