Guardian's review of Canton VER for LayerZero, published May 2026. The report records 65 findings across 2 review rounds, including 3 medium and 28 low.
- Published
- Review window
- March 20 to May 7, 2026
- Rounds
- Main Review 1, Main Review 2
- Language
- TypeScript
- Chains
- Canton
- Sector
- Cross-chain, Infrastructure
- 0 Critical
- 0 High
- 3 Medium
- 28 Low
- 34 Informational
Scope
-
GuardianOrg/canton-ver-team1-1773774383947
3e2c36d41b -
GuardianOrg/audit-external-team1-1777914234503
01d6c7d255
103 files in scope · 10,509 nSLOC
| File | nSLOC | Lines |
|---|---|---|
packages/api-common/src/container/component.ts | 211 | 335 |
packages/api-common/src/container/components/blocked-message-library.ts | 22 | 35 |
packages/api-common/src/container/components/endpoint.ts | 38 | 50 |
packages/api-common/src/container/components/uln-302.ts | 61 | 75 |
packages/api-common/src/container/container.ts | 127 | 236 |
packages/api-common/src/schemas/contract.ts | 69 | 174 |
packages/api-common/src/schemas/transaction.ts | 37 | 100 |
packages/api-common/src/schemas/vapp.ts | 8 | 27 |
packages/api-common/src/schemas/vapps/blocked-message-library.ts | 20 | 22 |
packages/api-common/src/schemas/vapps/endpoint.ts | 282 | 288 |
packages/api-common/src/schemas/vapps/message-library.ts | 64 | 67 |
packages/api-common/src/schemas/vapps/treasury.ts | 65 | 66 |
packages/api-common/src/schemas/vapps/uln-302.ts | 480 | 492 |
packages/common-utils/src/byte-codec.ts | 184 | 260 |
packages/common-utils/src/utils/hash.ts | 5 | 18 |
packages/common-utils/src/utils/hex.ts | 19 | 30 |
packages/endpoint/src/common/guid.ts | 19 | 20 |
packages/endpoint/src/common/packet-v1-codec/error.ts | 5 | 5 |
packages/endpoint/src/common/packet-v1-codec/index.ts | 136 | 170 |
packages/endpoint/src/dvn/dvn-options.ts | 145 | 215 |
packages/endpoint/src/dvn/dvn.ts | 424 | 523 |
packages/endpoint/src/dvn/error.ts | 40 | 54 |
packages/endpoint/src/dvn/event.ts | 24 | 27 |
packages/endpoint/src/dvn/fee-lib.ts | 90 | 113 |
packages/endpoint/src/dvn/multisig/error.ts | 50 | 62 |
packages/endpoint/src/dvn/multisig/event.ts | 37 | 43 |
packages/endpoint/src/dvn/multisig/hash-builder.ts | 18 | 26 |
packages/endpoint/src/dvn/multisig/hashes.ts | 106 | 118 |
packages/endpoint/src/dvn/multisig/multisig.ts | 9 | 20 |
packages/endpoint/src/endpoint-base/index.ts | 40 | 47 |
packages/endpoint/src/endpoint-v2/error.ts | 16 | 23 |
packages/endpoint/src/endpoint-v2/event.ts | 138 | 153 |
packages/endpoint/src/endpoint-v2/index.ts | 559 | 634 |
packages/endpoint/src/executor/error.ts | 27 | 49 |
packages/endpoint/src/executor/event.ts | 24 | 27 |
packages/endpoint/src/executor/executor-options.ts | 99 | 157 |
packages/endpoint/src/executor/executor.ts | 176 | 201 |
packages/endpoint/src/executor/fee-lib.ts | 166 | 184 |
packages/endpoint/src/fee-lib-base.ts | 36 | 43 |
packages/endpoint/src/message-library-manager/error.ts | 51 | 61 |
packages/endpoint/src/message-library-manager/event.ts | 144 | 172 |
packages/endpoint/src/message-library-manager/index.ts | 538 | 667 |
packages/endpoint/src/message-library/blocked-message-library.ts | 96 | 112 |
packages/endpoint/src/message-library/uln/uln-302/errors.ts | 101 | 122 |
packages/endpoint/src/message-library/uln/uln-302/events.ts | 246 | 292 |
packages/endpoint/src/message-library/uln/uln-302/types/executor-config.ts | 22 | 39 |
packages/endpoint/src/message-library/uln/uln-302/types/uln-config.ts | 118 | 206 |
packages/endpoint/src/message-library/uln/uln-302/uln-302.ts | 1104 | 1384 |
packages/endpoint/src/message-library/uln/uln-302/uln-options.ts | 127 | 162 |
packages/endpoint/src/messaging-channel/error.ts | 12 | 15 |
packages/endpoint/src/messaging-channel/event.ts | 99 | 111 |
packages/endpoint/src/messaging-channel/index.ts | 304 | 364 |
packages/endpoint/src/price-feed/errors.ts | 28 | 32 |
packages/endpoint/src/price-feed/price-feed.ts | 309 | 388 |
packages/endpoint/src/transaction.ts | 6 | 7 |
packages/endpoint/src/treasury/errors.ts | 5 | 5 |
packages/endpoint/src/treasury/events.ts | 32 | 38 |
packages/endpoint/src/treasury/treasury.ts | 96 | 114 |
packages/endpoint/src/worker/acl.ts | 5 | 14 |
packages/endpoint/src/worker/error.ts | 8 | 15 |
packages/endpoint/src/worker/event.ts | 176 | 212 |
packages/endpoint/src/worker/pausable-worker-base.ts | 35 | 48 |
packages/endpoint/src/worker/worker-base.ts | 220 | 268 |
packages/protocol-common/src/common/address.ts | 14 | 22 |
packages/protocol-common/src/common/options-base.ts | 9 | 23 |
packages/protocol-common/src/context.ts | 86 | 123 |
packages/protocol-common/src/contract-component.ts | 7 | 8 |
packages/protocol-common/src/contract-like.ts | 29 | 45 |
packages/protocol-common/src/contract.ts | 21 | 25 |
packages/protocol-common/src/event.ts | 20 | 30 |
packages/protocol-common/src/ownable.ts | 40 | 50 |
packages/protocol-common/src/payable.ts | 38 | 60 |
packages/ver-merkle/src/contract/address.ts | 20 | 38 |
packages/ver-merkle/src/contract/array.ts | 74 | 128 |
packages/ver-merkle/src/contract/base.ts | 20 | 42 |
packages/ver-merkle/src/contract/big-uint.ts | 20 | 38 |
packages/ver-merkle/src/contract/boolean.ts | 20 | 38 |
packages/ver-merkle/src/contract/codec.ts | 163 | 216 |
packages/ver-merkle/src/contract/enumerable-set.ts | 55 | 93 |
packages/ver-merkle/src/contract/map.ts | 55 | 98 |
packages/ver-merkle/src/contract/set.ts | 39 | 71 |
packages/ver-merkle/src/contract/string.ts | 20 | 38 |
packages/ver-merkle/src/contract/uint8-array.ts | 20 | 38 |
packages/ver-merkle/src/slots/slot-allocator.ts | 14 | 32 |
packages/ver-merkle/src/slots/slot-derivation.ts | 28 | 85 |
packages/ver-types/src/abi/primitives.ts | 11 | 12 |
packages/ver-types/src/abi/schema-builder.ts | 55 | 61 |
packages/ver-types/src/abi/types.ts | 26 | 47 |
packages/ver-types/src/primitives/schemas.ts | 44 | 56 |
packages/ver-types/src/primitives/types.ts | 4 | 9 |
packages/ver-types/src/schemas/common.ts | 64 | 112 |
packages/ver-types/src/schemas/endpoint/endpoint-v2.ts | 245 | 346 |
packages/ver-types/src/schemas/endpoint/message-library-manager.ts | 212 | 320 |
packages/ver-types/src/schemas/endpoint/messaging-channel.ts | 99 | 144 |
packages/ver-types/src/schemas/message-library/message-library.ts | 102 | 157 |
packages/ver-types/src/schemas/message-library/uln-302.ts | 303 | 407 |
packages/ver-types/src/schemas/transaction.ts | 10 | 14 |
packages/ver-types/src/schemas/workers/dvn.ts | 177 | 249 |
packages/ver-types/src/schemas/workers/executor.ts | 81 | 113 |
packages/ver-types/src/schemas/workers/price-feed.ts | 142 | 233 |
packages/ver-types/src/schemas/workers/treasury.ts | 38 | 67 |
packages/ver-types/src/schemas/workers/worker-access-control.ts | 36 | 61 |
packages/ver-types/src/schemas/workers/worker.ts | 90 | 131 |
Findings 65
Main Review 1
60 findings · March 20 to April 13, 2026-
M-01 Medium Nonce-As-Block-Number Semantic Mismatch Censoring Pending
Description
The system models
Context.blockNumberusing the container’s global write nonce instead of a true host-chain block number. As a result, block-number-based logic advances with write request throughput, rather than actual blockchain progression or elapsed time. In a real blockchain: Multiple transactions can be included in a single block without increasingblock.number. Block numbers only advance when a new block is produced Block cadence introduces a natural time constraint. In this implementation: Each processed write request increments the modeled blockNumber Failed writes also increment the counter Rapid consecutive writes can significantly advance blockNumber No real-time or block-production dependency exists Effectively, the system behaves as:blockNumber := number of processed writesinstead of:blockNumber := number of host-chain blocksRoot Cause The validator constructs the execution context as:blockNumber = await container.nonce(state)Meanwhile, the container increments the nonce for every write call prior to execution. This creates a direct coupling between request volume and the perceived block number. Protocol Impact Components such asMessageLibraryManagerrely oncontext.blockNumberfor timeout and grace-period logic: Library validity windows depend ontimeout.expiry > context.blockNumberGrace periods are defined as:expiry = context.blockNumber + gracePeriodTimeout updates are validated against the current blockNumber This leads to:- Premature expiration of grace periods
- Reduced safety margins during library migration
- Distortion of time-based assumptions due to unrelated traffic
- Potential disruption of in-flight message processing
- Security & Operational Implications While not directly enabling fund theft, this issue introduces liveness and reliability risks: Attackers (or high traffic) can accelerate timeout windows Library rotation processes may fail or behave unpredictably Systems relying on block-based timing assumptions may degrade under load The severity depends on: Reliance on block-number-based timing in production Cost and accessibility of generating write requests Assumptions made by operators about timing guarantees
Recommendation
Do not derive
Context.blockNumberfrom container nonce. Safer options:- use an actual host-chain block number if the host chain exposes one
- if no true block number exists, use explicit timestamps for timeout logic instead of a synthetic nonce
- if a synthetic counter must exist, do not use it for security-sensitive timeout semantics that assume block-cadence behavior
-
M-02 Medium Worker address front-running Frontrunning Pending
Description
Uln302.registerDvn(),registerExecutor(), andregisterPriceFeed()derive new worker addresses from a shared monotonic nonce and a fixed worker-type hint for all senders. Because these registration functions are externally callable without admin authorization, any caller can consume future worker addresses before a legitimate operator does. This creates a front-running risk for integrations that precompute a worker address off-chain and then later configure it. For example, if an operator expects the next DVN address to bederiveGlobalAddress("dvn-N", ...)and prepares a later configuration transaction referencing that address, and submits two transactions—one withregisterDvn()and one withsetConfig()—an attacker can front-run with their ownregisterDvn()call first. For example: Safe Transactions by USDT0 Team. The attacker then receives the exact address the operator expected, while the legitimate DVN is created at the next nonce. The later configuration transaction can therefore end up pointing to an attacker-controlled worker.Recommendation
Consider making derived worker addresses incorporate caller-specific entropy, such as the registering sender’s address, rather than relying only on a shared global nonce and a fixed type hint. This would prevent one caller from stealing another caller’s predicted worker address, even if registration remains externally accessible.
-
L-01 Low Canonical pre-state verification for writes Best Practices Pending
Description
Validator write execution currently trusts the
stateRootsupplied in the write request and does not independently verify that it matches the latest committed root before executing and signing the next transition. In packages/api-common/src/controllers/validator.ts, #write() directly executes the request against the caller-provided stateRoot:- packages/api-common/src/controllers/validator.ts:135
- packages/api-common/src/controllers/validator.ts:147 The intended production flow appears to rely on the sequencer to provide the correct latest root:
- packages/api-common/src/services/request-processor.ts:42 Under that model, correctness of the predecessor state is enforced by the sequencer/request processor rather than by each validator independently. If the sequencer is correct and validator write endpoints are not externally reachable, this is primarily a defense-in-depth weakness rather than a direct exploit. However, validator correctness depends on an external assumption: that the supplied pre-state root is canonical and current. Misconfiguration, stale retries, or non standard callers could cause validators to execute and sign transitions derived from a non-canonical predecessor state.
Recommendation
Consider adding a validator-side canonical pre-state check before write execution, or otherwise enforce that validators only accept requests whose predecessor root is proven to be the current committed root. Even if the sequencer remains the primary source of truth, local enforcement would reduce trust coupling and make the system more robust against stale or malformed write requests.
-
L-02 Low Recovery of Validator Execution State Best Practices Pending
Description
The VER execution layer stores all protocol state in the local Merkle trie, while Canton/DAML appears to persist only the resulting stateRoot. A Merkle root is only a commitment to state, not the state itself, so a validator cannot reconstruct the executable trie contents from the root alone after losing its local trie database. In the current validator wiring, the vapp server constructs the container with an in-memory trie, while the chain-side commitment path stores only stateRoot metadata. As a result, if the validator process restarts and loses its local trie contents, it cannot safely resume execution from the committed stateRoot unless there is an external replay/rehydration mechanism. If deployed with ephemeral trie storage and no recovery flow, a validator restart can leave the node unable to safely continue protocol execution from the last committed state. This can cause operational downtime, failed retries, or inconsistent recovery behavior if execution resumes without the original trie contents.
Recommendation
Ensure validator execution state is durably recoverable by doing at least one of the following:
- persist the trie node database using a durable backend such as Postgres or DynamoDB
- implement a replay/rehydration mechanism that can deterministically reconstruct trie contents from a known checkpoint and the full request stream
- add startup checks that prevent execution when only a root is available but the underlying trie state is missing.
-
L-03 Low Implicit Single-Use Context Assumption Validation Pending
Description
The container read-purity check assumes that the supplied
Contextis fresh, but that invariant is not enforced by the runtime API itself.Container.call(..., write = false)rejects whenever the passedContextalready contains events or payouts, but does not enforce this state. In the current production flow this does not appear to be exploitable: the validator allocates a brand-newContextfor every read request and for every write request before calling the container, so the sequencer/validator path does not currently reuse a dirty context. However, this safety property is implicit rather than enforced. Any future direct container consumer, refactor, helper, or test harness that reuses aContextacross calls could trigger falseSide effects in read-only callfailures.Recommendation
Consider to implement one of these changes:
- Document that
Contextinstances are one-shot and must not be reused across top-level container calls - Update the read-purity check to snapshot the initial event/payout counts and compare deltas instead of absolute sizes
- Revert at the beginning of the function if the
Contextis not fresh
- Document that
-
L-04 Low PriceFeed stores Arbitrum fee params globally Configuration Pending
Description
PriceFeed applies a dedicated fee model for Arbitrum-style chains because that model requires extra parameters beyond the default gas-price inputs, including
gasPerL2TxandgasPerL1CallDataByte. Although the setter API is EID-scoped, the implementation stores the Arbitrum extension data in a single shared record rather than under the supplied destination EID. If multiple Arbitrum-model destinations use different extension parameters, the most recentsetPriceForArbitrum()call overwrites the shared extension used by all of them. Updating chain A can therefore silently change fee estimation for chain B.Recommendation
If there is a possibility of multiple Arbitrum-style chains (orbit chains), being supported by LayerZero, consider storing ArbitrumPriceExt per destination EID and updating the getter and fee-estimation paths to read the extension for the specific destination being quoted.
-
L-05 Low resolveMethod() shadows same-family duplicates Logical Error Pending
Description
ContainerComponent.#resolveMethod()resolves callable methods by bare function name and returns the first match found among the loaded contract and its sub-components. It does not distinguish overloaded signatures and does not reject ambiguous same-family method names. The resolution order is:- top-level contract
- then each entry from contract.getComponents() and the first match wins. This means if a top-level contract and one of its sub-components expose the same method name in the same read/write category, one implementation silently shadows the other based solely on component ordering. We confirmed one current same-family duplicate in the EndpointV2 family:
- EndpointV2.getEid()
- MessagingChannel.getEid() Under current ordering, EndpointV2.getEid() wins first. This appears benign today because the top-level method delegates to MessagingChannel, but the behavior is still an architectural footgun. Future duplicate names could change reachability silently without any ambiguity error.
Recommendation
Consider rejecting ambiguous same-family method names during API construction or method resolution. If overload-like behavior is ever needed, resolve by a full typed signature rather than function name alone.
-
L-06 Low
finishSend()is not bound by send continuation Configuration PendingDescription
EndpointV2.send()returns a continuation to the configured message library, and that library later returns a continuation back intoEndpointV2.finishSend(). The final hop is accepted in packages/endpoint/src/endpoint-v2/endpoint-v2.ts:252 if:msg.senderis a registered librarymsg.sender === state.libraryThat meansfinishSend()trusts the runtime continuation caller plus the carriedstate.library, but it does not prove that thisfinishSend()is the unique successor of a specific earlierEndpointV2.send()invocation. There is no explicit continuation/session nonce or unforgeable handoff token binding the two hops together. In the intended Uln302 path this is mitigated because uln-302.ts:306 requires the caller ofUln302.send()to be the Endpoint, so an external caller cannot normally fabricate the middle hop. But structurally, the safety of the chain depends on every reachable library implementation enforcing that same invariant. A malicious or incorrectly exposed library could manufacture a continuation intofinishSend(). This is different from a normal EVM call stack, where the intermediate call chain is immediate and execution-context coupling is stronger. In this runtime, continuation ordering is reconstructed by the container across separate redispatch steps, so the trust boundary is more explicit.
Recommendation
Consider adding a continuation/session binding, for example a token derived from the originating
send()context and checked infinishSend(), so the final hop is tied to a specific initiated flow rather than only tostate.libraryand caller identity. Even if you do not change the design, this should at least be documented as a security assumption: every externally reachable librarysend()must enforce endpoint-only access. -
L-07 Low Read enforcement applies to the top-level only Unexpected Behavior Pending
Description
The runtime enforces read-only behavior only at the end of a top-level
Container.call(..., write=false)by checking whether final state, events, or payouts changed. This means the read/write split in the vApp schema is an external dispatch boundary, not a per-hop or per-method purity guarantee. Inside a top-level write flow, nested continuation hops inherit the same write=true mode, and direct intra- component proxy calls do not go back through the schema dispatcher. As a result, a method that is externally categorized as read-only can still mutate state when it is invoked internally during a larger write path without triggering the container’s read-only protection.Recommendation
If stronger guarantees are desired, consider adding an explicit read-only mode to propagated execution context and enforcing it on continuation hops and/or direct proxy-mediated internal calls.
-
L-08 Low Unbounded Execution Can Halt Validators Validation Pending
Description
As the system misses gas like computation constraints it is vulnerable to wasting computation power because of malicious requests and could run into endless loops. Request creation on the DAML/Canton side does not appear to price requests based on the cost of the TS execution they will trigger.
In
RequestFactory.daml:73request creation validates only that the supplied fee satisfies the configured minimum (config.minFee) before transferring the fee amount to treasury and creating the request. As a result, request-layer cost is largely decoupled from TS runtime cost. If an externally reachable request path can be made unusually expensive in CPU, memory, or serialization work, an attacker may be able to impose disproportionate load on validators at near-flat request cost. For example the continuation loop is created withwhile (true)and it will go on for ever as long as a continuation is returned by the called contract. This enables a potential endless loop DoS vector for future versions of the codebase.Recommendation
Beaware of this consideration and consider adding gas like logic to prevent such cases.
-
L-09 Low DVN Duplicate Check Can Be Bypassed Validation Pending
Description
The
assertSortedNoDuplicatesfunction compares raw strings. Therefore a config containing the same DVN twice with different hex casing passes validation, is stored, and decodes back into two identical addresses. The verification loop then iterates both entries, so one DVN verification can satisfy multiple positions in requiredDvns or optionalDvns. InsetSendConfigandsetReceiveConfigthedecodeUlnConfigfunction is called before to get rid of this issue but this does not happen in the set default functions.Recommendation
Canonicalize all address inputs before validation and then perform duplicate detection.
-
L-10 Low Duplicate Contract Addresses Are Possible Validation Pending
Description
Each component registers its contracts into a local contracts map during initializeContracts(), but there is no global uniqueness check across components. The container later resolves an address by probing every component and taking the first match via findIndex(Boolean). If two components ever register the same address, the later one becomes unreachable and execution is silently routed to the first matching component instead of failing fast. This is a correctness and safety issue for future deployments, configuration mistakes, or address collisions.
Recommendation
Consider to enforce contract-address uniqueness.
-
L-11 Low Comment misstates nextMsgSender semantics Best Practices Pending
Description
The live Context constructor initializes nextMsgSender to the same value as msgSender, not to 0x0 or a meaningless placeholder. In packages/protocol-common/src/context.ts, the constructor sets:
this.#msgSender = msgSender; this.#nextMsgSender = msgSender;However, packages/protocol-common/src/contract-like.ts still documents that the initial value of nextMsgSender is "meaningless and set to 0x0 in practice." That is no longer true in the current implementation. This is not a direct correctness issue because runtime behavior follows the constructor, not the comment.
Recommendation
Consider updating the comment
-
L-12 Low Weaker type match for method detection Best Practices Pending
Description
The proxy wrapper in
contract-like.tsdecides whether to apply Context / msg.sender rewriting based on:if (!(value instanceof Function) || property === 'constructor') { return value; }In normal same-realm Node/TypeScript execution this behaves as expected for class methods, async methods, and arrow-function fields. However, instanceof Function is not the most robust callable check in JavaScript and can misclassify cross-realm or otherwise exotic callable values. In such cases, a callable property could bypass the wrapper logic and avoid the intended switchMsgSender(...) behavior.
Recommendation
Consider replacing the check with typeof value !== 'function' to reduce the edge-case risk.
-
L-13 Low DVN signatures are not bound to a specific DVN Signatures Pending
Description
hashVerify(...)signs the packet header, payload hash, confirmations, target ULN address, vid, and expiration, but not the DVN contract address, in hashes.ts:102. Dvn.verify() then validates those signatures and forwards the attestation intoUln302.verify()in dvn.ts:186. If multiple DVNs are configured with the same signer set, quorum, vid, and linked ULN target, the same signature bundle can be reused across them. This does not let signatures cross ULN targets, but it does mean verification signatures are domain-separated by message library rather than by individual DVN identity.Recommendation
If the protocol expects separate DVN addresses to represent independently authorized verifier sets, binding the DVN address into the signed payload would make that assumption explicit.
-
L-14 Low PacketV1 silently truncates oversized fields Validation Pending
Description
PacketV1Codec.encodePacketHeader()silently truncates oversized nonce, srcEid, and dstEid values instead of rejecting them. The encoder usesBigInt.asUintN(64, ...)for nonce andBigInt.asUintN(32, ...)for both EIDs. This wraps values modulo the target bit width. At the same time, the ABI/Zod layer does not enforce uint32/uint64 width, only non-negativity, so out-of-range values can survive request decoding and reach the codec. The intendedEndpointV2.send/quotepath is partially protected becausegenerateGuid(...)validates uint64/uint32 bounds first. However, the message-library APIs expose direct send/quote entry points.Recommendation
Consider adding explicit range checks in
PacketV1Codec.encodePacketHeader()and reject values outside uint64 / uint32 bounds instead of truncating. As defense in depth, also enforce ABI-width limits during schema decoding for uint32 and uint64 rather than treating all uintN as generic non-negative bigints. -
L-15 Low Dvn exposes admin-only ACL and library setters Trust Assumptions Pending
Description
uln-302.tsbuilds theDvnwrite API by spreadingworkerBase[MethodType.WRITE]into theDvnwrite set. That makes the inheritedsetMessageLibrary,setAllowlistedandsetDenylistedmethods callable next to the quorum-signedsetDvnMessageLibrary,setAllowlistandsetDenylistmethods. This matters because the inherited methods do not enforce the DVN signature checks.Dvn.create()disablesroleAdminby setting it to zero, butWorkerBasestill lets any configuredadmincall those inherited setters through#assertRoleAdminOrAdmin(). Consequently, an admin can update the DVN allowlist, denylist, or message-library set withoutvid, expiration, quorum signatures or replay consumption. This does not let an outsider take over the DVN, but it does weaken the documented governance model and makes those protections optional in practice.Recommendation
Remove
setMessageLibrary,setAllowlistedandsetDenylistedfrom the exportedDvnAPI. If those operations are meant to stay callable onDvn, override them so they use the same checks assetDvnMessageLibrary,setAllowlistandsetDenylist. -
L-16 Low Same DVN can satisfy two ULN quorum slots Validation Pending
Description
assertNoDuplicateDvns()only checks sorting and duplicates insiderequiredDvnsand insideoptionalDvns. It never rejects overlap between the two arrays.resolveUlnConfig()later merges defaults with OApp overrides and only callsassertValidStandaloneConfig(), so an overlap introduced by a partial override also survives. This weakens the effective verification quorum.Uln302.#checkVerifiable()first requires every DVN inrequiredDvns, then iteratesoptionalDvnsand decrementsoptionalDvnThresholdfor each verified DVN. Therefore the same DVN can be counted once as required and again as optional. For example,requiredDvns = [A],optionalDvns = [A]andoptionalDvnThreshold = 1become verifiable after a single attestation fromA, even though the config appears to require two independent checks.Recommendation
Reject any address that appears in both DVN arrays. The smallest safe change is to extend
assertNoDuplicateDvns()with a cross-array overlap check, then run that validation again afterresolveUlnConfig()builds the effective config. -
L-17 Low Library registration accepts mismatched names Validation Pending
Description
registerLibrary()stores any non-emptynamestring for a library address. It does not verify that the address is a deployed message library or that the supplied name matches the contract's canonical name. That stored name is later reused astransaction.functionSignature.contractbygetLibraryName().ContainerComponent.#resolveContract()then checks that the requested contract name exactly matches the canonical name stored in the contracts map for that address. Therefore, if an admin registers a real library address with a typo or alternate name, later manager-generated calls to that library revert withContractNotFoundErroreven though the address itself is correct. This can leave the library unusable for manager-mediated dispatch such assend,quote, config continuations and verification-state callbacks. The misregistration is sticky because there is no rename or unregister function and the same address cannot be registered again.Recommendation
Do not trust a user-supplied library name.
registerLibrary()should verify that the target address resolves to a deployed contract and that the supplied name matches the contract's canonicalgetName()result. A safer change is to stop acceptingnameas input and derive the canonical contract name directly from the container's contract registry. -
L-18 Low Receive support check ignores DVN registration Validation Pending
Description
Uln302.#isSupportedReceiveEid()returnstrueas soon as the default receive ULN config exists and names at least one DVN. It does not check whether those DVN addresses are actually registered with the library. That mismatch is reachable becauseMessageLibraryManagerrelies on the library'sassertSupportedEid()continuation when a default receive library is selected. A route can therefore be accepted as supported even though its configured receive quorum is impossible to satisfy.Uln302.verify()only records attestations from callers present in#dvnsandcommitVerification()later waits for the configured DVNs through#checkVerifiable(). If the receive config depends on an unregistered required DVN, or on enough unregistered optional DVNs to make the threshold unreachable, verification for that EID can never complete.Recommendation
Keep the receive support predicate aligned with the actual receive requirements. The smallest safe change is to make
#isSupportedReceiveEid()require every DVN in the default receive config to be registered before returningtrue. Consider also rejecting default and OApp receive configs that reference unregistered DVNs so broken routes fail at configuration time instead of after packets start arriving. -
L-19 Low Event commitment excludes event type/name Validation Pending
Description
In the current TS runtime, the component-level
eventHashdoes not commit the event class/name. The base Event type exposes eventName, but the hashing path only includesevent.emitterandevent.encode()as payload. The base definition in event.ts confirms eventName is available for serialization, and validator.ts:397 uses it for outward event reporting, but it is not part of the committed event hash. As a result, two distinct event classes emitted by the same contract would be commitment-equivalent if they ABI-encode to the same payload bytes. This is not an immediate integrity break if event payload schemas are disjoint in practice, but it means the commitment is not formally binding to the semantic event type, only to (emitter, encoded payload).Recommendation
If event-type distinction is intended to be part of the commitment, include a stable event discriminator in encode() or in the outer event entry hashing format. Otherwise, document that event identity is defined by emitter plus payload shape, not by event name.
-
L-20 Low PriceFeed uses different EID key spaces Unexpected Behavior Pending
Description
setPrice(),setPriceForArbitrum()andsetEidToModelType()store entries under the exacteidordstEidthey receive.getPrice()andgetModelType()also read the exact key back.estimateFeeByEid()does not use that same key space. It first rewrites the requested destination todstEid % 30_000before it loads the model type and price data. This means configuration and estimation use different EID key spaces. A caller can configure prices and model types under the raw destination EID that the rest of the endpoint stack uses, while fee estimation later reads the normalized key instead. The bug is not in the fee math itself. The bug is that the admin surface and the quote surface disagree on which storage key represents the same destination. For example, an operator may treat30001as the destination EID everywhere else in the system. They callsetPrice([{ eid: 30001, ... }])andsetEidToModelType([{ dstEid: 30001, modelType: OPTIMISM }]). Later,ExecutororDvnasksPriceFeedto quotedstEid = 30001.estimateFeeByEid()converts that value to1before reading storage. If key1has no price, the quote reverts withPriceFeed_NoPriceSetError. If key1has a price but no model type, the quote succeeds under the default model instead of the intended Optimism model. In both cases the operator configured the same EID that the rest of the endpoint stack uses, but estimation looked somewhere else. The problem is not that every v2 EID quote is wrong. The problem is that correctness depends on the operator already knowing which representationestimateFeeByEid()will use internally and manually configuring storage under that alternate key.Uln302,ExecutorandDvnall forward the rawdstEidthrough their normal fee flow, so the mismatch is reachable during ordinaryquote()andsend()execution.Recommendation
Pick one canonical representation for destination EIDs and use it consistently across both configuration and estimation. The smallest fix is to normalize
eidanddstEidinsidesetPrice(),setPriceForArbitrum(),setEidToModelType()and the matching getters with the same helper thatestimateFeeByEid()uses. If v1 and v2 aliases are not meant to be interchangeable, remove the modulo rewrite fromestimateFeeByEid()and require callers to configure and query the exact stored EID. -
L-21 Low Canton receive prices native options it ignores Unexpected Behavior Pending
Description
ExecutorFeeLib.#parseExecutorOptions()treats non-zerolzReceivevalue andnativeDropamount as real executor-side native value. It adds both intototalValue, andgetFee()later charges for that value throughconvertAndApplyPremiumToValue(). That assumption does not hold for the Canton receive implementation.DelegateLzReceiveonly forwards a singlefeeAmountintoOApp_LzReceive. The receive request builder consumes that fee for the Amulet-backed request and decodes only the LayerZero message. It does not decode executor options into native-value delivery. Canton does have a separateNativeDropchoice, but it is exposed as a separate helper and is not wired intoDelegateLzReceive. Consequently, a Canton-bound send can be quoted and accepted with non-zerolzReceivevalue ornativeDropamount even though the destination receive execution ignores those options. The result is a mismatched option surface. The sender can be charged for executor value that the Canton receive stack does not deliver.Recommendation
Reject non-zero
lzReceivevalue andnativeDropoptions for Canton destinations until the receive implementation supports them end to end. If those options are intended to work on Canton, decode them during executor handling and map them into concrete Canton operations. That includes a real equivalent forlzReceivevalue delivery and an automaticNativeDropinvocation. -
L-22 Low Odd-length signer input can wedge DVN quorum Unexpected Behavior Pending
Description
setSigner()approves one signer key and stores another when the input hex string has an odd number of nibbles. This happens because the request layer accepts odd-length values forbytes64,hashSetDvnSigner()and#assertValidSignerPublicKey()both normalize that input throughhexToBytes(), and#signersthen stores the raw caller string throughhexStringCodec. Consequently, the quorum signs the left-padded 64-byte public key, but the committee stores a different key.verifySignatures()later recovers the canonical 64-byte signer key, so the malformed member cannot satisfy quorum even though it was added successfully. For a 127-nibble input, the stored array element is worse than just mismatched:hexStringCodeccannot decode it back.getSigners()can then fail, and removing the malformed member can also fail when deletion needs to read that array slot. This can leave the DVN with a committee size that looks larger than the number of signers that can actually authenticate. If quorum is configured against that inflated size, later governance updates and verification can become impossible to satisfy.Recommendation
Enforce exact width for fixed-size byte arguments before
setSigner()runs. Abytes64signer should require exactly 128 hex nibbles after0x, with no odd-length inputs accepted.setSigner()should also canonicalize the signer key once and reuse that same canonical value for hashing, membership checks, storage, and recovered-signer comparison. Using a fixed-width signer codec instead ofhexStringCodecis the safest way to keep those representations aligned. -
L-23 Low Dynamic mapping keys use non-Solidity slots Unexpected Behavior Pending
Description
MapState.#deriveSlot()derives mapping slots fromz.encode(codec, key). That is incorrect for dynamic keys.stringCodec,hexStringCodec, anduint8ArrayCodecemit full ABI value encodings with offset, length, and padded data, while Solidity mapping lookup forbytesandstringuses the raw key bytes as the hashed preimage. Consequently,MapStatewrites dynamic-key entries under different trie slots than a Solidity-compatible verifier would derive. This is already live in the repo.EnumerableSetStatereuses its element codec as the backingMapStatekey codec, andDvnbuilds its signer committee withhexStringCodec. The signer set is therefore committed under non-Solidity slots even though@layerzerolabs/ver-merkleadvertises Ethereum-compatible roots and proofs. Local reads still succeed because the repo repeats the same derivation on both write and read. External proofs or slot lookups that follow Solidity rules will inspect a different location and can fail to verify signer membership correctly.Recommendation
Separate mapping-key encoding from value encoding.
MapStateandSetStateshould accept dedicated key encoders that implement Solidity's mapping-key rule directly, with 32-byte padded encoding for value types and raw bytes forbytesandstring. Until that split exists, rejectstringCodec,hexStringCodec, anduint8ArrayCodecas mapping-key codecs. The current DVN signer set should use a fixed-width signer-key codec instead ofhexStringCodec. -
L-24 Low Mixed-case bytes32 hashes break matching Validation Pending
Description
hexStringSchemaonly adds a missing0xprefix.createZodSchemaFromAbi()then reuses that codec for every fixed-widthbytesNfield, including thepayloadHasharguments onMessagingChannel.nilify(),MessagingChannel.burn()andUln302.finishGetVerificationState(). Mixed-casebytes32hashes therefore survive request decoding unchanged. That interacts badly with the rest of the hash handling code. Inbound payload hashes are stored throughhashCodec, which decodes ABIbytes32values withviem. I confirmed locally against the installedviemthat this decode lowercases the hex body.nilify()andburn()compare the stored hash to the caller-suppliedpayloadHashwith raw string inequality andfinishGetVerificationState()does the same withexistingPayloadHash === payloadHash. The same 32-byte value therefore stops matching itself if the request uses checksum or other mixed-case hex. A legitimate OApp can hitMessagingChannel_PayloadHashNotFoundErroronnilify()orburn()even when it supplies the correct payload hash bytes. The verification-state helper can also reportVerifyingorVerifiablefor a packet that is already committed, because the committed hash in storage does not string-match the mixed-case request argument. These functions are packet-recovery and packet-status tools. A caller should not need the runtime's exact lowercase string form to nilify or burn a known payload hash. The read-side misreporting also makes off-chain automation less reliable, because one committed packet can appear unfinished purely due to hex casing.Recommendation
Canonicalize fixed-width hash values before comparing them. The smallest fix is to make
hexStringSchema, or at least thebytesNbranch ofcreateZodSchemaFromAbi(), normalize hex to one representation such as lowercase0x-prefixed strings. Avoid raw string equality forHashvalues inMessagingChannelandUln302. Compare canonicalized strings or compare the underlying bytes instead. -
L-25 Low Ownable admin rotation is not exposed Unexpected Behavior Pending
Description
ContainerComponent.#resolveMethod()can only call methods on the root contract and the components returned bycontract.getComponents(). Contracts inheritgetComponents() = []by default, so an embeddedOwnablehelper stays invisible unless a contract explicitly exposes it. This matters because several scoped contracts useOwnableto guard live admin actions, but the public vapp schemas do not exportOwnable.getAdmin()orOwnable.setAdmin().TreasuryexposessetLzTokenEnabled(),setNativeFeeBp()andsetLzTokenFee().PriceFeedexposessetPriceUpdater()and other admin setters.Uln302exposes default-config setters and worker registration. All of those operations depend onOwnable.assertAdmin(), but none of them exposes a way to rotate that owner through the same dispatch surface. Consequently, a lost or compromised admin key cannot be replaced with a normal VER transaction. This is port-specific. It comes from the TypeScript container and schema surface, not from upstream LayerZero contract behavior.Recommendation
Expose ownership management anywhere
Ownableprotects mutable state. The smallest safe fix is to surface theOwnablecomponent from each affected contract withgetComponents()and addgetAdminplussetAdminto the exported vapp schemas. If ownership transfer should stay contract-specific, add explicit wrapper methods instead. Emit an ownership-change event and reject the zero address so a bad update cannot freeze the contract permanently. -
L-26 Low Supported EIDs can still fail quote and send Validation Pending
Description
Uln302.#isSupportedSendEid()only checks for a default ULN config with at least one DVN. It does not check whether the EID has a usable executor configuration or whether the resolved executor is actually registered in this library.MessageLibraryManager.setSendLibrary()commits the library selection and then relies on the library'sassertSupportedEid()continuation as its acceptance gate. Therefore an admin or OApp can successfully select a send library for an EID that is still not send-ready. The runtime checks are stricter.quote()andsend()both resolve the executor through#getExecutorConfigOrThrow()and#getExecutor(). If no executor is resolved, they throwUln302_InvalidExecutorError. If the resolved executor is not in#executors, they throwUln302_ExecutorNotRegisteredError. As a result, the configuration can be accepted first and fail only when the first real quote or send is attempted. The zero-address case is already rejected bysetDefaultExecutorConfigs(). The remaining issue is the readiness mismatch: "supported" only proves the DVN side is configured, while quote and send still require a valid registered executor.Recommendation
Keep the support check aligned with the conditions that
quote()andsend()actually require. The smallest safe fix is to makeassertSupportedEid()reject EIDs whose resolved executor config is empty or points to an unregistered executor. -
I-01 Informational Generic non-OApp Request_Create can reach unauthenticated ULN worker-registration methods Access Control Pending
Description
The generic non-OApp request path allows arbitrary VER write transactions to be executed with minimal DAML-side validation, and
Uln302.registerPriceFeed(),registerDvn(), andregisterExecutor()do not enforce any local authorization before mutating trie-backed protocol state. On the DAML side, non-OApp IRequestFactory.Request_Create is controlled only by sender, and RequestFactory validates only:- that callContext has single-path shape
- that callerAddr == sender It does not authenticate callerName, targetName, targetAddr, function name, or arguments for non-OApp requests. The TS layer then converts the callContext into executable VER transactions verbatim and dispatches them through the container. As a result, any party that can use the generic non-OApp request path can target arbitrary VER write methods. In Uln302, the public methods registerPriceFeed(), registerDvn(), and registerExecutor() are exposed in the write ABI and do not perform assertAdmin() or any equivalent authorization check. An attacker can therefore use a generic request to create and register attacker-controlled workers directly inside the shared VER trie. These methods do not merely store metadata. They:
- increment ULN worker nonce
- create new PriceFeed, Dvn, and Executor contract instances in the trie
- add those addresses to ULN’s registered worker sets
- write them into the component contract registry This is an unauthorized protocol-state mutation primitive against the VER execution state.
Recommendation
Apply defense at both layers:
- Add local authorization checks to Uln302.registerPriceFeed(), registerDvn(), and registerExecutor() such as this.#ownable.assertAdmin(context).
- Restrict generic non-OApp Request_Create if arbitrary VER write execution is not intended for unprivileged parties.
- Consider allowlisting callable target contracts/functions for non-OApp requests, rather than relying on method-level discipline alone.
- More broadly, treat all VappApi-exposed write methods as externally reachable unless explicitly blocked by contract logic.
-
I-02 Informational Unused Error Superfluous Code Pending
Description
The
MessageLibraryManager_UnsupportedEidErrorerror is declared but never used.Recommendation
Consider to remove unused code.
-
I-03 Informational Sender-switching does not apply to constructors Informational Pending
Description
ContractLike uses a JavaScript Proxy to rewrite Context.msgSender / nextMsgSender for ordinary method calls between virtual contracts. However, that mechanism only applies when methods are accessed through the proxied instance. Constructor execution itself is not part of that method-interception flow, and the proxy explicitly excludes the constructor property from wrapping. As a result, if a constructor were to perform context-sensitive calls into another contract/helper expecting normal inter-contract caller semantics, the usual msg.sender switching behavior would not be guaranteed. This creates a semantic footgun: constructor-originated cross-contract logic would not behave like normal routed/proxied contract calls. In the current scoped codebase, constructors appear to be used only for object wiring and state-wrapper initialization, and we did not identify a live bug caused by this pattern. The concern is architectural and preventative rather than an active exploit.
Recommendation
Beware of this behaviour and dont make calls inside constructor for future as well.
-
I-04 Informational Storage compatibility for composite values Documentation Pending
Description
For current documention, it multiple places its mentioned that storage follows soldity's storage layout. The current ver-merkle implementation is Ethereum-like in slot derivation and trie commitment, but composite values are encoded as single ABI-encoded blobs rather than stored using true Solidity field-by-field storage layout. The README still describes the package in terms that imply broader Ethereum/Solidity storage compatibility than the implementation actually provides.
Recommendation
Beware and document this design difference from EVM.
-
I-05 Informational Cross-component calls use continuation handoff semantics rather than synchronous call/return Documentation Pending
Description
Cross-component interactions in the VER runtime do not behave like EVM-style synchronous contract calls. When a contract wants to invoke a contract in another component, it returns a continuation Transaction to the Container. The current method then finishes, the component state is committed, and the Container starts a new dispatch loop iteration for the next target. This means cross-component execution follows a handoff model:
- contract A returns a continuation targeting contract B
- Container switches msg.sender to A’s address
- Container resolves the component for B’s address
- contract B executes as a fresh call Contract A does not resume the remainder of its original method body after B returns. Any later “return” to A must be expressed as another continuation into a new A method invocation rather than a resumed stack frame. This is expected by design, but it is a meaningful semantic difference from EVM cross-contract calls and should be documented clearly because incorrect assumptions here can lead to flawed reasoning about atomicity, call stacks, and control flow.
Recommendation
Document explicitly that:
- intra-component direct calls are synchronous and use ContractLike proxy sender rewriting
- cross-component calls are continuation-based handoffs mediated by Container
- cross-component flows cannot resume the original caller’s remaining method body after the callee finishes
-
I-06 Informational Slot isolation depends on sparse address spacing Configuration Pending
Description
For primitive storage wrappers, ver-merkle derives the base slot as holderAddress + slotAllocator.next(), then pads that numeric result into a 32-byte trie key. This means primitive-slot namespaces are separated by the numerical distance between holder addresses, not by a structurally independent (address, slot) storage domain. As a result, two contracts with sufficiently close holder addresses can overlap in principle if one contract allocates enough slots to numerically reach into the other contract’s range. This concern is much weaker for dynamically derived contract addresses, because those addresses are generated via
deriveGlobalAddress(...)usingkeccak256(domain || seed)and are therefore effectively random and sparse in a 256-bit address space. The concern is more relevant formanuallychosen static addresses, especially in tests or bootstrap code that use small values such as 0x1000, 0x2000, 0x3000. Those addresses are close enough numerically that slot ranges can overlap in principle if enough primitive slots are allocated. This is specific to the current storage model because slot derivation for primitive values is based on numeric address offset. It does not apply to EVM in the same way. In EVM, contract storage is scoped by contract account first, and slot 0 in contract A is not numerically adjacent to slot 0 in contract B in a shared global keyspace.Recommendation
- Avoid tightly spaced manually chosen static addresses in bootstrap configurations.
- Prefer deterministic hash-derived addresses for deployed contract holders.
- Consider a slot derivation scheme that structurally separates holder identity from slot index, rather than using numeric address offset for primitive slots.
- Avoid tightly spaced manually chosen static addresses in bootstrap configurations.
-
I-07 Informational TS lzReceive is pull-based, unlike EVM Documentation Pending
Description
In the TS implementation,
endpoint-v2.ts:365treatscontext.msgSenderas the receiver OApp and clears the committed payload for that caller. This differs from the EVM model, where the executor calls EndpointV2.lzReceive(), and the endpoint then pushes delivery into the receiver contract. So OApps, or some off-chain agent acting with OApp identity, must actively initiate the final delivery request. This is a semantic divergence from EVM and should be documented as an integration assumption, because liveness and auth now depend on the TS↔request-layer pull model rather than on executor-driven push delivery alone.Recommendation
Document this explicitly for OApps deployed on canton.
-
I-08 Informational DVN governance does not have a sequencing nonce Signatures Pending
Description
DVN multisig governance actions rely on expiration, vid, and exact-hash replay protection through
#usedHashes. There is no monotonic governance nonce or epoch in the signed payloads for actions likesetQuorum,setSigner,setAllowlist,setDenylist,setDvnMessageLibrary, andquorumChangeAdmin. As a result, newer governance actions do not automatically invalidate older signed instructions; only the exact same signed message is consumed. This makes governance ordering rely on expiration and current signer/quorum drift rather than on an explicit single-step approval sequence.Recommendation
Beware of this consideration.
-
I-09 Informational Inconsistent malformed-argument handling Error Pending
Description
Malformed external arguments can escape the normal
UserErrorpath because method argument decoding happens before the try/catch in the component dispatch boundary. In component.ts:209,request.schema.decode(args)is executed before the block that wraps thrown Errors into UserError. As a result, malformed transactions such as missing required fields, wrong types, strict-object key violations, or malformed nested tuples can throw directly from schema decoding and bypass the container’s normal in-band rejection flow. This creates inconsistent boundary behavior.Recommendation
Consider moving request decoding inside the existing try/catch, or wrap decode failures into UserError at the same boundary as contract-thrown errors.
-
I-10 Informational Executor fees price only message bytes Unexpected Behavior Pending
Description
Uln302prices executor delivery withBigInt(message.length)and passes that value intoExecutor.assignJob()ascalldataSize.ExecutorFeeLibandPriceFeedthen treat that input as the calldata size used for per-byte pricing. That is smaller than the delivered payload. The destination delivery call isEndpointV2.lzReceive(origin, guid, message), and the payload codec also prepends a fixed 32-byteguidto every message. However, the executor fee path prices only the raw message bytes and ignores those fixed bytes. The same pattern appears in#quoteExecutor(), so both quoting and fee collection use the reduced size. This creates a systematic undercharge on chains where calldata bytes are priced separately from execution gas. The effect is strongest on Optimism-style pricing, wherePriceFeedcharges a distinct L1 data term fromcallDataSize.lzReceiveBaseGascan compensate for execution overhead, but it cannot precisely model missing calldata bytes in that separate L1 fee component. As a result, executor fees can be consistently too low, especially for short messages where the omitted fixed bytes are a large share of the total.Recommendation
Do not use
message.lengthas the executorcalldataSize. Derive the billed size from the actual destination delivery footprint and reuse that value in bothquoteandsend. At minimum, include the fixed bytes that are always delivered alongside the message, such as theguidand any other fixed arguments inEndpointV2.lzReceive(...). -
I-11 Informational DVN library revocation does not affect
verifyWarning PendingDescription
setDvnMessageLibraryupdates the worker's mutablemessageLibrariesACL, butverifydoes not read that ACL. Instead,verifyaccepts the call as long asmsgSenderis an admin, the quorum signatures are valid and the providedmessageLibraryequals the DVN's fixed#ulnAddress. This means removing a library withsetDvnMessageLibrary(..., false)only affects entry points that callassertMessageLibrary, such asassignJob. It does not disableverifyfor the ULN address that was bound when the DVN was created. Therefore, the library toggle does not revoke all DVN interaction with that library. It only revokes worker-style caller access.setDvnMessageLibrarydoes not control all DVN interactions with a library. It only updates the worker ACL that gates calls such asassignJob.verifyis governed by a different rule: it can still submit attestations to the DVN's fixed#ulnAddressand it still requires an admin caller plus valid quorum signatures. The problem is that the API presents both controls as "message library" management even though they affect different behavior. After revoking a library, an operator can believe the DVN is fully disconnected from it whenverifystill remains bound to the originally configured ULN.Recommendation
Make the interface reflect the actual control boundary. If
setDvnMessageLibraryis only meant to govern worker caller access, rename it or document thatverifyis intentionally tied to the DVN's fixed ULN address. If the intent is to revoke all interaction with a library, thenverifyshould also require the target library to remain present inmessageLibrariesor the DVN should store that target in one place instead of splitting the rule across#ulnAddressand the worker ACL set. -
I-12 Informational TS_AUDIT describes outdated container semantics Documentation Pending
Description
The audit document states that the container dispatches a transaction to all components in parallel via
Promise.allSettled, expecting exactly one success and the others to reject. The live implementation in packages/api-common/src/container/container.ts no longer behaves that way. Instead, it performs parallel ownership checks withcomponent.hasContract(...)usingPromise.all, selects the first matching component, and executes only that component's call(...). This drift matters because the documented invariants are different from the runtime invariants. The current design still depends on unique contract ownership across components, but it does not rely on "exactly one execution succeeds; others fail" semantics.Recommendation
Consider correcting the comments.
-
I-13 Informational Fallback To Default Drops OApp Timeout Grace Documentation Pending
Description
When an OApp switches its receive library from a non-default override back to the default library, the previous OApp-specific timeout is not preserved or consulted afterward.
setReceiveLibrary(...)explicitly forbids applying a gracePeriod when either the old or new library is the default sentinel (0x0). If the OApp falls back to default withgracePeriod === 0, the per-OApp timeout entry is deleted in packages/endpoint/src/message-library-manager/index.ts:485. After that,getReceiveLibrary(...)resolves the path as default: true, causingisValidReceiveLibrary(...)to consult only the default timeout branch, not the old OApp-specific timeout. This means migration behavior is asymmetric: non-default to non-default supports a grace window, but non-default to default is immediate from the OApp-specific library’s perspective. That may be intended, but iit is easy to misread the timeout model as “old library remains valid for some grace period regardless of whether the new target is default or explicit,” which is not how the current implementation behaves.Recommendation
Document this explicitly in the receive-library migration semantics, especially for operators expecting a graceful fallback from custom receive libraries back to defaults.
-
I-14 Informational OApp ULN override can set zero confirmations Validation Pending
Description
resolveUlnConfig()merges the default ULN config with an OApp-specific override, then validates the merged result withassertValidStandaloneConfig(). That validation checks DVN presence, threshold bounds and total DVN count. It does not callassertValidConfirmations(). Therefore an OApp can setuseDefaultForConfirmations = falseandconfirmations = 0and still produce a merged config that passes validation as long as the DVN arrays are otherwise valid. This value is not inert.Uln302.#setOappUlnSendConfig()andUln302.#setOappUlnReceiveConfig()store the override first, then treat#getOappUlnSendConfig()or#getOappUlnReceiveConfig()as the only post-merge validation step. Those helpers callresolveUlnConfig(), so the zero-confirmation override becomes the live config used by the library.Uln302.#checkVerifiable()later consumes the mergedconfirmationsvalue when it decides whether a packet is ready to commit. It passes that value into#verified()and#verified()only requiresverification.submittedandverification.confirmations >= requiredConfirmations. Therefore a zero-confirmation override removes the finality delay entirely. A packet still needs the configured DVN attestations, but each attestation becomes sufficient as soon as it is recorded. Default configs reject the same value throughassertValidUlnConfig(), so the confirmation invariant is enforced inconsistently between default settings and OApp-specific overrides.Recommendation
Validate the merged config with the same confirmation rule that default configs already use. The smallest safe fix is to call
assertValidConfirmations(resolvedConfig)insideresolveUlnConfig()before returning. UsingassertValidUlnConfig(resolvedConfig)is also reasonable if the duplicate-DVN checks remain compatible with merged overrides. Either change keepsconfirmations > 0enforced after the merge, which preserves the intended finality floor for OApp-specific configs. -
I-15 Informational Nilified packets can become executable again Validation Pending
Description
MessagingChannel.nilify()is an OApp-authorized control that storesNIL_PAYLOAD_HASHfor a nonce:// packages/endpoint/src/messaging-channel/index.ts:127-141 await this.#base.assertAuthorized(context, oapp); const currentPayloadHash = await this.#getInboundPayloadHash(oapp, srcEid, sender, nonce); if (currentPayloadHash !== payloadHash) { throw new MessagingChannel_PayloadHashNotFoundError(currentPayloadHash, payloadHash); } if ( nonce <= (await this.lazyInboundNonce(context, oapp, srcEid, sender)) && currentPayloadHash === EMPTY_PAYLOAD_HASH ) { throw new MessagingChannel_InvalidNonceError(nonce); } await this.setInboundPayloadHash(context, oapp, srcEid, sender, nonce, NIL_PAYLOAD_HASH);EndpointV2.getExecutionState()already treats that sentinel as a canceled packet and returnsNotExecutable.EndpointV2.verify()does not preserve that invariant. It calls#verifiable()and#verifiable()treats any non-empty stored hash as verifiable again. SinceNIL_PAYLOAD_HASHis notEMPTY_PAYLOAD_HASH, a valid receive library can callverify()for the same nonce after the OApp nilified it.verify()then overwrites the nil sentinel with a live payload hash throughsetInboundPayloadHash(...). This lets the receive library undo an OApp's nilification decision and make the packet executable again. The OApp-only nilification control is therefore not final. A receiver that nilified a message to avoid processing it can later be forced back into the same message once the library recommits it.Recommendation
Treat
NIL_PAYLOAD_HASHas terminal inside the endpoint.#verifiable()should reject nilified nonces andverify()should refuse to overwrite an existing nil sentinel with a new payload hash. -
I-16 Informational
lzReceiveAlert()emits spoofable alerts Warning PendingDescription
EndpointV2.lzReceiveAlert()emitsEndpointV2_LzReceiveAlertEventwithcontext.msgSenderrecorded as the executor. The function does not check that the caller is a registered executor, that the caller belongs to the configured message library, or that any delivery attempt actually failed. The intended helper inpackages/endpoint/src/executor/executor.ts:114-141is admin-gated, but that does not protect the endpoint entrypoint itself. Any external caller can invokeEndpointV2.lzReceiveAlert()directly and write a canonical alert event with arbitraryreceiver,origin,guid,message,extraDataandreasonvalues. This makes the alert stream spoofable. Off-chain systems that rely on these events for delivery monitoring, retry logic, or incident handling can be griefed with fake failures. The write also hashes attacker-controlled dynamic byte arrays into the canonical event stream without any endpoint-side authorization gate.Recommendation
Restrict
lzReceiveAlert()to the expected executor set, or at minimum to callers that are authorized through the configured receive stack. If the event is only valid after a failed delivery attempt, bind it to concrete packet state so arbitrary callers cannot mint standalone alerts. -
I-17 Informational Config getters are unreachable via public reads Warning Pending
Description
The endpoint API publishes
MessageLibraryManager.getSendConfig()andgetReceiveConfig()as public read methods. That makes them appear callable through the sequencer and validator read interfaces. Those getters are not actually public reads. The shared config getter still requires the caller to be the OApp or its delegate before it returns the continuation transaction to the target library. The existing tests only exercise these methods with an explicitly injected OApp caller. The external read pipeline never carries a caller identity. Read requests only contain a transaction and state root and the validator executes them with bothtxOriginandmsgSenderset to the zero address. Consequently, these two methods can never pass their own authorization check for any real OApp or delegate. The schema advertises live config inspection, but the only production read entrypoint always calls them as0x0and they revert withEndpointV2_UnauthorizedErrorinstead. This matters because send and receive config are part of the live trust configuration for libraries, executors and DVNs. Exposing auth-gated getters through a callerless read API creates a silent observability gap during integration checks and incident response.Recommendation
Make the interface consistent in one direction or the other. If these getters are meant to stay authorization-gated, extend the public read request format and validator read context so the authenticated caller is propagated into
Context. If the public read API is intentionally anonymous, removegetSendConfigandgetReceiveConfigfrom the exportedREADregistry until they can be called successfully through that interface. -
I-18 Informational
quorumChangeAdmin()drops the DVN admin check Documentation PendingDescription
docs/audit/TS_AUDIT.mdsaysquorumChangeAdmin()requires both admin authorization and quorum signatures. The implementation atpackages/endpoint/src/dvn/dvn.ts:339-357does not callassertAdmin(context), so that statement reads like a missing access-control check at first glance. After comparing the port against canonical LayerZero documentation, this is not a Canton-specific privilege bypass. It matches the upstream DVN design and follows the canonical LayerZero EVM-side recovery model. LayerZero's DVN technical reference docs describequorumChangeAdmin()as an emergency control that lets the signer quorum reassign the admin role without the current admin's permission. Those same docs also state that anyone can submit the signed transaction. This is the same rule used on the LayerZero EVM side. Therefore the signer quorum is supposed to authorize the change, while the submitter is only a relayer. For example, enough DVN signers can sign an instruction that says "add Bob as admin". Eve does not need to be an admin to submit that signed bundle and Bob still becomes admin. A compromised signer quorum does not need cooperation from any current admin to seize the admin role. It can add an attacker-controlled admin throughquorumChangeAdmin(), then use that new admin to exercise every admin-only capability on the worker. That includes fee and routing controls such assetDstConfig(),setPriceFeed()and payee management, plus the rest of the admin-plus-quorum governance surface. However, that is the documented upstream recovery model, not a TypeScript-port regression. It also means any observer who gets a valid signed admin-rotation bundle can relay or front-run that rotation without current-admin participation. The weaker boundary is real, but the inconsistency is between the local audit documentation and the implemented upstream-compatible design.Recommendation
Update
docs/audit/TS_AUDIT.mdand any related local notes to sayquorumChangeAdmin()is a quorum-only emergency admin rotation that does not require current-admin participation and can be relayed by any caller. If the Canton deployment truly wants a stricter local trust model than upstream, then addassertAdmin(context)and document that the port intentionally diverges from LayerZero. -
I-19 Informational Default ULN zero-confirmation parity mismatch Warning Pending
Description
assertValidConfirmations()rejects every config withconfirmations <= 0andsetDefaultSendConfigs()plussetDefaultReceiveConfigs()route default configs through that validator. As a result, the local port cannot accept a default ULN config withconfirmations = 0. That behavior differs from the canonical LayerZero EVM implementation. UpstreamUlnBase.setDefaultUlnConfigs()treats zero as a literal valid default value and reserves onlytype(uint64).maxas the NIL sentinel.Recommendation
If the goal is strict LayerZero EVM parity, validate default ULN configs with a dedicated rule that reserves only the NIL sentinel and allows literal zero confirmations.
-
I-20 Informational Interleaved DVN options make regrouping costly Warning Pending
Description
DvnOptions.groupDvnOptionsByIndex()is meant to regroup DVN options bydvn_index. It stays cheap when all options for the same DVN are already adjacent. It gets much more expensive when the input is interleaved, for example0,1,0,1,0,1. This happens because every time an index appears again,#insertDvnOptions()rebuilds the bytes collected for that DVN withnew ByteCodec().bytes(existing).bytes(newOptions).toBytes(). That copies the old bytes again before adding the new chunk. If the same few indices keep alternating, the helper keeps re-copying larger and larger buffers instead of grouping everything in one linear pass.EndpointV2.quote()andEndpointV2.send()both reach this helper throughUln302. In this repository that cost is paid in synchronous Node CPU.quoteis also a public read method, so an external caller can force the service to spend time on regrouping before any useful work is done. I would still treat this as informational because the practical impact depends on request sizing and service limits rather than on a protocol safety failure. LayerZero EVM shows the same underlying behavior. The officialDVNOptions.solathttps://github.com/LayerZero-Labs/LayerZero-v2/blob/main/packages/layerzero-v2/evm/messagelib/contracts/uln/libs/DVNOptions.soluses the same grouping pattern and concatenates prior bytes withabi.encodePacked(...)in_insertDVNOptions. The officialSendUlnBase.solathttps://github.com/LayerZero-Labs/LayerZero-v2/blob/main/packages/layerzero-v2/evm/messagelib/contracts/uln/SendUlnBase.solcalls that helper from both_quoteDVNs()and_payDVNs(). Therefore this is not a TypeScript-only divergence. The impact is different, however. On EVM the caller pays gas and execution is gas-bounded. Here the same pattern burns service CPU on a public quote surface.Recommendation
Keep the regrouping step linear. Collect chunks per DVN index during the scan, track the final size for each group and build each grouped byte array once at the end. If the current behavior is intentionally aligned with upstream, add request-size or segment-count limits so public quoting cannot spend excessive CPU on regrouping alone.
-
I-21 Informational Unbounded payee strings DoS Pending
Description
The
registerDvnandregisterExecutorfunctions accept apayee: stringparameter without any contract-level length constraint. At the ABI/schema layer, these values are also treated as unconstrained:packages/ver-types/src/schemas/message-library/uln-302.ts:239defines the field astype: 'string'packages/ver-types/src/abi/schema-builder.ts:35mapsstringto a plainz.string()with no maximum length enforcement This value is subsequently ABI-encoded and written into trie-backedStringStatestorage:packages/endpoint/src/worker/worker-base.ts:101packages/ver-merkle/src/contract/string.ts:30packages/ver-merkle/src/contract/codec.ts:48The public HTTP servers in this repository rely on defaultexpress.json()parsing, with no explicit request size limits configured:apps/sequencer/src/server.ts:62apps/vapp/src/server.ts:32As a result, current protection appears to depend on transport or parser defaults rather than protocol-level constraints. If larger payloads can reach execution—whether through deployment-specific ingress paths or direct Canton submission—oversized strings could introduce unnecessary memory consumption and encoding overhead in the shared TypeScript runtime. In theory, if sufficiently large request payloads can reach execution, unbounded payee strings could cause excessive memory allocation in TS validators and potentially lead to process instability or denial of service.
Recommendation
Consider adding explicit maximum lengths for payee and other unbounded string fields at the contract or schema boundary. Do not rely on HTTP body limits or external transport defaults as the only safeguard.
-
I-22 Informational load(...) must preserve layout across TS upgrade Informational Pending
Description
Persistent contract state is reconstructed via
load(...)methods rather than being redeployed from scratch. These loaders recreate state handles using a freshSlotAllocator, and correctness depends on rebuilding them with the same field order, holder address, and codecs as the original layout. Slot derivation is based onaddress + slotAllocator.next()(packages/ver-merkle/src/contract/base.ts:24). As a result, any post-deployment code change that alters the field order or layout assumptions withinload(...)can cause upgraded code to read existing trie data from incorrect slots. This is analogous to storage layout compatibility in upgradeable Solidity contracts, but centered on loader reconstruction. New code can safely extend state only by appending fields, provided thatload(...)continues to rebuild all pre-existing state handles exactly as before.Recommendation
Beware of this constraint, and always make sure in general that order of object's initialization in create matches with load.
-
I-23 Informational Receive path is not atomic due to callbacks Informational Pending
Description
In Solidity,
EndpointV2.lzReceive(...)clears the payload and invokes the OApp receiver within the same transaction. If the OApp callback reverts, the entire transaction reverts, and the packet remains uncleared. In the TS/DAML design, the receive flow is split into two stages:- The TS endpoint clears the payload
- The OApp-specific callback is finalized separately via
PendingCallback/FinalizeCallbackAs a result,packages/endpoint/src/endpoint-v2/endpoint-v2.ts:481may returnExecutionState.Executedas soon as the payload is cleared and the lazy inbound nonce advances—even if the downstream DAML callback has not yet succeeded or is still retrying. This changes the semantics ofExecutionState.Executed. Unlike Solidity, it no longer guarantees that:
“the packet was delivered and the OApp callback succeeded atomically.”Instead, it only indicates that the endpoint-side protocol state has consumed the packet. The application-layer effect may still be pending in
PendingCallbackfinalization. While failed callback finalizations remain retryable (sincedaml/Layerzero/Request/daml/PendingCallback.daml:59archives the callback only after successful dispatch), this split execution model introduces:- Potential liveness issues
- Misleading status signals for monitors or integrations assuming EVM-like semantics
Recommendation
Consider documenting
ExecutionState.Executedas meaning “endpoint-consumed” rather than “fully finalized at the OApp layer.” Any application or monitoring logic that requires end-to-end delivery guarantees should also track the DAML callback finalization state. -
I-24 Informational DAML Receive Callback and msg.value Forwarding Documentation Pending
Description
In
lz-evm, the executor invokes the OApp receiver as:ILayerZeroReceiver(_receiver).lzReceive{ value: msg.value }(...)This allows native value (
msg.value) to be forwarded directly into the receive callback. In the Canton/DAML design, the subsequentLzReceive_callbackaccept path (daml/Layerzero/Oft/daml/OApp/LzReceive/Callback/Accept.daml:27) only processes message, configuration, and rate-limit inputs. It does not receive any direct native-value or Amulet-value equivalent at callback time. As a result, the funding model for receive callbacks differs materially from Solidity:- There is no equivalent of forwarding
msg.valueat execution time - All economic support for the operation must be preconfigured during request creation Implication: Application logic cannot assume EVM-like semantics where callbacks are invoked with attached native value. Any required funding must already be accounted for within the request and fee infrastructure.
Recommendation
Document this as a callback funding model difference from
lz-evm, ensuring that OApp developers do not rely onmsg.value-style behavior during DAML callback finalization. - There is no equivalent of forwarding
-
I-25 Informational No fee-based prioritization under load Gaming Pending
Description
The DAML/Canton request layer validates only a minimum fee at request creation rather than pricing requests according to expected TS execution cost. As a result, the system also lacks a native fee-based priority mechanism: under congestion, users cannot bid more to prefer urgent requests over background or adversarial traffic. This differs from gas-market systems where price signals help both spam resistance and transaction prioritization.
Recommendation
If latency under load matters, introduce explicit prioritization policy, differentiated request classes, or fee tiers tied to scheduling rather than relying on a flat minimum request fee alone.
-
I-26 Informational Skipped & Burnt Packets Are Reported As Executed Unexpected Behavior Pending
Description
The
getExecutionStatefunction classifies any packet withEMPTY_PAYLOAD_HASHand nonce <=lazyInboundNonceasExecuted. However, that storage shape is not unique to packets that were actually delivered to the OApp.skipadvances thelazyInboundNoncewithout delivery, andburndeletes the stored payload hash, which also leaves the packet with an empty hash inside the lazy inbound window. As a result, administratively skipped or burnt packets are reported asExecuted, conflating operational cleanup actions with successful delivery. This mainly affects observability.Recommendation
Consider to document this behavior or to track skipped and burnt packets explicitly.
-
I-27 Informational SupportedOptionTypes Is Unused Superfluous Code Pending
Description
The
SupportedOptionTypesfunctionality seems to be unused in the codebase.Recommendation
Consider removing the
SupportedOptionTypesfunctionality if it is not used to follow best practices. -
I-28 Informational Unused LZ Token Fee Surface Remains Exposed Informational Pending
Description
The VER explicitly skips LZ-token payment handling in
finishSend, but the treasury layer still exposes LZ Token related functions like for example:getLzTokenFee.Recommendation
Consider to remove or disable the LZ-token fee surface completely or to document this behavior.
-
I-29 Informational Stale
startSendreferences in comments Documentation PendingDescription
The comments in
transaction.tsusestartSendandEndpoint.startSendas example method names, but there is no live startSend path in the repository. While this does not affect runtime behavior, inaccurate inline documentation is a documentation hygiene issue and diverges from best practices.Recommendation
Consider to update these comments to reference currently supported methods to follow best practices and reduce confusion.
-
I-30 Informational Consumed Nonce Marks Arbitrary Payloads Verified Unexpected Behavior Pending
Description
The
finishGetVerificationStatefunction returnsVerifiedwheneverendpointVerifiableis false. After a packet has already been cleared and the nonce is consumed,endpointVerifiablebecomes false for that nonce. As a result,getVerificationStatecan be queried with a different payload hash for the same nonce and still reportVerified, even though that payload hash was never actually verified. This does not appear to let an invalid payload execute, but it can mislead offchain tooling.Recommendation
Be aware and consider to document this behavior.
-
I-31 Informational Endpoint API exports unusable contract names Unexpected Behavior Pending
Description
endpointVappApiexportsMessagingChannelandMessageLibraryManageras top-level contract identities next toEndpointV2. That is not how the endpoint family is actually addressable at runtime.EndpointComponentregisters the endpoint address only underEndpointV2, andContainerComponent.#resolveContract()requiresfunctionSignature.contractto match that stored name before method dispatch reachesgetComponents(). Consequently, a client that follows the exported API and sendscontract: "MessagingChannel"orcontract: "MessageLibraryManager"to the endpoint address getsContractNotFoundError, even though the corresponding method exists on the loaded endpoint family. Internal calls avoid this only because they already use the hidden convention of targetingEndpointV2for those methods. This makes the wire-level contract field misleading for real endpoint operations. Methods such asskip,nilify,burn,registerLibrary, and the default-library setters can appear unavailable if a caller relies on the exported contract names instead of the undocumentedEndpointV2convention.Recommendation
Make the endpoint family use one contract-identity model consistently. The simplest fix is to expose all endpoint-family methods only under
EndpointV2and removeMessagingChannelplusMessageLibraryManagerfromendpointVappApi. If those subcomponents are meant to stay independently addressable, then register callable identities for them and update contract resolution so those names can be loaded safely at the endpoint address. -
I-32 Informational Mutuation Testing Results Informational Pending
Description
We ran Stryker to evaluate the quality of the current tests. Below is a summary of the key gaps where coverage could be improved. Full results: https://github.com/GuardianOrg/canton-ver-team1-1773774383947/tree/guardian/mutation-testing-results
1. ULN config resolution and fee plumbing
Files:
packages/endpoint/src/message-library/uln/uln-302/uln-302.ts:623packages/endpoint/src/message-library/uln/uln-302/types/uln-config.ts:73Surviving-mutant themes:- required vs optional DVN handling
- optional-threshold satisfaction
- supported-EID gating
- fee-path edge cases around treasury and worker-fee composition
- DVN option routing/index handling
2. Executor fee arithmetic
File:
packages/endpoint/src/executor/fee-lib.ts:58Surviving-mutant themes:dstEid < 30000boundary- ordered-execution surcharge
- compose gas aggregation
- native-cap enforcement
- value-premium arithmetic
3. Price-model boundaries
File:
packages/endpoint/src/price-feed/price-feed.ts:304Surviving-mutant themes:- “no price set” detection
- Optimism L1 lookup routing
- fee assertions around partial-zero price inputs
- EID bucket / lookup transitions
4. DVN multisig validation
Files:
packages/endpoint/src/dvn/dvn.ts:196packages/endpoint/src/dvn/dvn.ts:455Surviving-mutant themes:- wrong
messageLibraryhandling - duplicate / non-strict signer ordering
- recovery-id validation
- malformed recovered public-key handling
5. Receive-state transitions
Files:
packages/endpoint/src/endpoint-v2/endpoint-v2.ts:470packages/endpoint/src/messaging-channel/index.ts:129Surviving-mutant themes:EMPTYvsNILpayload-hash behavior- lazy inbound nonce boundaries
- distinction between
Executed,Executable, and onlyVerified
6. ACL / admin / runtime glue
Files:
packages/endpoint/src/worker/worker-base.ts:118packages/protocol-common/src/context.ts:114packages/api-common/src/container/component.ts:150These are less protocol-core than the items above, but still security- relevant. Surviving-mutant themes:- allowlist size bookkeeping
- exact
msg.valuedepletion boundaries - event hashing across continuations
- dynamic method dispatch and void-response encoding
Recommendation
Consider improving coverage at these points
Main Review 2
5 findings · May 7, 2026-
M-01 Medium Operations Remain Callable While EID Is 0 Validation Pending
Description
EndpointV2.createnow leaves the endpointEIDunset at genesis and expects a later admin call tosetEid. However,quoteandsendstill readsrcEiddirectly from the messaging channel without asserting that it has been initialized, andvalidateGuidInputsonly rejects EIDs outside the uint32 range, therefore it accepts0n. As a result these functions are callable while the EID is not set.Recommendation
Consider to reject if the local EID equals 0.
-
L-01 Low Inbound nonce checks can scan unbounded backlogs Validation Pending
Description
MessagingChannel.setInboundPayloadHash()stores a payload hash for any nonce that passes the endpoint's verifiability check. It does not update an explicit pending nonce list and it does not enforce a maximum distance from the current lazy inbound nonce.MessagingChannel.inboundNonce()then reconstructs the effective inbound nonce by starting fromlazyInboundNoncesand reading one map slot at a time until it finds a missing payload hash.clearPayload()performs the same kind of linear walk when a receiver clears a later nonce. Therefore a valid receive library can create a large consecutive backlog of verified nonces and later calls that need the effective inbound nonce must scan the whole backlog before returning. This can makeinboundNonce,skip,clearPayloadand endpoint execution-state checks grow linearly with the number of verified but uncleared messages for a(receiver, srcEid, sender)tuple. If VER execution is metered, those calls can exceed the execution budget. If it is not metered, a large backlog still creates an avoidable storage-read DoS surface for receivers and delegates. The Stellar endpoint implementation in this repository handles the same problem with a bounded pending inbound nonce list and aPENDING_INBOUND_NONCE_MAX_LENlimit. This TypeScript implementation has no equivalent bound.Recommendation
Track pending inbound nonces explicitly and advance the effective inbound nonce during verification instead of reconstructing it with an unbounded scan. Reject future nonces that are more than a configured maximum gap beyond the current inbound nonce. At minimum, add a hard cap to the scan in
inboundNonce()andclearPayload(), then return a clear error when the cap is reached. Add regression tests that verify large out-of-order or consecutive verified backlogs cannot make receive-side calls perform unbounded storage reads. -
L-02 Low ULN And Endpoint EIDs Can Diverge Validation Pending
Description
The endpoint-side
EIDand the ULN-sideEIDare now configured by two independent one-shot admin setters. There is no invariant enforcing equality between these stored values.Recommendation
Consider to remove the duplicated source of truth or enforce equality.
-
I-01 Informational DVN signatures omit deployment domain Signatures Pending
Description
DvnHashBuilder.hashhashes the packed DVN action bytes directly. The action helpers include the action selector, DVN address, VID, expiration, and action-specific fields, but they do not include a deployment domain such as chain ID, VER network ID, vApp identity or hash schema version.Dvn.verifySignaturesalso recovers signers from the raw digest, without an EIP-191 or typed-data domain prefix. Therefore a signature is only scoped by the values present in the packed payload. If two deployments reuse the same signer set and can produce the same DVN address, VID, target address and action payload, a signature from one deployment can verify in the other until it expires.Recommendation
Add an explicit domain separator to every DVN multisig preimage. The domain should include at least a protocol name, hash schema version, deployment or network identifier and the verifying DVN address. If raw secp256k1 digests are intentional, document that signer infrastructure must sign this exact domain-separated digest rather than generic wallet messages.
-
I-02 Informational ULN Exposes setEid But No getEid Method Informational Pending
Description
ULNnow has asetEidfunction but misses agetEidfunction as implemented in other parts of the system.Recommendation
Consider to implement a
getEidfunction.
No findings match.
More from LayerZero
All 11 reports-
OneSig on Canton
32 findings 32 findings: 5 medium, 23 low, 4 informational -
Canton VER Updates
169 findings2 critical · 12 high 169 findings: 2 critical, 12 high, 36 medium, 54 low, 65 informational -
OneSig on Solana
27 findings 27 findings: 3 low, 24 informational -
Console EVM Updates
4 findings 4 findings: 1 low, 3 informational
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.