LayerZero

LayerZero - Stellar endpoint
Findings & Analysis Report

2026-05-06

Table of contents

Overview

About C4

Code4rena (C4) is a competitive audit platform where security researchers, referred to as Wardens, review, audit, and analyze codebases for security vulnerabilities in exchange for bounties provided by sponsoring projects.

During the audit outlined in this document, C4 conducted an analysis of the LayerZero - Stellar endpoint smart contract system. The audit took place from April 01 to April 14, 2026.

Given the size of the codebase, a contest with a duration of 21 days was initially proposed by Code4rena. To meet LayerZero’s deadline of a Mid April 2026 launch, the contest was shortened to 14 days.

Final report assembled by Code4rena.

Summary

The C4 analysis yielded an aggregated total of 0 HIGH or MEDIUM vulnerabilities. Additionally, C4 analysis included 131 QA reports compiling issues with a risk rating of LOW severity or informational.

All of the issues presented here are linked back to their original finding, which may include relevant context from the judge and LayerZero team.

Scope

The code under review can be found within the C4 LayerZero - Stellar endpoint repository, and is composed of 86 smart contracts written in the Rust programming language and includes 5,002 lines of Rust code.

The code in C4’s LayerZero’s repository was pulled from:

Severity Criteria

C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/informational.

High-level considerations for vulnerabilities span the following key areas when conducting assessments:

  • Malicious Input Handling
  • Escalation of privileges
  • Arithmetic
  • Gas use

For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.

Low Risk and Informational Issues

For this audit, 131 QA reports were submitted by wardens compiling low risk and informational issues. The QA report highlighted below by slvDev received the top score from the judge. 3 Low-severity findings were also submitted individually, and can be viewed here.

The following wardens also submitted QA reports: 0x_DyDx, 0x211001, 0x4non, 0xanony, 0xc0ffEE, 0xc1ph3r, 0xFBI, 0xhp9, 0xiehnnkta, 0xki, 0xnija, 0xSmartContract, 2997ms, a-xc, ABAIKUNANBAEV, Afriauditor, Agontuk, Agrawain, akshay2796, aman, Andytex, AriF9212, Atharv, Athenea, Auditor_Nate, AuditShield, aves, Bala1796, Bale, belmo, biakia, billlucky, BlackAnon, BlockSentry, caesar49, caido01952, CaptSinbad, CertiK, ChainSentry, cheng9061, Cold_Exploit, Collinsoden, Cryptor, Dan23RR, DarkWingCipher, daveclark, Dest1ny_rs, diegoquant, Digg3r, Divine_Dragon, dystopia, eta, Fade_cyrpto, friz, Funen, fyvgsk, ghufran, gz627, HalfBloodPrince, HawkShadows25, hirusha, I1iveF0rTh1Sh1t, j3x, jackX, jamshed, jerry0422, jo13, johnyfwesh, K42, Kael, Kandre, khaye26, kudaliar, legat, LeoGold, LeopoldFlint, letchupkt, luckyidiot, M4v3r1ck, Manosh19, minicookie, moon0x2, mycroft, natachi, NexusAudits, nexusweb3, nonso72, notok, openchat97, oreztker, oxp_tr125, OxRugstein, Pellucid, Pelz, Petrate, pfapostol, Prateekhh, qed, rakanaji, rbd3, ret2basic, richa, Rikka, Romashka, sarugami, Serpent0x, ShadowKinetics, shadowwarden, snufflesrea, Sparrow, Syv, t4sk, Tanjiro, TechFusion, Teycir, TrillionaireEmpressClub, Utilisateur33, vangrim, Vano, verseagent, Viquetour, webrainsec, WhiteKnightK, willycode20, Wojack, wuji, y4y, Yanx, yonko, and zheed.

Low Severity Findings

# Issue File Note
L‑01 pay_messaging_fees uses raw balance(this_contract) endpoint-v2/src/endpoint_v2.rs:244-305 Donation absorption
L‑02 recover_token skims donated dust before next sender endpoint-v2/src/endpoint_v2.rs:32-35 Combines with L-01
L‑03 BlockedMessageLib advertises SendAndReceive without receive impl message-libs/blocked-message-lib/src/lib.rs:57-59 Trait mismatch
L‑04 Cold per-OApp / per-EID config keys can archive endpoint-v2/src/storage.rs, uln-302/src/storage.rs UX friction
L‑05 inbound() does not reject NIL_PAYLOAD_HASH endpoint-v2/src/messaging_channel.rs:171-195 Defense-in-depth
L‑06 Admin/signer rotation has no batched setter or cap workers/worker/src/worker.rs:412-428 Inter-tx window

Non-Critical / Informational Findings

# Issue File Note
NC‑01 DVN upgrade has no timelock or announce window utils/src/upgradeable.rs:68-71 Trust surface
NC‑02 Endpoint::send does not enforce contract-variant sender endpoint-v2/src/endpoint_v2.rs:63-85 Indexer regression
NC‑03 Default ULN config changes silently propagate to all OApps uln-302/src/senduln.rs:187, receiveuln.rs:110 Inheritance amplifier
NC‑04 Worker admin Vec has no cap and linear contains workers/worker/src/worker.rs:412-428 Capacity DoS
NC‑05 DVN signed-payload expiration has no upper bound workers/dvn/src/auth.rs:32 Forward-compat
NC‑06 DVN UsedHash grows unbounded over time workers/dvn/src/storage.rs:23-25 Rent cost
NC‑07 No endpoint check that assign_job amount equals get_fee uln-302/src/send_uln.rs:232-355 Worker trust
NC‑08 write_address_payload drops account/contract tag on the wire utils/src/buffer_writer.rs:85-88 Codec note
NC‑09 secp256k1 accepts v ∈ {29, 30} and high-s signatures utils/src/multisig.rs:159-176 EVM drift
NC‑10 lz_compose_alert callable by any authenticated executor endpoint-v2/src/messaging_composer.rs:75 Event spam
NC‑11 clear_payload lacks explicit nonce > 0 guard endpoint-v2/src/messaging_channel.rs:208-226 Defense-in-depth
NC‑12 register_library probe weaker than ERC-165 endpoint-v2/src/messagelibmanager.rs:20-34 Interface fingerprint
NC‑13 DVN assign_job returns FeeRecipient.to at call time uln-302/src/send_uln.rs:312,339-347 Documented trust model
NC‑14 1-byte DVN option payload decodes as zero-byte option message-libs/message-lib-common/src/worker_options.rs:155 Codec edge case
NC‑15 Pagination view uses unchecked u32 arithmetic endpoint-v2 pagination view View-only
NC‑16 Treasury withdraw_token accepts zero amount message-libs/treasury/src/treasury.rs Event spam
NC‑17 ZRO path has no independent enabled flag message-libs/treasury/src/treasury.rs Cosmetic
NC‑18 begin_ownership_transfer silently floors short TTL utils/src/ownable.rs:142-143 Doc bug
NC‑19 init_worker accepts empty admins despite docstring workers/worker/src/worker.rs:322-338 Docstring unenforced
NC‑20 Unchecked i128::sum over per-DVN fees message-libs/uln-302/src/send_uln.rs:92 Panic-DoS
NC‑21 Treasury fee math uses unchecked multiplication message-libs/treasury/src/treasury.rs:97-99 Overflow abort
NC‑22 Custom→default send-library swap has no grace window endpoint-v2/src/messagelibmanager.rs Cutover surprise

Low Severity Details

[01] pay_messaging_fees uses raw balance(this_contract)

File

  • contracts/protocol/stellar/contracts/endpoint-v2/src/endpoint_v2.rs #L244-#L305

    • (function body; raw reads at :256 native and :283 ZRO)

Issue

pay_messaging_fees defines the fee envelope as native_token_client.balance(&this_contract) rather than as a captured delta or an explicit amount parameter. Any token balance sitting on the Endpoint when send runs — accidental donations, leftovers from a failed prior call, deliberate dust drops — is silently included in the next sender’s “supplied” amount and either consumed as additional fees or refunded to that sender’s refund_address.

Relevant Code

The native fee envelope read:

// endpoint-v2/src/endpoint_v2.rs:256 (inside pay_messaging_fees, native path)
let balance = native_token_client.balance(&this_contract);
// use `balance` as the "supplied" amount; refund surplus to params.refund_address

Parallel ZRO path:

// endpoint-v2/src/endpoint_v2.rs:283 (ZRO path)
let balance = zro_token_client.balance(&this_contract);

There is no balance_before / balance_after delta capture, and send does not accept an explicit amount parameter. Atomicity holds intra-tx, but the pattern violates inter-tx accounting: the Endpoint cannot distinguish in-flight fees from stray donations. This is the canonical raw-balance anti-pattern from the EVM refund-pattern bug catalog, translated verbatim to Soroban TokenClient::balance.

Impact

Donations are silently rerouted to whichever sender calls send next. No single identifiable victim because donors are unknown, but the invariant “the Endpoint never holds in-flight funds between txs” — implicit in the EVM model and asserted in earlier audit notes — does not hold. Pairs with L-02 (owner-side skim). Triple-confirmed across three independent analysis paths.

Recommendation

Capture balance_before at function entry and treat supplied = balance_after - balance_before, or require an explicit amount parameter on send so the Endpoint never reads its own balance for accounting:

let balance_before = native_token_client.balance(&this_contract);
// ... OApp pre-transfer happens here ...
let balance_after = native_token_client.balance(&this_contract);
let supplied = balance_after.checked_sub(balance_before)
    .ok_or(EndpointError::InvalidFeeSupplied)?;

[02] recover_token skims donated dust before next sender

File

  • contracts/protocol/stellar/contracts/endpoint-v2/src/endpoint_v2.rs #L32-#L35

Issue

recover_token is owner-gated (#[only_auth]) and transfers any token balance from the Endpoint to any address. Combined with L-01, the inter-tx window during which donated dust is reachable on the Endpoint becomes a privileged-skim opportunity: the owner can race the next send() by submitting recover_token first and capture dust that would otherwise have flowed to the next sender’s refund_address.

Relevant Code

// endpoint-v2/src/endpoint_v2.rs:32-35
#[only_auth]
fn recover_token(env: &Env, token: Address, to: Address) {
    let balance = TokenClient::new(env, &token).balance(&env.current_contract_address());
    TokenClient::new(env, &token).transfer(&env.current_contract_address(), &to, &balance);
}

No exclusion list (native / ZRO are recoverable), no rate limit, no time lock.

Impact

Defense-in-depth and trust-model divergence. No identifiable victim (donors are anonymous; the next sender was only going to receive a windfall refund). The substantive issue is that the Endpoint becomes a transient owner-trusted custody surface even though its README posture is “passes through, holds nothing”. Endpoint is immutable, owner is permanent, and there is no rate limit on recover_token.

Recommendation

Fix L-01 (captured-delta accounting) so dust is never absorbed in the first place, and restrict recover_token to non-native, non-ZRO tokens — those are the only assets that should ever legitimately transit the Endpoint:

#[only_auth]
fn recover_token(env: &Env, token: Address, to: Address) {
    let native = EndpointStorage::native_token(env);
    assert_with_error!(env, token != native, EndpointError::CannotRecoverNative);
    if let Some(zro) = EndpointStorage::zro_token(env) {
        assert_with_error!(env, token != zro, EndpointError::CannotRecoverZro);
    }
    // ... existing transfer body
}

[03] BlockedMessageLib advertises SendAndReceive without receive impl

File

  • contracts/protocol/stellar/contracts/message-libs/blocked-message-lib/src/lib.rs #L57-#L59

Issue

message_lib_type() returns MessageLibType::SendAndReceive, but only IMessageLib and ISendLib are implemented in the crate. If the endpoint routes a receive-side call (e.g. commit_verification) to a registered BlockedMessageLib, the host call dispatch panics with “function not found” instead of the intended Uln302Error::NotImplemented / domain-typed error. The advertised type contract is broken.

Relevant Code

// message-libs/blocked-message-lib/src/lib.rs:57-59
fn message_lib_type(_env: &Env) -> MessageLibType {
    MessageLibType::SendAndReceive  // advertises both sides
}

No IReceiveLib impl block exists in the crate — only IMessageLib and ISendLib. A receive-side call goes through the host dispatch and hits an undefined function.

Impact

Operator confusion — a registered blocker emits an opaque host error on receive instead of the controlled “blocked” panic. Severity Low because the only path to invoke it is owner-gated registration, and the failure mode is still revert-only.

Recommendation

Return MessageLibType::Send if only the send side is blocked, OR implement an IReceiveLib stub that panics with the canonical NotImplemented error:

#[contractimpl]
impl IReceiveLib for BlockedMessageLib {
    fn commit_verification(env: &Env, _packet_header: Bytes, _payload_hash: BytesN<32>) {
        panic_with_error!(env, BlockedMessageLibError::NotImplemented);
    }
    // ... other IReceiveLib methods with the same panic body
}

[04] Cold per-OApp / per-EID config keys can archive

File

  • contracts/protocol/stellar/contracts/endpoint-v2/src/storage.rs (library and per-OApp keys);
  • message-libs/uln-302/src/storage.rs (default and per-OApp ULN configs);
  • auto-TTL in common-macros/src/storage.rs #L116-#L154

Issue

#[contract_impl] injects only extend_instance_ttl(env) into each public method (common-macros/src/contract_ttl.rs:87-92), which extends instance storage only. Per-key persistent TTL extension is generated by the #[storage] macro at each per-key get/set site and fires only on touched keys. Cold keys that are never read in normal operation eventually archive.

Relevant Code

Concrete archival candidates — IndexToLibrary is written once on registration and only re-read by rare admin enumeration:

// endpoint-v2/src/storage.rs (simplified)
#[persistent]
enum EndpointStorage {
    IndexToLibrary { index: u32 },           // cold after registration
    DefaultSendLibrary { dst_eid: u32 },     // cold for rare EIDs
    DefaultReceiveLibrary { src_eid: u32 },  // cold for rare EIDs
    // ...
}

And the per-method TTL injection that only refreshes instance storage:

// common-macros/src/contract_ttl.rs:87-92 (generated into each entry method)
env.storage().instance().extend_ttl(INSTANCE_THRESHOLD, INSTANCE_EXTEND);
// does NOT iterate persistent keys

When an archived persistent entry is later accessed, the host aborts with INVOKE_HOST_FUNCTION_ENTRY_ARCHIVED and the user transaction is forced to bundle a RestoreFootprintOp.

Impact

UX/operational friction, not fund loss. The first OApp that tries to use a cold default after archival pays an unexpected restore fee (~5,000 stroops, ~ $0.001) and may also see a hard transaction failure if the call was not simulated. In the worst case, a new OApp is briefly unable to bootstrap until someone funds the restore. All entries are restorable by anyone; no entries are permanently lost.

Recommendation

Add an admin function refresh_default_configs() that touches every default key (per dst_eid / src_eid) to force-extend their TTLs in bulk; recommend operators run it on a monthly schedule. Document explicitly that #[contract_impl] does not refresh persistent keys — only the #[storage] macro does, and only on touched keys.

[05] inbound() does not reject NIL_PAYLOAD_HASH

File

  • contracts/protocol/stellar/contracts/endpoint-v2/src/messaging_channel.rs #L171-#L195 (inbound)

Issue

inbound() unconditionally writes the supplied payload_hash into the inbound slot. A receive library calling verify with the sentinel NIL_PAYLOAD_HASH_BYTES (0xFF × 32) would land the slot directly in NIL state, bypassing the explicit nilify path and its OApp/delegate authorization.

Relevant Code

// endpoint-v2/src/messaging_channel.rs:171-195 (inbound, simplified)
pub fn inbound(
    env: &Env,
    receiver: Address,
    src_eid: u32,
    sender: BytesN<32>,
    nonce: u64,
    payload_hash: BytesN<32>,  // not checked against NIL_PAYLOAD_HASH_BYTES
) {
    MessagingChannelStorage::set_payload_hash(
        env, receiver, src_eid, sender, nonce, payload_hash,
    );
    // ... event emit
}

Impact

Defense-in-depth. Requires a malicious or buggy receive library — out of trust model — but the explicit reject is one line and worth keeping.

Recommendation

assert_with_error!(
    env,
    payload_hash != NIL_PAYLOAD_HASH_BYTES,
    EndpointError::InvalidPayloadHash
);

[06] Admin/signer rotation has no batched setter or cap

File

  • contracts/protocol/stellar/contracts/workers/worker/src/worker.rs #L412-#L428 (admin Vec management)
  • utils/src/multisig.rs (DVN signer rotation)

Issue

Worker admins are stored in a Vec<Address> with no explicit cap; admin add/remove and DVN signer rotation are individual execute_transaction calls, not atomic batches. Soroban transactions are strictly sequential — no in-flight observation within a tx — but the inter-transaction window across two admin rotations can still leave the system observable in a half-updated state, and unbounded admin lists open a slow capacity-DoS griefing path.

Relevant Code

// workers/worker/src/worker.rs:412-428 (set_admin_no_auth — no cap, no batch)
fn set_admin_no_auth(env: &Env, target: Address, active: bool) {
    let mut admins = WorkerStorage::admins(env);
    if active && !admins.contains(&target) {
        admins.push_back(target.clone());  // no max-len check
    }
    // remove path similarly unconstrained
    WorkerStorage::set_admins(env, admins);
}

No set_admins(Vec<Address>) batched primitive exists; each grant / revoke is a separate tx, and DVN signer rotations via multisig are individually-signed calls.

Impact

Defense-in-depth. No fund loss; griefing limited by admin-only authorization. Pairs with NC-04 (capacity-DoS shape of the same unbounded list).

Recommendation

Add a hard cap (e.g., 16) in set_admin_no_auth; add a batched set_admins(Vec<Address>) rotation primitive so admin churn is one tx; document the inter-tx window as expected.


Non-Critical / Informational Details

[01] DVN upgrade has no timelock or announce window

File

  • contracts/protocol/stellar/contracts/utils/src/upgradeable.rs:#L68-#L71
  • workers/dvn/src/dvn.rs #L29 (#[lz_contract(multisig, upgradeable(no_migration))])

Issue

upgrade() calls update_current_contract_wasm immediately within the same transaction that satisfies the multisig auth, atomically replacing the WASM with no on-chain “pending upgrade” signal. OApps using this DVN have no observation window in which to react. DVN is the only upgradeable contract in scope.

// utils/src/upgradeable.rs:68-71
fn upgrade(env: &Env, new_wasm_hash: BytesN<32>) {
    enforce_upgrade_auth::<Self>(env);
    env.deployer().update_current_contract_wasm(new_wasm_hash);  // atomic
}

Impact

Trust-model surface. Below the finding threshold because the upgrade is multisig-gated.

Recommendation

Two-step propose-then-apply with a fixed delay (e.g., 48h), or route DVN upgrades through a timelocked upgrader contract.

[02] Endpoint::send does not enforce contract-variant sender

File

  • contracts/protocol/stellar/contracts/endpoint-v2/src/endpoint_v2.rs #L63-#L85

Issue

send() calls sender.require_auth() but does not check that sender is a ContractIdHash variant. An AccountIdPublicKeyEd25519 (any G-account) is accepted and its 32-byte pubkey is encoded into the wire sender slot via write_address_payload. Sui, Aptos, and Solana LayerZero endpoints all restrict sender to a contract/module-owned address.

// endpoint-v2/src/endpoint_v2.rs:63-85 (send, simplified)
sender.require_auth();  // accepts ContractId OR AccountId variant
// ... encode sender into outbound header

Impact

Off-chain indexer confusion; no protocol break. Properly configured EVM peers reject non-peer senders, so impersonation is blocked.

Recommendation

assert!(matches!(sender.to_payload(), AddressPayload::ContractId(_))) in send() and quote(), or document the deliberate design decision to allow account-typed OApps.

[03] Default ULN config changes silently propagate to all OApps

File

  • contracts/protocol/stellar/contracts/message-libs/uln-302/src/send_uln.rs #L187 (effective_send_uln_config);
  • contracts/protocol/stellar/contracts/message-libs/uln-302/src/receive_uln.rs #L110 (effective_receive_uln_config)

Issue

Both effective_*_uln_config resolvers fall back to the stored default whenever an OApp has not set its own per-OApp config. OApps that never call set_config automatically pick up any live mutation of the default ULN config performed by the ULN302 owner — no notification, no grace period, no per-OApp opt-in.

// uln-302/src/send_uln.rs:187 (simplified)
fn effective_send_uln_config(env: &Env, oapp: &Address, dst_eid: u32) -> UlnConfig {
    Uln302Storage::send_uln_config(env, oapp, dst_eid)
        .unwrap_or_else(|| Uln302Storage::default_send_uln_config(env, dst_eid))  // live default
}

Impact

Integrator-trust amplifier. Combined with L-01 Tier B manifestations, default-config changes are silently absorbed by every default-using OApp on the network.

Recommendation

Document the default ULN config as a live trust input, not a one-shot bootstrap default. Consider a timelock on default-config mutations (consistent with NC-01).

[04] Worker admin Vec has no cap and linear contains

File

  • contracts/protocol/stellar/contracts/workers/worker/src/worker.rs#L218-#L219 (is_admin),

Issue

Worker admins are stored in an unbounded Vec<Address> and is_admin performs a linear scan on every privileged call. A worker owner / existing admin can grow the admin list until the linear scan combined with the rest of the call’s compute trips Soroban’s per-tx compute budget.

// workers/worker/src/worker.rs:218-219
fn is_admin(env: &Env, addr: &Address) -> bool {
    WorkerStorage::admins(env).contains(addr)  // O(n) on every privileged call
}

Impact

Self-inflicted worker DoS by a malicious / coerced owner. Bounded to the affected worker; does not propagate to Endpoint or ULN302. Pairs with L-08 (no batched setter).

Recommendation

Cap the admin list in set_admin_no_auth (e.g., 32); or switch storage to Map<Address, ()> for O(1) membership.

[05] DVN signed-payload expiration has no upper bound

File

  • contracts/protocol/stellar/contracts/workers/dvn/src/auth.rs #L32

Issue

__check_auth verifies expiration > env.ledger().timestamp() but does not cap how far in the future expiration may be. Originally a conditional Medium (UsedHash-eviction replay), invalidated because the #[storage] macro auto-extends UsedHash TTL and archived entries restore to used = true. The one-line upper bound is still worth applying as forward-compatibility insurance.

// workers/dvn/src/auth.rs:32
ensure!(expiration > env.ledger().timestamp(), DvnError::AuthDataExpired);
// no upper bound on (expiration - now)

Impact

None under current Stellar protocol. Defense-in-depth.

Recommendation

const MAX_SIG_TTL: u64 = 7 * 24 * 60 * 60; // 7 days
ensure!(
    expiration <= env.ledger().timestamp() + MAX_SIG_TTL,
    DvnError::AuthDataExpirationTooFar
);

[06] DVN UsedHash grows unbounded over time

File

  • contracts/protocol/stellar/contracts/workers/dvn/src/storage.rs #L23-#L25

Issue

Every signed call writes a new UsedHash entry and there is no pruning mechanism. Over multi-year DVN operation the count grows linearly with signed-call volume; entries auto-extend on every successful replay-protection check, so they never naturally archive while the DVN is in use.

// workers/dvn/src/storage.rs:23-25
#[persistent(bool)]
enum DvnStorage {
    // ...
    UsedHash { hash: BytesN<32> },  // no pruning; auto-extends on read
}

Impact

Bounded by per-write rent. Not exploitable; design note.

Recommendation

Optional — allow rotating Vid to scope-out old UsedHash entries, or add a bulk-prune admin function gated to multisig.

[07] No endpoint check that assign_job amount equals get_fee

File

  • contracts/protocol/stellar/contracts/message-libs/uln-302/src/senduln.rs:232-241 (`getfeecalls), :297-355 (assign_job` calls)

Issue

Quote calls executor.get_fee / dvn.get_fee; send calls executor.assign_job / dvn.assign_job. These are independent trait entry points and the endpoint never cross-checks that assign_job.amount == get_fee. Consistency is delegated entirely to worker contract authors.

Impact

An honest worker can drift if its fee-lib is upgraded between quote and send.

Recommendation

Re-quote inside send_uln’s send path and assert equality.

[08] write_address_payload drops account/contract tag on the wire

File

  • contracts/protocol/stellar/contracts/utils/src/buffer_writer.rs :#L85-#L88;

    • consumers in message-libs/message-lib-common/src/packet_codec_v1.rs: #L49-#L59 and endpoint-v2/src/util.rs:#L22-#L32

Issue

Outbound headers and GUIDs encode the sender as a tagless 32-byte payload, while Soroban storage keys (e.g., OutboundNonce { sender: Address, ... }) preserve the tagged form. Under a 32-byte collision between an Ed25519 pubkey and a Soroban contract id (~2^128 work), an account and a contract would share the same wire packet but have distinct storage keys.

Impact

Cryptographically infeasible. Informational / completeness only. The current encoding is required to match cross-chain GUID compatibility.

Recommendation

None required. Document the assumption in the codec module so future maintainers do not accidentally rely on Account vs Contract distinguishability on the wire.

[09] secp256k1 accepts v ∈ {29, 30} and high-s signatures

File

  • contracts/protocol/stellar/contracts/utils/src/multisig.rs #L159-#L176

Issue

recover_signer accepts recovery byte v ∈ {27..30}. EVM canonical ecrecover accepts only {27, 28}; values 29/30 historically encoded EIP-155 chain-id-protected signatures. There is also no EIP-2 low-s enforcement. Replay impact is mitigated because UsedHash keys on the message hash, but signature normalization drifts from EVM.

// utils/src/multisig.rs:164
let v = if (27..=30).contains(&v) { v - 27 } else { v };  // accepts 29, 30
// no `s > secp256k1_n / 2` rejection

Impact

Hygiene drift from EVM canonical acceptance set.

Recommendation

Reject v ∉ {27, 28} and reject s > secp256k1_n / 2 to match EVM’s normalized acceptance set.

[10] lz_compose_alert callable by any authenticated executor

File

  • contracts/protocol/stellar/contracts/endpoint-v2/src/messaging_composer.rs #L75

Issue

lz_compose_alert requires only executor.require_auth() — any address that calls itself an executor and authorizes can emit the alert. The hook is informational (alerts about failed compose execution); no state mutation, no funds.

// endpoint-v2/src/messaging_composer.rs:75 (simplified)
fn lz_compose_alert(env: &Env, executor: Address, /* ... */) {
    executor.require_auth();  // any authorized address passes
    LzComposeAlert { /* ... */ }.publish(env);
}

Impact

Spam-emit of alert events from arbitrary addresses. Off-chain consumers should already filter alerts by configured executor identity.

Recommendation

Optionally gate to “is executor the configured executor for (receiver, src_eid)”, or document the open emission as intentional alert-style behavior.

[11] clear_payload lacks explicit nonce > 0 guard

File

  • contracts/protocol/stellar/contracts/endpoint-v2/src/messaging_channel.rs #L208-#L226

Issue

clear_payload rejects nonce == 0 only indirectly via the downstream hash-mismatch check (slot 0 is never populated, so Some(actual_hash) == None evaluates false and the function reverts). Not exploitable — there is no path that can populate slot 0 — but defense-in-depth would prefer the explicit check.

Impact

None.

Recommendation

assert_with_error!(env, nonce > 0, EndpointError::InvalidNonce);

at the top of clear_payload.

[12] register_library probe weaker than ERC-165

File

  • contracts/protocol/stellar/contracts/endpoint-v2/src/message_lib_manager.rs #L20-#L34

Issue

register_library only probes the new library by calling message_lib_type() and discarding the result. EVM’s analog requires supportsInterface(IMessageLib) (ERC-165). On Stellar any contract that exposes a function with the same name passes registration. Owner-trust mitigates: the call is #[only_auth].

// endpoint-v2/src/message_lib_manager.rs:20-34 (simplified)
fn register_library(env: &Env, lib: Address) {
    enforce_owner_auth::<Self>(env);
    let _ = IMessageLibClient::new(env, &lib).message_lib_type();  // single view probe
    // no version() / domain-tag cross-check
}

Recommendation

Probe two distinct view functions (e.g., message_lib_type AND version) to give a stronger interface fingerprint, or add a dedicated “I am a LayerZero message lib v2” sentinel.

[13] DVN assign_job returns FeeRecipient.to at call time

File

contracts/protocol/stellar/contracts/message-libs/uln-302/src/send_uln.rs:312 (executor recipient), :339-347 (DVN recipient); contrast :99-105 (hard-coded treasury recipient)

Issue

ULN302 builds native_fee_recipients from whatever address each worker’s assign_job returns — typically the worker’s deposit_address, which is admin-mutable via worker.rs:154-161. The treasury recipient by contrast is hard-coded to treasury_addr precisely to avoid this flexibility. This matches the documented “DVNs/executors are OApp-trusted, not endpoint-pinned” trust model.

Impact

Trust-model acknowledgement. A compromised DVN admin can rotate deposit_address to an attacker between assign_job calls; this is the documented boundary.

Recommendation

No code change. Document the asymmetry (treasury hard-coded vs worker recipient returned-at-call-time) prominently in integrator guidance.

[14] 1-byte DVN option payload decodes as zero-byte option

File

  • contracts/protocol/stellar/contracts/message-libs/message-lib-common/src/worker_options.rs #L155

    • (append_dvn_option); bounds check at :73

Issue

A DVN option of option_size == 1 decodes as a valid “empty-payload option for DVN idx = X” with zero option bytes. The bounds check at option_size > 0 lets the 1-byte case through even though the option carries no semantic payload.

// message-libs/message-lib-common/src/worker_options.rs:73
ensure!(option_size > 0, WorkerOptionsError::InvalidOptionSize);
// allows option_size == 1 → dvn_idx only, no payload

Impact

None observed; codec accepts the value cleanly without OOB.

Recommendation

Optional — assert option_size >= 2 in append_dvn_option if executors require at least one payload byte beyond the dvn_idx.

[15] Pagination view uses unchecked u32 arithmetic

File

contracts/protocol/stellar/contracts/endpoint-v2: pagination view function

Issue

A pagination view computes an offset / limit interaction using u32 arithmetic without explicit overflow check. View-only path.

Impact

None on state; query-path hygiene only.

Recommendation

Use checked_add and saturate or revert.

[16] Treasury withdraw_token accepts zero amount

File

  • contracts/protocol/stellar/contracts/message-libs/treasury/src/treasury.rs (withdraw_token)

Issue

Owner-gated withdraw_token does not reject amount == 0. Net effect is event spam only.

Recommendation

assert_with_error!(env, amount > 0, TreasuryError::ZeroAmount);

[17] ZRO path has no independent enabled flag

File

  • contracts/protocol/stellar/contracts/message-libs/treasury/src/treasury.rs (zro_fee_lib)

Issue

There is no separate boolean to disable the ZRO fee path; the owner must call set_zro_fee_lib(None) to fully disable it. Cosmetic; the existing flow works.

Recommendation

None required, or add a one-line is_zro_enabled view.

[18] begin_ownership_transfer silently floors short TTL

File

  • contracts/protocol/stellar/contracts/utils/src/ownable.rs #L142-#L143

Issue

begin_ownership_transfer calls OwnableStorage::extend_pending_owner_ttl(env, ttl, ttl). The first argument is the threshold; the #[storage] macro floors at the default temporary TTL, so a caller passing a short TTL (intending a tight acceptance window) silently gets the default temporary TTL instead. The docstring does not warn about this floor.

// utils/src/ownable.rs:142-143
OwnableStorage::extend_pending_owner_ttl(env, ttl, ttl);
// `ttl` as threshold is floored to DEFAULT_TEMPORARY_TTL by the #[storage] macro

Recommendation

Document the floor, or honor the user-requested TTL exactly (no default floor) when explicitly provided.

[19] init_worker accepts empty admins despite docstring

File

  • contracts/protocol/stellar/contracts/workers/worker/src/worker.rs #L322-#L338

    • (docstring at L#315-#L316 claiming admins “must not be empty”)

Issue

init_worker iterates admins.iter().for_each(...) with no length assertion. An empty admins Vec passes init, leaving the worker temporarily with zero admins until a manager set_admin call. Recoverable, but the docstring’s “must not be empty” guarantee is unenforced.

// workers/worker/src/worker.rs:322-338 (simplified)
admins.iter().for_each(|a| set_admin_no_auth::<Self>(env, a, true));
// no `!admins.is_empty()` check

Recommendation

assert_with_error!(env, !admins.is_empty(), WorkerError::EmptyAdmins);

at the top of init_worker.

[20] Unchecked i128::sum over per-DVN fees

File

  • contracts/protocol/stellar/contracts/message-libs/uln-302/src/send_uln.rs #L92 (primary)

    • related bare-add at :47, :53, :275

Issue

i128::Sum delegates to bare +, and per-DVN fee returned by assign_job is constrained only by amount >= 0 (send_uln.rs:348) with no upper bound. The build profile uses overflow-checks = true, so an overflow on the sum panics rather than silently wrapping — but a panic in quote() / send() is still a complete DoS.

Relevant Code

// message-libs/uln-302/src/send_uln.rs:92
let total_worker_fee: i128 = native_fee_recipients
    .iter()
    .map(|fee| fee.amount)
    .sum();  // panics on i128::MAX overflow

And the parallel bare-add sites at :47 and :53:

// send_uln.rs:47, :53 (simplified)
let total_native_fee = worker_fee + treasury_fee;  // unchecked +

Impact

Defense-in-depth. Reachability requires an OApp to configure a DVN that returns an inflated fee (realistic fees are ~22 orders of magnitude away from i128::MAX). Choosing a malicious DVN is a self-inflicted trust failure, but a checked_add revert is strictly better than a panic-DoS, and the fix is six lines.

Recommendation

let total_worker_fee = native_fee_recipients
    .iter()
    .try_fold(0i128, |acc, f| acc.checked_add(f.amount).ok_or(Uln302Error::FeeOverflow))?;

Replace bare + at :47, :53, and :275 with checked_add. Optionally add an absolute MAX_WORKER_FEE sanity ceiling (e.g., 1e18 stroops).

[21] Treasury fee math uses unchecked multiplication

File

  • contracts/protocol/stellar/contracts/message-libs/treasury/src/treasury.rs #L97-#L99

Issue

calculate_native_fee performs total_native_fee * native_fee_bp as i128 / BPS_DENOMINATOR with no checked_mul. With overflow-checks = true in release, this aborts on overflow rather than wrapping — distinct abort site from NC-20’s i128::sum, same threat model.

Relevant Code

// message-libs/treasury/src/treasury.rs:97-99
let fee = total_native_fee
    * Self::native_fee_bp(env) as i128
    / BPS_DENOMINATOR;  // panics on overflow (overflow-checks = true)

Impact

Aborting fee path on absurdly large total_native_fee values. Bounded by upstream caps; defense-in-depth.

Recommendation

let fee = total_native_fee
    .checked_mul(Self::native_fee_bp(env) as i128)
    .ok_or(TreasuryError::FeeOverflow)?
    .checked_div(BPS_DENOMINATOR)
    .ok_or(TreasuryError::FeeOverflow)?;

[22] Custom→default send-library swap has no grace window

File

contracts/protocol/stellar/contracts/endpoint-v2/src/messagelibmanager.rs (send-library resolution; compare receive-library Timeout handling)

Issue

When an OApp clears its custom send library back to the default, there is no grace window — the next send immediately resolves the new library. The receive side has a Timeout mechanism; the send side does not. This is intentional (send resolves at call time and the OApp is the sole party affected), but the asymmetry is undocumented and can surprise operators during cutover.

Relevant Code

The receive-side setter wires a timeout struct:

// endpoint-v2/src/message_lib_manager.rs (receive path — has timeout)
fn set_receive_library(
    env: &Env, receiver: Address, src_eid: u32,
    new_lib: Address, grace_period: u64,
) {
    // ... records Timeout { expiry, fallback } for dual-acceptance
}

The send-side setter does not:

// endpoint-v2/src/message_lib_manager.rs (send path — no grace)
fn set_send_library(env: &Env, sender: Address, dst_eid: u32, new_lib: Address) {
    // no grace_period parameter; next send() resolves new_lib immediately
    EndpointStorage::set_send_library(env, sender, dst_eid, new_lib);
}

Impact

Potential operator surprise on cutover. By design — but undocumented.

Recommendation

Document the asymmetry between send (no grace) and receive (timeout-based grace) in the README and in the set_send_library doc-comment.


Disclosures

C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.

C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.