LayerZero - Stellar endpoint
Findings & Analysis Report
2026-05-06
Table of contents
- Summary
- Scope
- Severity Criteria
-
- 01
pay_messaging_feesuses rawbalance(this_contract) - 02
recover_tokenskims donated dust before next sender - 03
BlockedMessageLibadvertisesSendAndReceivewithout receive impl - 04 Cold per-OApp / per-EID config keys can archive
- 05
inbound()does not rejectNIL_PAYLOAD_HASH - 06 Admin/signer rotation has no batched setter or cap
- 01
-
Non-Critical / Informational Details
- 01 DVN upgrade has no timelock or announce window
- 02
Endpoint::senddoes not enforce contract-variant sender - 03 Default ULN config changes silently propagate to all OApps
- 04 Worker admin
Vechas no cap and linearcontains - 05 DVN signed-payload
expirationhas no upper bound - 06 DVN
UsedHashgrows unbounded over time - 07 No endpoint check that
assign_jobamount equalsget_fee - 08
write_address_payloaddrops account/contract tag on the wire - 09 secp256k1 accepts
v ∈ {29, 30}and high-ssignatures - 10
lz_compose_alertcallable by any authenticated executor - 11
clear_payloadlacks explicitnonce > 0guard - 12
register_libraryprobe weaker than ERC-165 - 13 DVN
assign_jobreturnsFeeRecipient.toat call time - 14 1-byte DVN option payload decodes as zero-byte option
- 15 Pagination view uses unchecked
u32arithmetic - 16 Treasury
withdraw_tokenaccepts zero amount - 17 ZRO path has no independent enabled flag
- 18
begin_ownership_transfersilently floors short TTL - 19
init_workeraccepts emptyadminsdespite docstring - 20 Unchecked
i128::sumover per-DVN fees - 21 Treasury fee math uses unchecked multiplication
- 22 Custom→default send-library swap has no grace window
- Disclosures
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:
- Repository: https://github.com/LayerZero-Labs/audit-external/
- Commit hash:
295ac38766ff158e349ea9226569c275cb2f3f7b
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 (adminVecmanagement)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-#L71workers/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),- #L412-428 (
set_admin_no_auth)
- #L412-428 (
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 (`getfee
calls), :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;
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”)
- (docstring at L#315-#L316 claiming
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.