Skip to content

fix: ccip settlement state#618

Open
0xHarbs wants to merge 1 commit intodevfrom
fix/solana-ccip-pda
Open

fix: ccip settlement state#618
0xHarbs wants to merge 1 commit intodevfrom
fix/solana-ccip-pda

Conversation

@0xHarbs
Copy link
Collaborator

@0xHarbs 0xHarbs commented Mar 25, 2026

🤖 Linear

Fixing evm to svm account storage issue by adjusting ccip message storage flow.

Copilot AI review requested due to automatic review settings March 25, 2026 11:31
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_settlement to SpokeState and introduce a new settle_ccip_delivery instruction to create the intent_status_pda with Delivered status 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.

Comment on lines 74 to 78
+ 1 + 8 // everclear_ccip_chain_selector: Option<u64>
+ 1 // messaging_provider: MessagingProviderType
+ 32 // everclear_gateway: [u8; 32]
+ 1 + 136 // pending_ccip_settlement: Option<Settlement>
;
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +90 to +120
/// 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
);
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +140 to 151
// 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)),
});
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines 185 to 226
@@ -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),
],
)?;
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +659 to +663
// 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");
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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",
);

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +53
/// Pending CCIP settlement awaiting relay-triggered settlement
pub pending_ccip_settlement: Option<Settlement>,
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants