Create your own
Lesson illustration

Cross-Chain Message Validation: Domain, Sender, Payload, Finality, and Replay Protection

Welcome back. In the previous lesson, you traced an EVM-to-Solana transfer as a sequence of source execution, finality and attestation, Solana-side verification, and destination execution. The essential security lesson was that a relayer transports evidence but does not become trusted merely by transporting it.

Now we design the receiving side. By the end of this lesson, you should be able to specify the five checks a Solana program needs before it acts on a message originating on an EVM chain:

  1. Source domain: which chain did this claim come from?
  2. Sender: which contract emitted it?
  3. Payload: what action is being requested, for whom, and under what limits?
  4. Finality: was the source event attested with enough resistance to reorganization?
  5. Replay protection: has this exact message already been consumed?

The examples use Wormhole’s posted VAA model, then compare it with LayerZero’s endpoint-managed receive flow. The security design itself is protocol-independent.


A verified message is necessary, but not sufficient

A cross-chain protocol can establish a narrow claim:

A particular emitter on a particular source chain published particular bytes, and the protocol considers that event sufficiently final under its rules.

Your application must still decide whether that claim authorizes your intended action.

This distinction matters because a valid VAA can be:

  • from the wrong chain;
  • from an untrusted contract on the right chain;
  • from your trusted contract but intended for a different Solana application;
  • correctly formed yet obsolete, too risky for the action, or already executed.

The system overview below separates the stages. The receiving program sits at Destination Action. Its job is not to reimplement bridging or signature verification; it consumes verified evidence and applies application-specific authorization rules before changing state.

A six-stage cross-chain workflow: user intent creates a source action, a coordinator or relayer carries evidence, the destination executes an action, optional settlement finalizes asset accounting, and observability records events and identifiers. The Solana receiver’s validation belongs at the destination-action stage.

A useful design principle is:

Treat every field that affects authorization, value, identity, or destination as untrusted until it has been checked against a rule your program owns.

This includes data that was cryptographically authenticated by an interoperability protocol. Authentication says who published bytes; it does not automatically say the bytes are safe for a particular state transition.


Establish the receiving trust boundary

In a Wormhole-based Solana application, the Core Contract posts and verifies the VAA first. Your program then receives the corresponding posted-message account as an input and reads its authenticated metadata and payload.

Get Started with Core Contracts | Wormhole Docs

Read Wormhole’s “Get Started with Core Contracts” guide. It establishes the Solana-specific division of responsibility: the Core Contract verifies and posts a VAA, while your program reads it and performs application safety checks.

In the “Receiving Messages” section, begin at the Solana receive flow. Notice that the posted message is evidence supplied to your instruction, not a command that must be obeyed. Then read the “Validating the Emitter” section, especially the trusted-emitter rationale. Finish with “Additional Checks,” from the checklist on sequence, consistency, and digest. Relate each item to a distinct policy decision rather than treating VAA validity as a complete authorization decision.

A receiver should be thought of as having two layers:

LayerPrimary questionUsually owned by
Protocol-verification layer“Is this a genuine protocol-attested message?”Wormhole Core, LayerZero Endpoint, or another interoperability protocol
Application-authorization layer“Should this program perform this action for this message?”Your Solana program

The first layer protects against forged attestations. The second protects against misrouting, overly broad privileges, unintended payload interpretation, duplicate execution, and weak source-finality policy.

For a new cross-chain feature, write the application layer down before writing the instruction handler. A concise policy statement might be:

This program accepts GrantAchievementV1 only from Ethereum chain , emitted by contract , addressed to this Solana program , carrying a supported version and bounded fields, attested at or above finality policy , and not previously consumed.

Each part maps directly to a check.


Check 1: bind the source domain and sender together

An EVM address is not globally unique. The same 20-byte value can exist on Ethereum, an L2, a testnet, or a private chain. Conversely, a Wormhole emitter is represented as a fixed-width 32-byte identifier, while familiar EVM contract addresses are 20 bytes.

Therefore, never allowlist only an address. Allowlist the ordered pair:

For a receiver accepting messages from one Ethereum contract, the conceptual configuration is:

TrustedEmitter {
    source_chain: ETHEREUM_CHAIN_ID,
    emitter: canonical_32_byte_evm_address,
    enabled: true,
}

For several origins, store a mapping keyed by source domain, or derive a PDA such as:

[b"trusted_emitter", source_chain_id_bytes]

The account’s stored emitter value is then compared with the emitter field in the verified message.

Why both checks are mandatory

Suppose your Solana program accepts messages from a governance contract deployed at an EVM address 0xAB...CD.

  • If it checks only the address, a message from a same-address deployment on an unintended chain could pass.
  • If it checks only Ethereum as the source domain, any Wormhole-capable publisher on Ethereum could instruct the receiver.
  • If it checks neither, any protocol-attested sender can potentially access your business logic.

The correct rule is an exact match for both domain and registered sender.

Canonical representations are security-relevant

Do not rely on UI-style EVM address strings inside the program. Store and compare the protocol’s canonical binary emitter representation. For Wormhole, that typically means the EVM address in a 32-byte format with the defined padding convention.

This avoids a class of integration bugs where:

  • an operator registers a noncanonical address representation;
  • the program compares the wrong byte order or width;
  • an allowlist is accidentally configured for the wrong environment.

Treat chain identifiers and canonical emitter bytes as configuration that deserves the same operational controls as an administrative authority. A typo here can produce either a total outage or an unintended trust relationship.

Make trusted-emitter changes deliberate

A trusted-emitter registry is powerful configuration. An administrator who can replace it can redirect incoming authorization to a new EVM contract. A practical configuration design includes:

  • a defined admin or governance authority;
  • an event whenever an emitter is added, replaced, disabled, or removed;
  • separate devnet and mainnet configurations;
  • an optional delay or multisig process for a production emitter change;
  • a way to pause inbound execution independently of changing the allowlist.

Do not accept an emitter merely because it is an official-looking deployment of a protocol. Your application should accept only the specific remote application contract that it intends to trust.


Check 2: make the payload a narrow, versioned command

After source and sender checks pass, the payload is still only a byte array. Your receiver must decide what those bytes mean.

The unsafe pattern is:

Decode arbitrary bytes and use them to select accounts, authorities, or instructions freely.

The safer pattern is:

Decode a small, versioned message schema; accept only known commands; validate every security-relevant field against the receiving program’s policy and provided accounts.

Consider an Ethereum-to-Solana game feature. An approved Ethereum contract may publish a request to grant an achievement on Solana:

version: 1
kind: GrantAchievement
destination_program: <your Solana program id>
recipient: <Solana public key>
achievement_id: 42
source_user: <EVM address>
request_id: <application identifier>

Even after the source is trusted, validate the payload in stages.

1. Decode exactly one supported schema

Prefer an explicit discriminator and version:

enum IncomingMessage {
    GrantAchievementV1 {
        destination_program: Pubkey,
        recipient: Pubkey,
        achievement_id: u32,
        request_id: [u8; 32],
    },
}

Reject:

  • unknown variants;
  • unsupported versions;
  • truncated data;
  • trailing data when your format requires an exact length;
  • fields that cannot be decoded without ambiguity.

A version field lets you add GrantAchievementV2 later without silently applying old validation rules to new data. It also makes upgrades inspectable: a future handler can consciously support both versions, reject v1 after a migration period, or route them to separate logic.

2. Bind the message to the intended destination

Many interoperability messages are multicast at the protocol layer. A VAA may be delivered anywhere, so the payload should identify the intended destination application, domain, or both.

For this example, require:

require_keys_eq!(
    message.destination_program,
    crate::ID,
    ReceiverError::WrongDestination
);

If the message also includes a destination chain or endpoint identifier, check that it names Solana according to your protocol’s canonical identifier.

Without destination binding, a message emitted for one Solana program might be interpreted by another program that happens to parse the same layout. This is particularly dangerous when several applications share an emitter or reuse payload formats.

3. Validate business fields before value movement or state mutation

The receiver should impose its own constraints. Examples include:

Payload fieldTypical validation
RecipientMust be a real Pubkey; if a recipient state PDA is required, derive and verify it rather than trusting a supplied account.
AmountNonzero, below a per-message cap, and safe in all arithmetic.
Token or asset IDMust equal a configured asset, not an arbitrary mint supplied by the message.
Achievement or action IDMust be in a supported range or registry.
DeadlineMust be valid under an explicitly chosen time policy.
Source userRequired only if your application needs an EVM-to-Solana identity binding.
Request IDMust meet any application-level uniqueness rule in addition to protocol replay protection.

In account-based Solana programming, payload validation and account validation reinforce each other. If the payload says “credit recipient ,” do not let the transaction caller provide an arbitrary writable recipient account. Derive the expected account from , or constrain the supplied account so that its stored owner is .

Do not allow payloads to choose privileged destinations

A particularly dangerous payload is one that contains arbitrary:

  • Solana program IDs for CPIs;
  • token mint addresses;
  • authority addresses;
  • writable program-state accounts;
  • account lists passed through as remaining_accounts.

If your receiver needs a CPI, choose the target program from static code or tightly controlled configuration, then validate all accounts it receives. Cross-chain payloads should describe business intent, not grant a remote message arbitrary local execution power.


Check 3: define a finality policy for the action’s risk

A source-chain transaction can be confirmed and then disappear from the canonical chain during a reorganization. Finality policy answers how much reorganization risk the application is willing to accept before Solana performs an irreversible or costly action.

This is separate from authenticity:

  • A valid attestation can truthfully represent an event observed under a low finality threshold.
  • Your application may still require a stronger level before minting, releasing value, changing ownership, or recording a governance outcome.

For Wormhole messages, inspect the attested consistency or finality information exposed by the posted message. Your policy should be defined per source domain, because the meaning and strength of finality differ across EVM networks.

A simple policy table could look like this:

Source domainAction categoryMinimum finality policyResponse if insufficient
Ethereum mainnetLow-value notificationApplication-defined accepted levelReject or defer
Ethereum mainnetAsset mint, unlock, or governance executionStrongest policy approved for productionReject or defer
EVM testnetDevelopment-only messageExplicit testnet policyNever mix with mainnet state

The exact numeric level is protocol- and chain-specific configuration, not a universal constant to copy into every project. The critical engineering decision is to classify actions by risk and encode the required policy.

Finality does not mean “the message is fresh”

Three ideas are often confused:

PropertyQuestion answered
FinalityHow safe is it that the source event will remain in source-chain history?
OrderingIs this message being processed in the intended position relative to other messages?
FreshnessIs this message still timely for the application’s purpose?

A highly final message can still be old. If a message authorizes a time-sensitive quote, auction settlement, or game action, include an application expiry or an epoch in the payload and validate it. Be cautious with cross-chain timestamps: they are useful policy inputs, but should not be treated as a precise synchronized clock across chains.

Sequence numbers help only when your semantics require ordering

A sequence number identifies a message within an emitter’s stream. It can support policies such as:

  • strictly process messages in order;
  • allow out-of-order processing but record the highest processed sequence;
  • accept independent commands in any order;
  • reject messages older than an application checkpoint.

Do not add strict ordering automatically. It may create unnecessary liveness problems: if sequence is delayed, a rule requiring before can block later messages even when they are safe and independent.

For value transfers, replay protection normally matters more than ordering. For replicated state updates, ordering or explicit versioning may be essential. Choose the rule from the application’s semantics, not because a sequence field happens to exist.


Check 4: consume each message exactly once

A cross-chain message may be delivered more than once. Retries are expected when a relayer fails, a transaction expires, or several delivery services race to submit the same evidence.

Your program needs a stable identity for each consumed message. With Wormhole, the VAA digest is designed for this purpose. At the application level, a suitable identity generally includes the protocol context and the immutable message digest:

The digest is preferable to a payload hash alone. Two different source messages may intentionally carry identical payloads, and the same payload can appear from different emitters or chains.

A replay-marker PDA

On Solana, the natural implementation is a dedicated PDA whose existence means “this message was consumed”:

[b"consumed", vaa_digest]

The account can store enough audit data to make operations and debugging easier:

#[account]
pub struct ConsumedMessage {
    pub vaa_digest: [u8; 32],
    pub source_chain: u16,
    pub emitter: [u8; 32],
    pub sequence: u64,
    pub consumed_at: i64,
    pub payload_version: u8,
}

The actual account layout and field types are your decision. The important invariant is:

There is exactly one valid replay marker for exactly one authenticated message identity.

Anchor’s init constraint is useful here. If two transactions try to process the same message, only one can create the same PDA. The other fails instead of applying the action a second time.

Conceptually:

#[derive(Accounts)]
#[instruction(vaa_digest: [u8; 32])]
pub struct Receive<'info> {
    #[account(
        init,
        payer = payer,
        space = 8 + ConsumedMessage::INIT_SPACE,
        seeds = [b"consumed", &vaa_digest],
        bump
    )]
    pub consumed: Account<'info, ConsumedMessage>,

    #[account(mut)]
    pub payer: Signer<'info>,

    // Posted VAA/message account and application state accounts omitted.
}

This sketch is not a complete Wormhole account context. In production, you must additionally constrain the posted-message account to the expected Wormhole Core program and deserialize it through the supported interface. The replay PDA is only one layer of the handler.

Atomicity is what makes the marker effective

Create the marker and apply the destination action in the same Solana transaction.

If a later validation, state write, or CPI fails, Solana rolls back the marker creation along with the failed transaction. If the transaction succeeds, both the business action and marker persist. This creates an all-or-nothing outcome:

OutcomeBusiness actionReplay marker
Instruction failsRolled backRolled back
Instruction succeedsCommitted onceCommitted once
Same message submitted againNot repeatedPDA initialization fails or existing marker is detected

Do not record “seen by a relayer” in an off-chain database and treat that as replay protection. It can improve monitoring, but only on-chain state can protect the on-chain state transition against another transaction sender.

Protocol replay protection and application replay protection

Some protocols already manage nonce consumption in their endpoint. That is valuable, but understand where the invariant lives.

LayerZero’s Solana OApp reference uses a Peer Config PDA to bind a remote endpoint to a configured peer, then calls Endpoint::clear to burn the inbound nonce before application state is touched.

LayerZero V2 Solana OApp Reference - LayerZero

Read the relevant parts of LayerZero’s Solana OApp reference as a second implementation model. It shows the same security responsibilities expressed through protocol-managed PDAs and an endpoint CPI rather than a receiver-owned VAA digest marker.

First inspect the “Required PDAs” table in the “Required PDAs” section. Focus on how the Peer Config PDA binds a configured remote peer to a source endpoint identifier. Then read “Implement lz_receive — business logic + endpoint clear,” beginning with the receive-handler overview and continuing through the “Rules of thumb” and “Security Reminders.” Focus on the deliberate order: validate the peer, call clear() before changing user state, and verify the stored Endpoint ID before making the CPI.

The mechanism differs, but the underlying principle is identical:

  1. Authenticate the delivery context and remote sender.
  2. Irreversibly mark the protocol message as consumed.
  3. Execute narrowly validated application logic.
  4. Commit everything atomically, or commit nothing.

If you use LayerZero’s endpoint-managed clearing, do not also invent a conflicting nonce system without a reason. You might still retain an application-level request identifier when your business rules need idempotency beyond the transport message, such as “only one reward per EVM account per season.”


Put the checks in a defensible handler order

The following order is a practical template for a Wormhole-style receiver. It is deliberately conservative.

  1. Authenticate the verification account.
    Require the supplied posted-message account to be owned by and decoded according to the expected interoperability Core program. Never deserialize arbitrary account data as if it were verified evidence.

  2. Read immutable attested metadata.
    Obtain source domain, emitter, sequence, finality or consistency level, VAA digest, and payload from the verified message account.

  3. Check source domain and sender.
    Compare the source chain and canonical emitter bytes against program-controlled trusted-emitter configuration.

  4. Check finality policy.
    Reject or defer messages whose attested finality level is too weak for the requested action.

  5. Decode and validate the payload.
    Require a supported version and command. Check destination binding, recipient binding, asset configuration, numeric bounds, optional expiry, and any business invariants.

  6. Consume the unique message identity.
    Initialize the replay-marker PDA, or invoke the protocol’s authenticated nonce-clearing mechanism. This must occur before changing application-controlled user state.

  7. Apply the state transition.
    Write state or make the narrowly authorized CPI. All accounts used by the transition must be constrained independently; a trusted payload never replaces Solana account validation.

  8. Emit an event.
    Include the message identity, source domain, emitter, sequence, action type, and affected local account. This supports cross-chain observability and incident investigation.

In simplified pseudocode:

pub fn receive(ctx: Context<Receive>, vaa_digest: [u8; 32]) -> Result<()> {
    let posted = ctx.accounts.posted_message.data()?;

    require!(
        posted.emitter_chain == ctx.accounts.trusted_emitter.source_chain,
        ReceiverError::WrongSourceDomain
    );

    require!(
        posted.emitter_address == ctx.accounts.trusted_emitter.emitter,
        ReceiverError::UntrustedEmitter
    );

    require!(
        finality_allowed(posted.emitter_chain, posted.consistency_level),
        ReceiverError::InsufficientFinality
    );

    let message = IncomingMessage::try_from_slice(&posted.payload)
        .map_err(|_| ReceiverError::InvalidPayload)?;

    let IncomingMessage::GrantAchievementV1 {
        destination_program,
        recipient,
        achievement_id,
        request_id,
    } = message else {
        return err!(ReceiverError::UnsupportedMessage);
    };

    require_keys_eq!(destination_program, crate::ID, ReceiverError::WrongDestination);
    require!(achievement_is_supported(achievement_id), ReceiverError::InvalidAchievement);

    // The `init`-created PDA makes a duplicate VAA fail atomically.
    ctx.accounts.consumed.set_inner(ConsumedMessage {
        vaa_digest,
        source_chain: posted.emitter_chain,
        emitter: posted.emitter_address,
        sequence: posted.sequence,
        consumed_at: Clock::get()?.unix_timestamp,
        payload_version: 1,
    });

    grant_achievement(&mut ctx.accounts.player_profile, recipient, achievement_id, request_id)?;
    Ok(())
}

The omitted account constraints are not unimportant. They are the next line of defense. For example, player_profile should be the PDA derived from the validated recipient, not simply a mutable account supplied by the relayer.


A design review: identify what each check prevents

Consider a hypothetical receiver that mints a “bridged loyalty credit” after an Ethereum contract publishes a message.

CheckExample policyFailure prevented
Source domainAccept only the configured Ethereum mainnet domainSame emitter address on a testnet or unintended EVM network
SenderExact match with the registered Ethereum LoyaltyGateway contractAnother contract on Ethereum publishing a plausible payload
PayloadMintCreditV1, destination equals this program, configured mint only, bounded amountCross-application confusion, arbitrary mint choice, malformed or excessive credit
FinalityStrong policy for value-bearing creditsActing on a source event later removed by a chain reorganization
ReplayOne PDA per VAA digest, created atomically with mintMultiple credits from delivery retries

Notice what is not listed as an authorization check: “the transaction was submitted by the official relayer.” Anyone may submit a valid message if the protocol permits public delivery. This can be desirable for liveness. Security comes from verified message evidence plus your program’s checks, not the identity of the transaction fee payer.


Design decisions to document before implementation

For an actual Solana program, capture these answers in the repository’s design notes and tests:

QuestionExample answer
Which protocol verification program is trusted?A pinned Core or Endpoint program ID for the target cluster
Which EVM domains are accepted?Ethereum mainnet only; no testnets in the production deployment
Which remote contracts are trusted?One canonical, 32-byte emitter per configured source domain
What actions are supported?A closed set of versioned payload variants
Which payload field binds destination?Destination Solana program ID and destination chain identifier
What finality is required?A documented source-chain policy based on action risk
Are messages ordered?No for independent credit grants; yes only for sequenced state snapshots
What makes a message unique?Protocol VAA digest, with source context retained for audit
Where is consumption recorded?Receiver-owned consumed-message PDA, atomically with application state
Who can change remote configuration?Multisig or governed authority, with audit events and an emergency pause

These choices should become tests. In particular, write negative tests that submit a correctly verified but unacceptable message: wrong chain, wrong emitter, wrong destination field, old finality level, malformed payload, duplicate digest, and mismatched recipient account. Cross-chain incidents often arise from an omitted condition, not a failure of the happy-path transfer.


Key takeaways

A Solana receiver should not equate “protocol verified” with “authorized for my application.” A secure EVM-to-Solana receive flow:

  • verifies that its evidence comes from the expected protocol verification program;
  • allowlists the pair of source domain and canonical emitter, never either field alone;
  • treats the payload as a strictly versioned, destination-bound command with its own business limits;
  • applies a documented source-finality policy appropriate to the action’s risk;
  • records a unique message identity on-chain before applying user-visible state changes;
  • relies on Solana transaction atomicity so replay consumption and the destination action either both commit or both roll back.

Next, the module turns back to Solana program architecture: using Anchor account discriminators to safely distinguish multiple account types within one program.

Can't find a good explanation? Sign up and we'll make it for you

Sign up