Guardian's review of Canton DAML for LayerZero, published April 2026. The report records 71 findings, including 1 high and 14 medium.
- Published
- Review window
- March 17 to April 3, 2026
- Language
- DAML
- Chains
- Canton
- Sector
- Cross-chain, Infrastructure
- 0 Critical
- 1 High
- 14 Medium
- 36 Low
- 20 Informational
Scope
-
GuardianOrg/canton-ver-team1-1773698451456
826c190968 - LayerZero-Labs/canton-ver 826c190968
98 files in scope · 7,703 nSLOC
| File | nSLOC | Lines |
|---|---|---|
daml/Layerzero/Common/daml/AppUID.daml | 16 | 19 |
daml/Layerzero/Common/daml/MathUtils.daml | 45 | 48 |
daml/Layerzero/Common/daml/MultiSig/MultiSig.daml | 156 | 167 |
daml/Layerzero/Common/daml/MultiSig/MultiSigErrors.daml | 41 | 53 |
daml/Layerzero/Common/daml/MultiSig/Utils.daml | 74 | 85 |
daml/Layerzero/Common/daml/Serde/Serde.daml | 133 | 153 |
daml/Layerzero/Common/daml/Serde/SerdeErrors.daml | 60 | 80 |
daml/Layerzero/DiscoveryRegistry/daml/DiscoveryRegistry.daml | 63 | 79 |
daml/Layerzero/DiscoveryRegistry/daml/DiscoveryRegistry/Errors.daml | 9 | 11 |
daml/Layerzero/Executor/daml/Executor.daml | 142 | 152 |
daml/Layerzero/FeeUtils/daml/FeeUtils/Constants.daml | 16 | 22 |
daml/Layerzero/FeeUtils/daml/FeeUtils/FeeUtils.daml | 328 | 361 |
daml/Layerzero/FeeUtils/daml/FeeUtils/FeeUtilsErrors.daml | 44 | 58 |
daml/Layerzero/Governance/daml/Errors.daml | 29 | 38 |
daml/Layerzero/Governance/daml/Governance.daml | 330 | 365 |
daml/Layerzero/IAccessControl/daml/IAccessControl.daml | 25 | 30 |
daml/Layerzero/IMinFee/daml/IMinFee.daml | 22 | 26 |
daml/Layerzero/IOApp/daml/ILockedPayout.daml | 38 | 46 |
daml/Layerzero/IOApp/daml/IOApp.daml | 152 | 166 |
daml/Layerzero/IOApp/daml/Types.daml | 26 | 29 |
daml/Layerzero/IRateLimiter/daml/IRateLimiterConfig.daml | 69 | 78 |
daml/Layerzero/IRateLimiter/daml/IRateLimiterErrors.daml | 3 | 4 |
daml/Layerzero/IRateLimiter/daml/IRateLimitState.daml | 97 | 106 |
daml/Layerzero/IRateLimiter/daml/RateLimiterConfigTypes.daml | 100 | 113 |
daml/Layerzero/IRequest/daml/Call.daml | 149 | 173 |
daml/Layerzero/IRequest/daml/CallErrors.daml | 45 | 65 |
daml/Layerzero/IRequest/daml/IPendingCallback.daml | 34 | 39 |
daml/Layerzero/IRequest/daml/IRequest.daml | 56 | 64 |
daml/Layerzero/IRequest/daml/IRequestCallback.daml | 34 | 41 |
daml/Layerzero/IRequestFactory/daml/IRequestFactory.daml | 74 | 82 |
daml/Layerzero/IRequestFactory/daml/Keys.daml | 20 | 26 |
daml/Layerzero/IToken/daml/IAllowlist.daml | 100 | 116 |
daml/Layerzero/IToken/daml/ILocalTransferPause.daml | 41 | 49 |
daml/Layerzero/IToken/daml/ITransferRule.daml | 43 | 51 |
daml/Layerzero/Oft/daml/Allocation/AllocationImpl.daml | 44 | 52 |
daml/Layerzero/Oft/daml/Allocation/OftAllocation.daml | 76 | 90 |
daml/Layerzero/Oft/daml/BurnMint/BurnMintImpl.daml | 40 | 47 |
daml/Layerzero/Oft/daml/Common/ConfigUtils.daml | 49 | 55 |
daml/Layerzero/Oft/daml/Common/Constants.daml | 45 | 56 |
daml/Layerzero/Oft/daml/Common/Errors.daml | 124 | 179 |
daml/Layerzero/Oft/daml/Common/Lock.daml | 29 | 38 |
daml/Layerzero/Oft/daml/Common/RoleConstants.daml | 22 | 32 |
daml/Layerzero/Oft/daml/Common/Utils.daml | 176 | 200 |
daml/Layerzero/Oft/daml/OApp/Callback/Dispatcher.daml | 130 | 143 |
daml/Layerzero/Oft/daml/OApp/Constants.daml | 11 | 14 |
daml/Layerzero/Oft/daml/OApp/CostAsserts.daml | 136 | 150 |
daml/Layerzero/Oft/daml/OApp/CostAssertsConstants.daml | 34 | 43 |
daml/Layerzero/Oft/daml/OApp/CostAssertsErrors.daml | 28 | 41 |
daml/Layerzero/Oft/daml/OApp/EnforcedOptions.daml | 103 | 117 |
daml/Layerzero/Oft/daml/OApp/EnforcedOptionsConstants.daml | 14 | 18 |
daml/Layerzero/Oft/daml/OApp/EnforcedOptionsErrors.daml | 6 | 8 |
daml/Layerzero/Oft/daml/OApp/Keys.daml | 5 | 6 |
daml/Layerzero/Oft/daml/OApp/LzReceive.daml | 64 | 74 |
daml/Layerzero/Oft/daml/OApp/LzReceive/Call/ContextBuilder.daml | 66 | 69 |
daml/Layerzero/Oft/daml/OApp/LzReceive/Call/Impl.daml | 67 | 76 |
daml/Layerzero/Oft/daml/OApp/LzReceive/Call/Keys.daml | 38 | 50 |
daml/Layerzero/Oft/daml/OApp/LzReceive/Callback/Accept.daml | 34 | 38 |
daml/Layerzero/Oft/daml/OApp/LzReceive/Callback/ContextBuilder.daml | 56 | 59 |
daml/Layerzero/Oft/daml/OApp/LzReceiveErrors.daml | 8 | 10 |
daml/Layerzero/Oft/daml/OApp/LzSend/Call/ContextBuilder.daml | 71 | 75 |
daml/Layerzero/Oft/daml/OApp/LzSend/Call/Impl.daml | 263 | 308 |
daml/Layerzero/Oft/daml/OApp/LzSend/Call/Keys.daml | 64 | 84 |
daml/Layerzero/Oft/daml/OApp/LzSend/Call/Parser.daml | 114 | 127 |
daml/Layerzero/Oft/daml/OApp/LzSend/Callback/Accept.daml | 83 | 94 |
daml/Layerzero/Oft/daml/OApp/LzSend/Callback/ContextBuilder.daml | 73 | 77 |
daml/Layerzero/Oft/daml/OApp/LzSend/Callback/Parser.daml | 42 | 48 |
daml/Layerzero/Oft/daml/OApp/LzSend/Callback/Reject.daml | 34 | 38 |
daml/Layerzero/Oft/daml/OApp/LzSend/Callback/Shared.daml | 53 | 62 |
daml/Layerzero/Oft/daml/OApp/LzSend/Errors.daml | 34 | 49 |
daml/Layerzero/Oft/daml/OApp/MessageCodec.daml | 133 | 157 |
daml/Layerzero/Oft/daml/OApp/MessageCodecErrors.daml | 31 | 41 |
daml/Layerzero/Oft/daml/OApp/OAppImpl.daml | 30 | 31 |
daml/Layerzero/Oft/daml/OApp/OptionsDecoder.daml | 182 | 203 |
daml/Layerzero/Oft/daml/OApp/Payout.daml | 63 | 71 |
daml/Layerzero/Oft/daml/Oft.daml | 83 | 98 |
daml/Layerzero/Oft/daml/OftFactory.daml | 317 | 388 |
daml/Layerzero/Oft/daml/OftFactoryConfig.daml | 368 | 423 |
daml/Layerzero/Oft/daml/OftFactoryRbac.daml | 49 | 60 |
daml/Layerzero/Oft/daml/Transfer/OftTransferOffer.daml | 137 | 158 |
daml/Layerzero/Oft/daml/Transfer/OftTransferRule.daml | 96 | 116 |
daml/Layerzero/Oft/daml/Transfer/TransferImpl.daml | 75 | 87 |
daml/Layerzero/RateLimiter/daml/RateLimitErrors.daml | 32 | 37 |
daml/Layerzero/RateLimiter/daml/RateLimiterState.daml | 361 | 398 |
daml/Layerzero/RateLimiter/daml/RateLimiterStateHelpers.daml | 77 | 85 |
daml/Layerzero/RateLimiter/daml/RateLimitTypes.daml | 124 | 134 |
daml/Layerzero/Registry/daml/Registry.daml | 97 | 119 |
daml/Layerzero/Registry/daml/Registry/Errors.daml | 14 | 18 |
daml/Layerzero/Registry/daml/Registry/Utils.daml | 15 | 17 |
daml/Layerzero/Request/daml/Errors.daml | 32 | 42 |
daml/Layerzero/Request/daml/Events.daml | 31 | 35 |
daml/Layerzero/Request/daml/PendingCallback.daml | 74 | 85 |
daml/Layerzero/Request/daml/Request.daml | 96 | 111 |
daml/Layerzero/RequestFactory/daml/RequestFactory.daml | 135 | 154 |
daml/Layerzero/RequestFactory/daml/RequestFactoryConfig.daml | 38 | 44 |
daml/Layerzero/StateTransition/daml/Committer/Committer.daml | 42 | 48 |
daml/Layerzero/StateTransition/daml/Committer/CommitterErrors.daml | 8 | 10 |
daml/Layerzero/StateTransition/daml/Committer/CommitterImpl.daml | 95 | 109 |
daml/Layerzero/StateTransition/daml/StateCommitment/StateCommitment.daml | 18 | 21 |
Findings 71
-
H-01 High LzReceive overflow after request acceptance Logical Error Resolved
Description
Inbound receive amounts are accepted into the request flow before the protocol proves that they can be represented in local token units. The OFT balance model stores token amounts as signed
Int, and cross-chain receive finalization converts the decoded shared-decimal amount into local decimals by multiplying it by10^(localDecimals - sharedDecimals). The receive request path validates that the encoded amount field is syntactically well formed and fits the signedInt64parser, but it does not validate that this later scaling step will also fit insideInt64. That omission matters because request creation, request acceptance, and callback finalization are split across separate transactions. A relayed message can therefore be admitted into the system, the handler can accept the request, the original request can be consumed and only then can callback finalization fail when the receive path performs the local-decimal conversion. At that point the receiver is not minted any tokens, but the accepted request has already been archived and the system is left with a livePendingCallbackthat will deterministically fail again on every normal retry because the overflowing message payload is unchanged. The arithmetic itself is straightforward. IfsharedDecimalsis lower thanlocalDecimals, the receive path computes:conversionRate = 10^(localDecimals - sharedDecimals) amountLD = amountSD * conversionRateAny message with
amountSD > maxInt64 / conversionRateis therefore guaranteed to revert during callback finalization. The danger is especially sharp under the repository's own default deployment assumptions, which setlocalDecimals = 18for OFT holdings andsharedDecimals = 6for the OFT adapter. Under that configuration the conversion rate is10^12, so the largest shared-decimal amount that still fits is9,223,372. The next value,9,223,373, overflows. Expressed in whole-token terms, that boundary is only about9.223372tokens. In other words, a normal-looking inbound transfer from an external peer can be accepted into the workflow and still become impossible to finalize. This behavior was confirmed with a standalone PoC test that exercises the realOApp_LzReceive -> IRequest_Accept -> IPendingCallback_FinalizeRequestflow. The PoC proves the exact boundary condition:amountSD = 9,223,372finalizes successfully for an18/6deployment, whileamountSD = 9,223,373is accepted into the request flow but fails only during callback finalization, leaving one live pending callback and zero minted holdings for the receiver. The issue is not limited to the18/6combination, although that configuration makes it far more likely in practice. With8/6, the threshold rises to roughly92.23billion whole tokens, so the same bug becomes harder to trigger accidentally. With6/6, there is no scaling multiplication and this specific overflow path disappears. The protocol therefore has a configuration-sensitive liveness bug whose exploitability ranges from latent to immediate depending on decimals. Because of this issue, a stuck inbound settlement after the request has already been accepted. For affected messages, the normal callback path cannot complete, the receiver never receives the minted balance, and operational recovery is pushed onto manual intervention or out-of-band remediation. In deployments that use the default18/6settings, this can be triggered by realistic cross-chain transfer sizes rather than by an obviously absurd payload.Recommendation
Add an explicit upper-bound check before the request is accepted, and ideally as early as request creation, so that any message whose scaled local-decimal amount would overflow is rejected before the protocol consumes the request and creates an irreversible liveness problem. The bound is simple:
amountSD <= maxInt64 / 10^(localDecimals - sharedDecimals)Keep the same validation in the callback path as defense in depth so that malformed or legacy requests still fail with a clear domain-specific error instead of a generic arithmetic exception. If
18local decimals must remain supported, the stronger long-term fix is to stop storing token balances in signedInt64and migrate the OFT amount model to a wider integer representation or another encoding that can safely represent standard ERC20-sized balances. -
M-01 Medium Rate-limiter config changes bypass checkpointing Unexpected Behavior Partially resolved
Description
The rate limiter was clearly designed around explicit checkpointing.
RateLimiterStatestores accumulated usage, whileIRateLimiterConfigdefines the limits, windows, and bucket-selection rules that tell the system how to interpret that usage. The codebase already includes dedicated checkpoint choices because recorded usage must be decayed under the old rules before a new configuration takes effect. Despite that design,OftFactoryConfigstill exposes direct config mutation paths that rewrite the live rate-limiter configuration without checkpointing the current state first. An authorized admin can change default limits, endpoint overrides, oruseGlobalStatein place while the previously accumulated usage remains untouched. From that moment on, the same stored usage is being read under rules that did not exist when that usage was recorded. For example, imagine an endpoint bucket is almost exhausted under one override and the admin flipsuseGlobalStateor replaces that override directly on the config contract. Future checks can start reading from a different bucket layout, or even from a fresh bucket ateid = 0, without ever migrating the old usage into that new layout. The reverse is just as problematic: tightening a window or lowering a limit in place can make historical traffic look more consumed than it should. In both directions, enforcement starts depending on how the config was edited rather than on the actual transfer history. This is not an unprivileged attack because the caller still needs admin authority. The problem is that the admin interface bypasses the rate limiter's own accounting model. Once that happens, the limiter can grant extra headroom, strand legitimate capacity, or otherwise misstate current utilization.Recommendation
Any change that reinterprets historical usage should go through the checkpointed state transition path, not through a direct config edit. The direct
IRateLimiterConfigchoices should either be removed or restricted to fields that do not affect decay semantics or bucket selection.useGlobalStatein particular should not change in place unless the protocol performs an explicit migration that rewrites the existing usage into the new bucket layout before the flag becomes active. -
M-02 Medium One member can freeze governance DoS Resolved
Description
The governance module describes proposal changes as a committee process, but
Governance_AcceptandGovernance_Rejectare both controlled by the fullmemberslist. In practice that means proposal finalization is unanimous. At the same time,Governance_Proposeonly requiresexpiry > nowand does not impose any upper bound on the proposal lifetime. A single member can therefore create a proposal with a far-future expiry and then refuse to co-sign either acceptance or rejection. That proposal remains pending for as long as the chosen expiry allows. While it is pending, the contract refuses new proposals and also blocksGovernance_SelectLeader. The result is that one committee member can lock the governance process for an arbitrarily long period without needing to break any authorization rule or compromise any other participant. If governance is supposed to be majority driven, the current design does not implement that property. Instead, it gives every member a unilateral veto plus the ability to turn that veto into a full governance freeze by choosing a distant expiry.Recommendation
Implement a real majority-based finalization rule for proposals and stop requiring unanimous participation for ordinary accept and reject flows. Proposal lifetime should also be bounded so that no submitter can create a practically permanent pending state. At minimum, add a majority-driven cancel or reject path that can clear a stalled proposal without requiring the cooperation of every member.
-
M-03 Medium Leader-held fee can freeze governance Trust Assumptions Pending
Description
Governance_Proposetransfers the proposal fee into a normalAmuletowned byleaderand stores only that contract id insideProposalData. The later cleanup paths all depend on that exact live contract staying available.Governance_Acceptfetches it to refund the proposer,Governance_RejectandGovernance_Expireslash it throughslashProposalFeeand even the auto-slash path inside a laterGovernance_Proposedepends on the same stored id. This is especially problematic because the leader is not described as a special trusted escrow role. The contract says the leader must be a committee member and the module-level documentation says governance is supposed to be a committee process where a majority of vApps agrees on changes. In other words, the leader is presented as one participant inside a majority-driven governance system, not as an authority who is allowed to unilaterally break liveness by moving proposal funds. That is unsafe because the fee is not escrowed under governance control. It is an ordinary owner-controlledAmulet, so the leader can consume it, merge it, or otherwise move it outside the governance workflow at any time after proposal creation. Once that happens, every later path that tries to fetch the storedamuletCidreverts. The proposal then remains pending, new proposals are blocked andGovernance_SelectLeaderis also blocked whileproposal /= None. A malicious or compromised leader can therefore freeze governance indefinitely while retaining custody of the fee. Suppose Alice is the current leader and Bob proposes a new committee configuration.Governance_Proposetransfers Bob's fee into anAmuletowned by Alice and stores that exact contract id in the pending proposal. Before Bob and Charlie can finish governance, Alice performs an ordinary self-transfer of that fee amulet, which archives the stored contract and recreates the same value under a fresh contract id that only she controls. From that point forward,Governance_Acceptfails because it tries to refund from the archived id,Governance_Rejectfails becauseslashProposalFeefetches the same archived id andGovernance_Expirefails for the same reason after the deadline. The proposal never clears, Bob cannot submit a replacement proposal and Bob plus Charlie cannot rotate Alice out becauseGovernance_SelectLeaderis blocked while a proposal is pending. The majority is therefore locked out by a single leader-controlled asset movement. Because of this, the leader-controlled amulet is enough to block a proposal resolution and leader rotation for the entire committee.Recommendation
Do not store the proposal fee in a leader-controlled
Amulet. Move the fee into an escrow or lock whose spend paths are governed by the contract logic rather than by the current leader. If the fee must remain transferable, store enough state to reconstruct or recover it without depending on one leader-owned contract id remaining live for the whole proposal lifecycle. -
M-04 Medium Pending outflow does not freeze net accounting Unexpected Behavior Acknowledged
Description
RecordOutflowfreezesscaledAmount,resolvedEidandforwardRecorded, but it does not freeze whether the later accept path should apply net accounting.CommitOutflowreads the live config again and decides on the spot whether inbound usage should be reduced based on the currentinboundEnabledandnetAccountingEnabledflags. That means a pending send can be admitted under one policy and finalized under another. If net accounting is turned on afterRecordOutflow, an old request can retroactively erase inbound usage and reopen capacity that did not exist when the send was created. If it is turned off before finalization, the opposite happens and the system keeps inbound usage that should have been offset. Because of this, a rate-limit manager can record a large outbound transfer, then change the EID policy through the intended checkpoint flow before the callback is finalized. The checkpoint keeps decay math consistent, butCommitOutflowstill re-reads the liveinboundEnabledandnetAccountingEnabledflags for that already-pending send. If the manager turns net accounting on, the old transfer now clears inbound usage and frees room for new traffic that was not available when the request was admitted. If the manager turns it off, the pending transfer no longer frees inbound capacity when it settles. Usage that should have been offset stays on the bucket, so later inbound transfers can be blocked even though that capacity would have been released under the earlier policy.Recommendation
Freeze the net-accounting decision in
RecordOutflowand carry it in the callback payload alongsidescaledAmount,resolvedEidandforwardRecorded.CommitOutflowshould use that frozen decision instead of rereadinginboundEnabledandnetAccountingEnabledfrom the live config. If config changes are only meant to affect new transfers, pending outflows must always settle under the policy that existed when they were recorded. -
M-05 Medium Executor refunds leftover fees to Poor Unexpected Behavior Resolved
Description
Requestsettlement sends leftover escrow tothis.sender. That is fine for normal requests, but it is wrong for executor-backed inbound requests because the executor flow creates the request withsender = pooreven thoughrichfunded the fee.DelegateLzReceivetransfersfeeAmountfromrichtopoorand callsOApp_LzReceiveaspoor. That value is carried intoRequest_Create, so the stored request sender ispoor. Later, bothrequest_AcceptImplandrequest_RejectImplcalldistributeAmuletwiththis.senderas the refund receiver. Any remainder afterminFeeand explicit payouts is therefore paid topoor, not back torich. This breaks the Rich/Poor isolation model.pooris supposed to be a disposable identity with no funds, but accepted or rejected inbound requests can leave operator-funded Amulet on that party. Ifpooris compromised or handled with weaker controls because it was assumed valueless, the refunded balance can be lost.Recommendation
Store the refund recipient separately from
sender. Direct requests can keepsenderand refund receiver equal, butDelegateLzReceiveshould create the request withsender = poorand refund receiver =rich. Both settlement paths should pass that refund receiver todistributeAmuletinstead ofthis.sender. -
M-06 Medium Dust-sized fees can wedge governance cleanup DoS Resolved
Description
slashProposalFeedivides the escrowed proposal fee equally across all non-proposer members with rawDecimaldivision and then passes those payouts todistributeAmulet. That is unsafe for dust-sized but still valid fees. The split helper assumes the fee can always be represented as an equal, strictly positive payout for every recipient. That is not guaranteed.distributeAmuletrequires every payout to be> 0.0and also requires the total payouts to stay within the escrowed amulet amount. With DamlDecimalfixed at 10 decimal places, some legal fees are too small to satisfy both conditions at once. With three committee members, for example, a fee of0.0000000001must be split across two recipients, but no equal split can produce two strictly positive representable payouts without exceeding the input amount. This is a liveness issue as the bad fee is accepted byGovernance_Proposeand only fails later during resolution.Governance_Reject,Governance_Expire, the expired auto-slash inGovernance_Propose, and the cleanup path inGovernance_SetMembersall depend on the same slashing helper. Before expiry the multisig can still avoid the slash path by accepting the proposal, but after expiry the normal governance cleanup flow can be wedged.Recommendation
Do not compute slash payouts with raw
Decimaldivision. Convert the escrowed fee into base units at10^-10precision, compute the per-recipient share with integer division, require the share to be non-zero, and leave any remainder in the treasury. That guarantees every payout is representable and prevents the total from exceeding the available amount. As a secondary guard, rejectminFeevalues that are too small for the current committee size. Regression tests should cover dust fees, expiry cleanup, auto-slash during repropose, andGovernance_SetMemberswith an active proposal. -
M-07 Medium Net accounting overcredits opposite-side usage Unexpected Behavior Pending
Description
applyRateLimitfirst rounds the transfer amount up intoscaledAmount, then uses that same rounded-up value not only to charge the forward direction but also to reduce usage in the opposite direction when net accounting is enabled.commitOutflowapplies the same pattern again for accepted outflows. This means the credit side is not conservative: a very small transfer can erase one full scaled unit of opposite-side usage. This becomes exploitable in the OFT flow because transfers are already quantized to the shared-decimal boundary byremoveDust, while the limiter can be configured with a coarserscaleDecimals. IfscaleDecimalsis larger thanlocalDecimals - sharedDecimals, a minimum nonzero transfer still rounds up to1scaled unit and can cancel a full unit of opposite-direction usage. An attacker can then use dust-sized balancing transfers to keep clearing inbound or outbound usage far faster than the real opposite-side flow would justify.Recommendation
Do not use the same rounded-up
scaledAmountfor opposite-direction credits. Keep forward charging in scaled units if needed, but track net-accounting credits with exact raw amounts or with a separate remainder accumulator so small transfers only offset the usage they actually represent. At minimum, reject configurations wherenetAccountingEnabled = TrueandscaleDecimals > localDecimals - sharedDecimals. -
M-08 Medium accepted OApp requests can run forged callbacks Unexpected Behavior Pending
Description
OApp_CreateRequestlets aDEFAULT_ADMIN_ROLEholder submit arbitrarycallContextand arbitrarycallbackContextand the request path never proves that the callback payload matches the accepted request.RequestFactoryvalidates only the request-sidecallContext, stores the suppliedcallbackContextunchanged, andRequestlater copies it intoPendingCallback. At finalization time, the OFT dispatcher reads that stored callback payload and routes on it as if it were canonical callback provenance. That means request acceptance and callback execution are only loosely coupled. A delegated admin can get one request accepted by the validator, but make finalization execute a different callback family than the accepted request ever created on-chain. The sharpest case is a forgedLzReceive_callback: the normalOApp_LzReceiveentrypoint checksguid,originand peer consistency before a request is created, but the callback-sidehandleLzReceiveAcceptpath does not replay those checks. It just takeslzMessagefromcallbackContext, takesreceiverHintfromexecuteContextand callslzReceive, which mints new OFT directly. A realistic trigger under the current repository is an admin request that uses a harmless acceptedcallContext, but a malicious callback payload. The validator currently executes only the requestcallContextand builds write context withZERO_BYTES32_ADDRESSsender data, so a delegated admin can submitOApp_CreateRequestwithcallContext = EndpointV2.lzReceiveAlert(...)andcallbackContext = LzReceive_callback(...)carrying an attacker-chosenlzMessage.lzReceiveAlertis a benign endpoint write that only emits an event, so the request can be accepted without touching token state. Once accepted,PendingCallbackis created with the forged callback payload. The attacker then finalizes it with a chosenreceiverHintand the dispatcher runs the forged receive callback, minting OFT for the attacker-controlled receiver even though no canonicalOApp_LzReceiverequest ever passed the normal peer and origin checks.Recommendation
Do not let
OApp_CreateRequestinject free-form callback payloads into the OApp callback path. The smallest safe fix is to forbid non-emptycallbackContextinOApp_CreateRequestand reserve callback creation for canonical flows likeOApp_LzSendandOApp_LzReceive. If generic admin requests must keep callback support, bind callback provenance on-ledger.RequestFactoryshould validate that the suppliedcallbackContextis the canonical callback derived from the acceptedcallContext, or store a typed callback discriminator and callback data generated inside trusted code instead of accepting arbitrary nested maps from the caller. -
M-09 Medium PendingCallback allows same-app callback swap Validation Pending
Description
PendingCallbackdoes not store which callback contract was approved when the request was accepted or rejected. At finalize time the caller suppliescallbackCidand the implementation only checks that the chosen contract belongs to the same app and the same admin. That does not preserve callback provenance. A visible finalizer can point settlement at a differentIRequestCallbackcontract for the same app/admin pair, even if the original request carried no callback intent. For example, imagine a bridge app has two callback contracts under the same admin: one releases tokens to the intended receiver and another sends accepted transfers into a manual-review queue. A request is accepted and everyone expects the release callback to run. Under the current design, the finalizer can still supply the manual-review callback at finalize time. The request is still treated as accepted, but the follow-up action is no longer the one that was originally approved. The result is that request authority is decided in two steps: the request is accepted first, then the actual callback target is picked later by whoever finalizes first. That is enough to redirect settlement side effects inside the same app boundary.Recommendation
Bind the callback target before the request becomes finalizable. Store the exact callback contract, or store an explicit marker that no callback is allowed and reject any finalize call that does not match that stored value. If requests without callback intent are supported, they should not create a
PendingCallbackthat can later be pointed at an arbitrary same-app callback. -
M-10 Medium Missing hint falls back to LZ recipient Unexpected Behavior Pending
Description
Before
0517a22,ExecutorSdk.getReceiverHintfailed closed. A missingRegistrycontract or a missingfingerprint -> hintentry stopped callback preparation immediately and surfaced a clear off-chain error to the operator. This commit changes that behavior.getReceiverHintnow scans all visible registries for theappUidand returnsundefinedwhen it does not find a match. Thatundefinedvalue is not rejected later in the SDK.prepareFinalizeCallbackpasses it through and the execute-context builder only insertsreceiverHintwhen the value is truthy. The standard wrapper flow inscripts/src/test-sdk-wrappers.tsnow does exactly that: it callsgetReceiverHint, logs the possibly missing value and still submitsprepareFinalizeCallback. Once the finalize request reaches the ledger without areceiverHint, the OFT callback dispatcher falls back to_DEFAULT_RECEIVER_HINT = "LZ".lzReceivethen reconstructs the actual settlement target asLZ::1220<fingerprint>. A normal registry miss is therefore no longer treated as a safe lookup failure. It becomes a settlement attempt against a synthetic or sibling party id, or a late finalize-time revert if that party id is not hosted. This is a real regression introduced by commit0517a22, not just a restatement of the older finalize-time hint-trust issue. The older bug was that on-ledger settlement trusted the supplied hint too much. This commit widens the reachability of that dangerous path by removing the off-chain fail-closed guard that used to stop finalization when registry resolution failed. This SDK change directly turns ordinary lookup misses into misrouting candidates.Recommendation
Restore the fail-closed behavior in the SDK.
getReceiverHintshould reject missing or ambiguous registry matches andprepareFinalizeCallbackshould require an explicitreceiverHintforLzReceivecallbacks instead of silently omitting the field. The finalize wrapper should abort rather than submit a callback withreceiverHint = undefined. -
M-11 Medium Odd composeMsg rewrites LZ payload on wire Unexpected Behavior Pending
Description
encodeMessageaccepts anycomposeMsgthat passesisValidHex. That helper only checks characters. It does not require an even number of hex digits. WhencomposeMsghas odd length,encodeMessagestill appends it directly to the packedlzMessage. That is not a harmless formatting quirk. The off-chain transport converts the fulllzMessagestring to bytes withhexToBytes, and that helper left-pads the whole string when the hex length is odd. The extra nibble is inserted at the start of the message, not at the start ofcomposeMsg. As a result, every fixed-width field shifts: the receiver fingerprint, the amount, the sender fingerprint, and the compose payload. The effect is visible with a 1-nibble payload. ForcomposeMsg = "a", the encoded message length is 145 hex chars. The transport normalizes it to 146 chars by prepending0. The first 64 chars no longer match the originalsendTo, and the next 16 chars become800000000000003einstead of00000000000003e8. Under the common18 -> 6decimal setup, that mutated amount is already above the signedInt64limit foramountSD, so the receive path fails. Under lower conversion rates, the same bug can settle a different amount than the source side encoded. The impact is on the transfer itself, not only on the compose leg. In the common18 -> 6deployment, the mutated amount usually overflows during inbound validation, so the destination leg becomes undeliverable before minting. If the shifted fields still decode cleanly under a different decimal setup, the destination can instead mint the wrong amount to the wrong fingerprint-derived recipient. For example, imagine an integrator enablesSEND_AND_CALLand submits a transfer with a tiny odd-lengthcomposeMsgsuch as"a". The source side builds a valid-lookinglzMessageand the cross-chain packet is sent. When the off-chain transport converts that message to bytes, it prepends a nibble to the front of the whole payload. The destination then decodes a different receiver fingerprint and a different amount than the source side encoded. Under the repo's normal18 -> 6settings that mutated amount already exceeds signedInt64, so the receive request fails and the packet is effectively stuck. Under a lower conversion rate, the same malformed packet can stay decodable and settle a different mint than the sender intended. This is distinct from the already-known "compose payload is ignored" issue. Here the transfer itself is rewritten in transit before the destination ever decides what to do with the compose payload.Recommendation
Reject odd-length
composeMsgvalues before packing the message. The smallest safe fix is to normalize the compose payload the same way astoand then require byte-aligned hex before concatenation. -
M-12 Medium Fake TransferFactory can spoof payouts Unexpected Behavior Pending
Description
validateExternalPartyAmuletRulesdoes not bindexternal-party-amulet-rulesto the realExternalPartyAmuletRulestemplate or to a trusted factory contract id. It takes a caller-supplied contract id out ofChoiceContext, coerces it toContractId ExternalPartyAmuletRules, converts it to the genericTransferFactoryinterface and then treats a successfulTransferFactory_PublicFetchas proof that the factory is safe to use. That is not a real trust check. A malicious vetted package can implementTransferFactory_PublicFetchandTransferFactory_Transferwith the expected surface behavior while skipping real settlement.distributeAmuletthen accepts aCompletedresult without verifying that any transfer instruction or recipient holding was actually created. This is most meaningful in the governance cleanup path, where the caller is not supposed to control payout settlement. An ordinary member creates a maliciousTransferFactory, waits for a proposal to expire and callsGovernance_Expirewithexternal-party-amulet-rulesreplaced by that contract. The proposal is cleared, the fake factory reports two successful slash payouts, no realTransferInstructionis created for either recipient and the treasury keeps the full fee amulet. For example, Bob submits a governance proposal with a 1.0 Amulet fee and it later expires. Alice, another ordinary member, deploys a vettedTransferFactorythat only records payouts and always returnsCompleted. She then callsGovernance_Expirewithexternal-party-amulet-rulesreplaced by that contract.slashProposalFeesplits the treasury-held fee into two 0.5 Amulet payouts, but no real payout artifact is created for Alice or Charlie. Governance still clears the proposal as if the slash succeeded. The same root cause also reaches otherdistributeAmuletconsumers that trustChoiceContextforexternal-party-amulet-rules, includingRequestsettlement andExecutor.NativeDrop. Those paths are weaker impact arguments because the handler, treasury, or executor already has broad control over payout execution. Governance is the strongest case because an ordinary member can use the bug to clear an expired proposal without delivering the slash payouts that the rest of the committee expects.Recommendation
Do not trust a caller-supplied generic
TransferFactorycontract id here. Bind the external payout factory to a trusted source controlled by the protocol, such as a stored config field or a DSO-owned contract reference and use that exact contract for payout creation. If the factory must still come from context, add an explicit trust-root check before using it. In practice that means validating more thanexpectedAdmin: prove that the contract is the concreteExternalPartyAmuletRulesinstance you intended to use, or reject the call. Also makedistributeAmuletverify a real payout artifact was created instead of treating anyCompletedresponse as settlement. -
M-13 Medium Dust governance fees brick cleanup DoS Pending
Description
distributeAmuletroutes all non-treasury payouts throughTransferFactory_Transfer, creating TransferInstructions with a fixed 24-hour lock. When a payout amount is below approximately 0.00274 CC (holdingFeeRate * transferInstructionExpiry / tickDuration), the amulet's holding-fee-adjusted lifetime is shorter than the lock period. Splice rejects with "lock expires before amulet" and the entire transaction reverts. This affects every caller ofdistributeAmulet. On the governance path,slashProposalFeedivides the escrowed fee equally among recipients. The existingsharePerRecipient > 0.0guard handles zero-share truncation but not small positive shares below the viability threshold, so governance reject, expire, and propose-with-auto-slash all fail permanently. On the request settlement path,IRequest_AcceptandIRequest_Rejectboth calldistributeAmuletdirectly with handler-supplied payouts. A handler who includes an external party with a dust amount bricks the accept. More critically, whenrefundAddressis set (all executor-backed inbound requests), any fee remainder below the threshold is automatically routed through the TransferInstruction path. A handler who sets a reasonable-looking treasury payout that leaves a small positive remainder — for example, 0.999 out of a 1.0 fee — triggers the failure without intending to. The handler cannot avoid this without knowing the exact amulet amount in advance. Recovery on the request path requires the handler to retry with a payout that avoids the external TI route entirely, such as setting the treasury payout to the full fee amount. For the refundAddress variant this is non-trivial because the handler must match the payout sum to the exact amulet balance.Recommendation
Add a minimum-viable-amount guard inside
distributeAmuletbefore creating each TransferInstruction. Compute the threshold from the current round's holding fee rate and skip the TI path for amounts below it — either accumulate dust into the provider payout or use a directtransferAmuletcall which has no TI lock constraint. -
M-14 Medium Slash fee rounding bricks cleanup Rounding Pending
Description
slashProposalFeecomputessharePerRecipient = amulet.amount.initialAmount / divisorusing raw DAMLDecimaldivision, then pays every recipient that full fixed-precision share. DAMLDecimalkeeps 10 decimal places, so repeating-decimal splits such as1.0 / 7 = 0.142857142857...are rounded to0.1428571429. Reusing that rounded share for all 7 recipients produces1.0000000003, which is slightly more than the original1.0fee amulet amount. When that happens,distributeAmuletrejects the payout map withERR_FEE_UTILS_PAYOUTS_EXCEED_AMOUNT. Governance cleanup then rolls back, so active proposals cannot be rejected or expired even with normal-sized fees. With 7 slash recipients, a standard1.0fee already fails this way. Impacts:Governance_SetMemberscallsslashProposalFeewhen a proposal is active → SetMembers revertsGovernance_Proposeauto-slashes expired proposals before accepting new ones → new Propose reverts after the bricked proposal expires- Combined with the original Reject/Expire brick, all four governance mutation paths are frozen. The only escape is
Governance_Accept, which requires multisig cooperation. If the proposer is uncooperative with the multisig, governance is permanently stuck.
Recommendation
Do not split the fee using raw rounded
Decimalshares. Compute slash payouts in a way that guaranteessum(payouts) <= fee, and keep any remainder with the treasury instead of assigning the rounded full share to every recipient. -
L-01 Low Explicit treasury payout can undercut minFee Unexpected Behavior Acknowledged
Description
minFeeis not really enforced as a minimum. When a request is created, the code suggests that treasury should receive at leastminFee. But during settlement, the payout logic only usesminFeeif treasury is missing from thepayoutsmap. If treasury is listed explicitly, that explicit amount wins, even if it is smaller thanminFee. So ifminFeeis100, and the handler settles withpayouts = {treasury -> 10, ...}, treasury gets10, not100. In other words,minFeebehaves like a default value, not like a guaranteed floor. The impact is limited because the handler already controls the payout map. The main problem is that the code and the intended rule do not match. Someone reading the system would reasonably think treasury can never receive less thanminFee, but that is not actually true.Recommendation
The code should make one clear choice and enforce it consistently. If
minFeeis supposed to be a real minimum, then settlement should reject any payout map that gives treasury less thanminFee, even when treasury is included explicitly. IfminFeeis only meant to be a fallback when treasury is omitted, then the naming and documentation should be updated to state that clearly. -
L-02 Low Non-ASCII instrument IDs break AppUID derivation Validation Resolved
Description
The OFT setup path only requires
instrumentId.idto be non-empty. Later, the protocol derives theAppUIDfor that same instrument by sending the text throughtextToHex, which rejects characters outside the supported byte range. The result is a split validation boundary: an instrument identifier can be accepted when the token is configured and then fail later when the protocol tries to use it in request creation, callback routing, or discovery. This is not a case where an invalid identifier is rejected up front. The system accepts it first and only trips over it when a cross-chain flow reaches code that depends onAppUIDderivation. For example, imagine an admin creates an OFT withinstrumentId.id = "Bond-EUR-€"or"债券". Creation succeeds because the factory only checks that the string is non-empty. Later, when the protocol builds theLzSendcall context, it tries to deriveinstrumentIdToAppUID, which callstextToHexon that identifier. At that point the flow fails because those characters cannot be encoded by the current helper. The token exists on-ledger, but any path that depends onappUIDderivation can now break at runtime. In practice this turns a bad identifier into a latent configuration bug. The failure shows up later, in a real token flow, instead of at the point where the configuration was introduced.Recommendation
Validate the identifier at the point it is introduced, using the same character-set rules that
AppUIDderivation expects. If wider Unicode support is intended, thenAppUIDderivation needs to move to a canonical encoding that can represent the full accepted input space. Either way, the creation path and the consumption path need to enforce the same contract. -
L-03 Low Payout expiry permanently locks tokens Logical Error Acknowledged
Description
lzSendcreates payout offers withexecuteBefore = now + offerExpiryDelayPeriod. During callback finalization,ILockedPayout_ForceAcceptcallsfinalizeTransfer→assertTransferTimeConstraints, which enforcesexecuteBefore > now. If finalization is delayed pastexecuteBefore,force-acceptfails withERR_OFT_TRANSFER_EXECUTE_BEFORE_PAST. Because DAML transactions are atomic, the entire accept callback reverts — including thearchive(burn) of the cross-chain holding. No partial success is possible. The reject callback (forceRejectImpl) does NOT enforce time constraints, it callsunlockAndFaildirectly, bypassingfinalizeTransfer. So the reject path always succeeds as recovery. Net impact: delayed finalization forces the reject path (sender gets tokens back, cross-chain message fails), not permanent fund loss. However,TransferRule_ExecuteAsAdminenforcingexecuteBeforeon admin-forced settlement is unnecessary and causes avoidable reject-path fallbacks.Recommendation
Remove the
executeBeforecheck fromTransferRule_ExecuteAsAdmin/ILockedPayout_ForceAccept. The admin already has full authority, so the time gate adds no security value on the force path and causes unnecessary accept-callback failures. -
L-04 Low feeDeposit cannot send when issuer fee is set Unexpected Behavior Pending
Description
lzSendprependsfeeDepositto the payout receiver list wheneverissuerFeeAmount > 0. If the sender is also the configuredfeeDepositparty, the payout path later tries to create anOftTransferOfferwithtransfer.sender == transfer.receiver. That shape is forbidden byOftTransferOffer. The send therefore reverts even though the sender is only trying to route the issuer fee back to itself. The code already hints at this failure mode in thelzSendimplementation, but there is no validation onfeeDepositand no special handling for the self-fee case. Because of this, any party configured asfeeDepositis unable to originate sends while a positive issuer fee is active.Recommendation
Handle the self-fee case explicitly. When
sender == feeDeposit, do not create a self-directedOftTransferOffer. Instead, keep the issuer-fee amount on the sender side as unlocked change or fold it into the refund path while still reducing the cross-chain amount by the fee. -
L-05 Low OApp request validation ignores endpoint address Validation Pending
Description
validateCallContextIdentitychecks that an OApp request usescallerName = "OApp",callerAddr = appUIDandtargetName = "EndpointV2", but it never checkstargetAddr. The OFT builders and TS SDK do set a concrete endpoint address, so this field is meant to carry real routing identity, not just metadata. That means a privileged caller usingOApp_CreateRequestcan submit a request with a forged endpoint address that still passes on-ledger identity validation. Off-chain code preserves that address and uses it for transaction routing. This does not let the caller spoof an arbitrary contract, because the container still checks that the address belongs to anEndpointV2instance, but it can route to the wrong live endpoint or create requests that only fail later during execution. The issue is low severity because the affected path is already admin or delegated-admin only,and the standardOApp_LzSendandOApp_LzReceiveflows build the correct call context internally.Recommendation
For OApp requests, bind
targetAddrto trusted config.Request_Createshould compare it against the configuredendpointAddress, or the OApp side should derive the endpoint address internally instead of accepting it from caller-suppliedcallContext. -
L-06 Low Zero minFee bricks registry-style writes DoS Pending
Description
RegistryallowsminFee >= 0, butSetPartyIdalways charges exactlyminFee. If an admin setsminFee = 0, the choice still callstransferAmuletwithamount = 0. That does not create a free registration mode. With the bundled Splice Amulet rules, a zero output is rejected during transfer processing, soSetPartyIdbecomes uncallable. The same pattern exists inDiscoveryRegistry.SetUrl, which also allowsminFee = 0and then always callstransferAmuletwithamount = minFee. Therefore, an admin can put the contract into a state that looks like "free mode" in configuration but actually bricks the public write path.Recommendation
Do not allow
minFee = 0on write paths that always charge exactlyminFee. The simplest fix is to requireminFee > 0in bothRegistryandDiscoveryRegistry. If zero-fee mode is intentional, handle it explicitly by skipping the fee transfer whenminFee = 0and document that spam protection is disabled in that mode. -
L-07 Low Missing validation on CostAsserts Validation Pending
Description
The
isValidCostAssertschecksnativeDecimalShift - gasPriceScale <= 10but does not check the opposite direction,gasPriceScale - nativeDecimalShift <= 10. The comment inCostAssertsConstants.daml:35says the scale ratio should be capped at10^10, but the current validation only enforces one side of that bound. As a result, a_COST_ASSERTS_ADMIN_ROLEholder can store a config wheregasPriceScalegreatly exceedsnativeDecimalShift. Such values are unlikely in a well-configured production deployment, but the validation is broad enough that extreme or mistaken configurations are still accepted. For example,totalGas=3,000,000,maxGasPrice=5000,gasPriceScale=21, andnativeDecimalShift=3are accepted today and makecomputeOptionsCostoverflow DAMLDecimalarithmetic. In those cases,lzSendaborts for the affected destination until the config is removed or corrected.Recommendation
As the primary fix, add
&& config.gasPriceScale - config.nativeDecimalShift <= _MAX_SCALE_DELTAtoisValidCostAsserts. As defense-in-depth, also capmaxGasPriceor add a runtime guard incomputeOptionsCostbefore the multiplication so extreme-but-valid configs cannot still overflowDecimal. -
L-08 Low DAML lzReceive lacks replay protection Validation Pending
Description
The DAML
lzReceivepath does not enforce idempotency for inbound messages.Impl.daml:50-53validatesguidformat (bytes32 hex) andorigin.nonce > 0, but neither value is recorded or checked for uniqueness.OApp_LzReceiveis a nonconsuming choice on IOApp (IOApp.daml:79), andRequestFactoryhas no idempotency key. Each call creates a fresh Request regardless of whether the same(srcEid, sender, nonce)tuple was already processed. The off-ledger TypeScript endpoint (packages/endpoint/src/messaging-channel/index.ts) provides replay protection throughverifyandclearPayloadwith strict nonce sequencing (nonce === previous + 1). However, this protection does not extend to the DAML contracts that ultimately mint tokens. The executor — a permissioned backend vApp service authenticated via Canton JWT — callsDelegateLzReceiveon the Executor contract, which callsOApp_LzReceiveon the DAML IOApp. Supply safety therefore depends entirely on the off-ledger executor service behaving correctly; the DAML layer has no independent idempotency guard. This is a defense-in-depth concern, not a permissionless attack: the executor is a trusted role. The finding is that this trust assumption is implicit and undocumented — the DAML contracts accept any well-formed inbound message without checking whether it was already processed.Recommendation
Either add DAML-side replay protection (e.g., atomically create a unique processed-message contract keyed by
(srcEid, sender, nonce)orguid— DAML contract uniqueness would reject duplicates), or explicitly document the executor service as a trust assumption for supply safety. -
L-09 Low DelegateLzReceive hands live fee to IOApp Trust Assumptions Pending
Description
DelegateLzReceivetransfersfeeAmountfromrichtopoorand then passes the resultingpoorAmuletCidinto arbitraryIOApp.OApp_LzReceivecode before anyRequestexists. At that point the untrusted IOApp has both the live fee asset andpoor's authorization.poorAmuletCid <- transferAmulet ... rich poor ... exercise ioAppCid OApp_LzReceive with sender = poor amuletCid = poorAmuletCidThat lets a malicious IOApp spend the fee and abort the flow before request creation. The executor's "poor has no value" safety story does not hold during this step because the bridge itself has just funded
poor.Recommendation
Do not pass a spendable fee amulet into untrusted IOApp code. Escrow the fee in trusted code before the IOApp call, or create a wrapper that
poorcan only forward into the trusted request path. If the IOApp still needs fee metadata, pass a non-spendable reference instead of a live amulet CID. -
L-10 Low lzReceive accepts zero-amountSD messages Validation Pending
Description
The LayerZero reference OFT on EVM allows zero-amount transfers when
minAmountLD == 0: source-side send succeeds with(amountSentLD=0, amountReceivedLD=0), destinationlzReceivecredits0, and composed messages still enqueuelzComposewith amount0. DAML diverges from that behavior.createLzReceiveRequestacceptsamountSD = 0because the guard isamountSD >= 0. The handler can accept the request and consume the fee. But finalization later callslzReceive, computesamountLD = 0, and tries tocreate Oftwithamount = 0. The Oft template requiresamount > 0, so finalization reverts and thePendingCallbackremains stuck. Result: a zero-value OFT message that is valid in upstream LayerZero semantics is accepted into the DAML request lifecycle, but DAML rejects it only at the final mint step, after fee consumption.Recommendation
Reject zero amount at request creation time by requiring
amountSD > 0increateLzReceiveRequest. -
L-11 Low Unbounded receiver list in lzSend Validation Pending
Description
parseLzSendArgsvalidatesreceiverCount >= 0but has no upper bound. Each receiver creates one locked Oft holding and one locked OftTransferOffer duringlzSend, and callback finalization must process all payouts in a single transaction. If the receiver list is large enough, finalization can exceed chain limits and revert. In that state, the accepted callback remains stuck: the cross-chain holding stays locked, the user has no self-service recovery path because the payout offers are admin-locked, and the request fee has already been consumed. The admin can likely recover funds manually by unlocking the main holding and force-rejecting payouts in smaller batches, but recovery is operationally painful and not part of the normal application flow.Recommendation
Limit
receiverCount <= MAX_RECEIVERSinparseLzSendArgs -
L-12 Low issuerFeeBps=10000 bricks lzSend Validation Pending
Description
The ensure clause allows
issuerFeeBpsup to 10000 (feeBps <= _BPS_DENOMINATOR). Setting 10000 (100%) passes config validation but bricks alllzSendfor that eid:safeMulDivCeil(amountLD, 10000, 10000) = amountLD, sototalPayouts >= amountLDalways reverts. No error at config time.Recommendation
Use
feeBps < _BPS_DENOMINATOR(strict inequality) in the ensure clause. -
L-13 Low Governance role separation not enforced Validation Pending
Description
The 3e2c36d refactor split governance into
multisig(decision authority) andtreasury(fee custody), but the ensure clause does not enforcetreasury /= multisig. A deployment can collapse both roles into one party, letting it unilaterally accept/reject proposals and control slashed fees without member co-signing. More broadly, the contract never provesmultisigrepresents the declaredmembers— authority is a deployment assumption, not an on-ledger invariant.Recommendation
Add
treasury /= multisigto the Governance ensure clause. -
L-14 Low Enforced options bypass via composeMsg Validation Pending
Description
combineOptionslooks up enforced options by(eid, msgType). ThemsgTypeis derived from the caller-suppliedcomposeMsg: empty means SEND (msgType=1), non-empty means SEND_AND_CALL (msgType=2). If the admin only configures enforced options for SEND, a caller can setcomposeMsgto any non-empty hex (e.g., "ff") to select SEND_AND_CALL and skip the enforced options lookup entirely.lookupEnforcedOptionreturns "" andcombineOptionspasses the caller's rawextraOptionsthrough without prepending the admin's enforced TLVs. Because the receive side does not execute the compose payload, the caller ends up with a plain token transfer whose cross-chain options contain no admin-enforced gas parameters. Enforced options typically encode a minimumlzReceiveGasfor reliable destination execution; bypassing them lets the caller underfund delivery gas.costAssertsprovide partial mitigation when configured, but enforced options and cost asserts are independent configuration mechanisms.Recommendation
When no enforced options are configured for SEND_AND_CALL on a given eid, fall back to the SEND enforced options for that eid. Alternatively, auto-replicate SEND enforced options to SEND_AND_CALL unless explicitly overridden.
-
L-15 Low PendingCallback finalizer is not bound Trust Assumptions Pending
Description
IPendingCallback_FinalizeRequestis controlled by a caller-suppliedactor. The implementation indaml/Layerzero/Request/daml/PendingCallback.damlvalidates the callback contract, but it never checks thatactoris the sender, the handler, the instrument admin, or any other role that was bound when the request was created. That makes visibility enough to finalize. Any party that can see both thePendingCallbackand the callback contract can submit the choice first. This turns observers into active operators. It also widens every finalize-time binding issue, because the first visible actor can win the race and choose the settlement inputs.Recommendation
Store an explicit finalizer, or an explicit allowlist of finalizers, on
PendingCallbackand reject anyactoroutside that set. If broad liveness support is intentional, keep that separate from callback authority. The code should still bind who is allowed to finalize before the request reaches the pending-callback stage. -
L-16 Low Invalid accept callback can silently no-op Unexpected Behavior Pending
Description
On the
OftFactoryaccept path,dispatchAcceptonly handles the expectedlzSendandlzReceivecallback families. If the storedcallbackContextis empty or names some other function, the dispatcher does not reject it. It falls through topure (). That would be less dangerous if finalization stayed retryable, but it does not.PendingCallbackstill archives afterOnRequestAcceptedreturns, so the request can look fully finalized on-ledger even though no callback-side action ran. In practice, an accepted request can consume its only finalization handle without running the callback logic that should mint, unlock, or settle value.if this.isAccept then exercise callbackCid OnRequestAccepted ... ... archive pendingCidRecommendation
Reject invalid accept callback contexts instead of treating them as a no-op.
dispatchAcceptshould abort whencallbackContextis empty or does not match a supported callback family. Also validatecallbackContextwhen the request is created so malformed accept callbacks are rejected before a request can ever reach the pending-callback stage. -
L-17 Low Malformed hints can brick registry resolution Validation Pending
Description
Registry.SetPartyIdstores the caller-suppliedhintwithout checking that it round-trips into a valid party id.GetPartyIdlater rebuildshint::1220<fingerprint>and aborts ifpartyFromTextrejects it. That lets a caller write a malformed hint and make its own fingerprint unresolvable. Once stored, every lookup for that fingerprint fails until the entry is replaced with a valid hint.Recommendation
Reject non-canonical hints at registration time, or stop using caller-provided hints as identity material. The safest fix is to derive canonical identity from
callerand keep any user-facing label separate from the value used for resolution. -
L-18 Low AppUID hashes differ for extended-byte ids Validation Pending
Description
instrumentIdToAppUIDhashes the hex encoding ofpartyToText admin <> ":" <> id. That only works if every caller turns the text into bytes the same way. This repo does not. On the DAML side,textToHexuses a one-byte-per-code-point encoding for values below256. On the TypeScript side,computeAppUIDUTF-8 encodes the same string before hashing. Extended-byte ids that are still accepted on-ledger therefore hash to differentappUIDvalues off-ledger. That breaks any flow that relies on a stableappUID. Request creation, callback finalization and discovery entries can all fail once the SDK and the ledger disagree about the same instrument id.Recommendation
Choose one byte encoding and enforce it everywhere. The safest fix is to reject extended-byte instrument ids at the OFT boundary. If extended bytes must stay supported, make the TypeScript helper use the same byte encoding as DAML and lock that down with cross-language test vectors.
-
L-19 Low Force-accept rechecks live local pause Validation Pending
Description
ILockedPayout_ForceAcceptuses the admin settlement path, but that path still callsfinalizeTransfer, which re-runsassertLocalTransfersNotPaused. That lets an operator pause local transfers after a payout was already locked and make a later force-settlement fail. The problem is not limited to theLzSendcallback flow. Any locked payout that relies onILockedPayout_ForceAcceptinherits the same live pause revalidation. A forced settlement path is supposed to finish a payout that is already in its locked finalization stage. Reusing the ordinary local-transfer pause gate on that path means a later config toggle can brick settlement for work that was already committed.Recommendation
Do not run the normal local-transfer pause check on
TransferRule_ExecuteAsAdmin. If callback settlement needs its own emergency stop, model it as a dedicated payout-settlement pause. -
L-20 Low Multi-registry lookup is first-match-wins Unexpected Behavior Pending
Description
Commit
0517a22changesExecutorSdk.getReceiverHintfrom a single-registry lookup into a scan across every visibleRegistryfor the sameappUid. The loop stops on the first matchingfingerprint -> hintentry and returns it. That only works if one app can never have two live registries with different entries for the same fingerprint, or if there is a clear rule for which registry wins. Neither is true here.Registry.SetPartyIdstill only enforces uniqueness inside one contract. It does not stop the same fingerprint from being present in two active registries for the same app with different hints. This same commit also addsmaxEntries, which makes multi-registry setups an intended mode rather than an odd corner case. The result is an ambiguous receiver resolution. If two registries both contain the same fingerprint, the SDK will silently take whichever contract happens to appear first in the active-contract query. There is no tie-break by version, shard, creation time, or anything else that would make the result predictable. This matters because the hint is later fed into OFT callback finalization.OApp.LzReceiverebuilds the mint recipient fromhint <> "::1220" <> fingerprint. Because of this, a stale or conflicting registry entry can send an inbound receive to the wrong same-fingerprint party, or make finalization fail if the chosen hint no longer forms a valid hosted party id.Recommendation
Do not resolve receiver hints by scanning all live registries and taking the first hit. Finalization should be tied to one authoritative registry, one shard, or an explicit registry contract id carried through the callback path. If multiple registries must coexist, the SDK should collect all matches and reject anything except one unambiguous result. The registry layer should also enforce uniqueness across active registries for the same
appUid. -
L-21 Low Deploy script defaults registry fee to zero Warning Pending
Description
This commit adds a new
buildDiscoveryRegistrydeployment helper and makesminFeedefault to0.0when the caller does not supply one. That is unsafe forDiscoveryRegistry. The on-ledger contract still routesminFeedirectly intotransferAmuletduringSetUrl, and the current fee path does not support a zero-valued transfer output. The result is that the new script can deploy a registry that looks valid but rejects every registration until the handler repairs the fee config.scripts/src/deploy.tsnow callsbuildDiscoveryRegistrywithout aminFeeargument, so the broken default is used by the new deployment flow.Recommendation
Do not default
DiscoveryRegistry.minFeeto0.0in deployment code while the contract still treats zero as an invalid transfer amount. The smallest safe fix is to require an explicit positiveminFeehere or to default to the same positive floor the project already uses for tests and other live registration flows. If zero-fee mode is meant to be supported long term, the on-ledger contract must first gain an explicitminFee = 0branch that skips fee transfer safely. Until then, deployment code should reject or avoid zero. -
L-22 Low Cost asserts underprice executor fees Validation Pending
Description
computeOptionsCostonly prices the raw gas and value decoded from the type-3 options blob. It addslzReceiveGasandlzComposeGas, checksnativeCapand multiplies that result by the configured gas price and price ratio. The offchain executor charges more than that. Inpackages/endpoint/src/executor/fee-lib.ts,decodeExecutorOptionsaddslzReceiveBaseGas, addslzComposeBaseGas * lzComposeCountand applies theorderedExecution2% premium before the final fee is quoted. The fee path also runs throughestimateFeeByEid, which charges forcallDataSize * gasPerByte, but the DAML cost-assert path has no calldata-size input and cannot model that term at all. There is also a Canton-specific mismatch between what cost assertion validates and what execution actually receives.assertOptionsCostcompares the computed cost against the grossfeeAmount, but request execution later reconstructsmsgValueas the escrowed amulet amount minusRequestFactoryConfig.minFee. That reservedminFeestays with treasury on accept and reject, so it is not available to satisfyEndpointV2's fee sufficiency check. On deployments with non-zerominFee, the spendable execution budget is therefore smaller than the amount cost assertion approved even before accounting for the missing executor base-gas, premium and calldata terms. That combined gap means a sender can supply a fee that passes the DAML cost assertion but is still too small for the executor. The repo's own guide presents cost assertion as the anti-spam check that stops underfunded sends before they consume the send flow, but the current formula cannot model the executor's real gas floor and also validates the wrong fee base whenminFee > 0. Thedaml-propsharness reproduced the executor-formula mismatch three ways: a plainlzReceiveunderpriced by missing base gas,orderedExecutionunderpriced because the 2% premium is ignored and alzComposesend underpriced because the per-compose base gas is ignored. The impact is operational rather than fund-stealing. When the handler rejects the underfunded request, the holding is unlocked and outbound usage is reversed, but the request still reaches the offchain path that cost assertions were meant to filter out. On deployments with lowminFee, this gives users a cheaper way to churn requests and rejection work even whencostAssertsare configured.Recommendation
Make the on-ledger estimate match the executor fee model closely enough to be a real prefilter and compare it against the net fee budget that execution can actually spend. At minimum, extend the DAML config and formula so
computeOptionsCostaccounts forlzReceiveBaseGas,lzComposeBaseGas, theorderedExecutionpremium and the calldata-size term used by the price feed. IfRequestFactoryConfig.minFeeremains a separate treasury reserve, subtract it before the cost-assert comparison or otherwise ensure callers provide an execution fee that is explicitly exclusive ofminFee. If exact parity with the executor's multiplier and floor-margin settings is required, add those terms too or reject option combinations that the DAML config cannot price safely. -
L-23 Low odd-length options fail at the ULN boundary Unexpected Behavior Pending
Description
combineOptionsonly checks thatextraOptionsstarts with the type-3 prefix0003. It does not require byte-aligned hex, so a caller can supply a one-nibble-truncated type-3 blob andprepareSendwill still return it as the finaloptions. The enforced-options config validator has the same gap inisValidEnforcedOptions, because it only checksisValidHexandisOptionsType3. That string does not survive the TypeScriptbytesboundary. The offchain codec converts hex to bytes withhexToBytes, which left-pads odd-length hex at the front. For thedaml-propswitness0003010011016d13193f3fbd9392ff414794a43b54b, the transport rewrites the prefix from0003to0000before the ULN parser sees it.OftFactory_BuildQuoteusesprepareSenddirectly, so it can hand the offchain layer a quote that is already invalid.lzSendonly catches the same malformed options early whencostAssertsis configured for that route; otherwise the request can be created on-ledger and then fail later in the offchain send path. This is a liveness and griefing issue. A user can submit malformed odd-lengthextraOptionsto create requests that are guaranteed to be rejected later and an admin can brick a route by storing odd-length enforced options.Recommendation
Reject odd-length hex anywhere options are accepted. The smallest safe fix is to add an explicit even-length check in
combineOptions,isValidEnforcedOptionsand any send-param validation path that accepts rawextraOptions. -
L-24 Low Cost assertions reject valid legacy ULN options Validation Pending
Description
decodeExecutorOptionsonly understands type-3 executor worker options. If the blob does not start with0003, it falls straight into the worker-TLV parser at offset0. That means valid legacy ULN options such as type 1 (0001 + u256 gas) and type 2 (0002 + u256 gas + u256 amount + bytes receiver) are treated as malformed worker headers and rejected by the DAML cost-assert path. That does not match the shipped offchain endpoint.packages/endpoint/src/message-library/uln/uln-302/uln-options.tsexplicitly accepts legacy types 1 and 2 and normalizes them into executor options before fee handling. The repo's TS tests cover both successful legacy decode cases. Thedaml-propsharness reproduced the mismatch on seed1with a valid type-1 witness, and fixed-witness scripts confirmed that the real DAML decoder rejects both0001and0002forms even though the TS decoder accepts them. The result is a route-dependent interoperability failure. WhencostAssertsare configured, a sender can provide options that the endpoint would accept and execute, but the ledger rejects them first duringlzSend. WhencostAssertsare not configured, the same legacy options still pass throughprepareSendand can be consumed offchain.OftFactory_BuildQuotealso returns the legacy options unchanged, so the quote path does not fail by itself, but it no longer matches the stricter send behavior on cost-asserted routes. This does not let an attacker steal funds, but it can break sends for integrations that still use legacy ULN options and it makes the DAML precheck disagree with the real endpoint behavior.Recommendation
Make the DAML cost-assert decoder follow the same option semantics as the endpoint. The smallest safe fix is to recognize legacy ULN types 1 and 2 in
decodeExecutorOptionsand translate them into the same aggregate executor fields that the type-3 path produces. If legacy options are intentionally unsupported on-ledger, reject them earlier in send and quote validation too, so the whole stack enforces one rule instead of only the cost-assert path doing so. -
L-25 Low outboundLimit=0 blocks all outbound transfers Logical Error Pending
Description
isValidConfigEntry
validatesoutboundLimit >= 0, allowingoutboundLimit=0. When rate limiting is active (outboundEnabled=True,isGloballyDisabled=False),computeDecayalways returnsavailable=0`:decay = safeMulDiv(limit=0, elapsedSecs, window) = 0 usage = max(0, amountInFlight - 0) = amountInFlight available = max(0, 0 - usage) = 0Any
scaledAmount > 0fails withERR_RATE_LIMIT_EXCEEDED. This is permanent — no time-based recovery exists becauselimit=0means zero decay rate. The entire outbound path is bricked until admin reconfigures. The footgun is subtle:_EMPTY_RATE_LIMITER_CONFIGusesoutboundLimit=0withisGloballyDisabled=Trueas its default. If an admin copies this template and enables rate limiting (isGloballyDisabled=False) without updating limits, all outbound traffic is permanently blocked with no error at config time.Recommendation
Either require
outboundLimit > 0whenoutboundEnabled=TrueinisValidConfigEntry, or document thatoutboundLimit=0with rate limiting enabled permanently blocks outbound. -
L-26 Low BurnMint has no supply conservation Validation Pending
Description
executeBurnMintburns inputs and mints outputs independently with no assertion thatsum(inputs) == sum(outputs). Admin can callBurnMintFactory_BurnMintwith emptyinputHoldingCidsand non-emptyoutputsto mint tokens from nothing, or non-empty inputs with empty outputs to destroy tokens. This violates the cross-chain supply conservation invariant — admin can arbitrarily inflate or deflate total supply, which is a stronger primitive than any other admin capability (e.g.,RecoverFundspreserves supply).Recommendation
Add
assertMsg \$ sumInputs == sumOutputsinsideexecuteBurnMint, wheresumInputsis computed from fetched input holdings andsumOutputsfrom the outputs list. -
L-27 Low High scaleDecimals disables rate limiter Configuration Pending
Description
downscaleuses ceiling division:(amount - 1) / scaleFactor + 1. Config validation (RateLimiterConfigTypes.daml:101) allowsscaleDecimalsup to 18. WhenscaleDecimalsgreatly exceedslocalDecimals(e.g., 18 vs 6),scaleFactor = 10^18and all practical token amounts (up to ~10^15 for a 6-decimal token) map to 1 unit. A rate limiter withoutboundLimit=1000would allow 1000 transfers of 1 billion tokens each, making rate limiting meaningless.Recommendation
Validate
scaleDecimals <= localDecimalsinOftFactoryConfigensure clause. -
L-28 Low amuletContext steering wedges settlement Validation Pending
Description
IRequest_AcceptandIRequest_Rejecttake a caller-suppliedamuletContextat settlement time.validateContextWithExternalAmuletonly checks that the suppliedAmuletRules,OpenMiningRound, andExternalPartyAmuletRulesbelong to the expected DSO — it does not bind settlement to the canonical fee infrastructure. A handler can supply a different live same-DSOOpenMiningRoundwith highercreateFee, causingdistributeAmuletto fail withITR_InsufficientFunds. TheRequestremains live (wedged). A fix for config/state selection does not fix this unless it also bindsamuletContextitself.Recommendation
Store canonical fee-infra CIDs at request creation and require them at settlement, or derive the canonical live round internally instead of trusting settlement-time CIDs.
-
L-29 Low Transfer silently truncates amounts Validation Pending
Description
OftTransferOffer.ensureallowstransfer.amount > 0.0(fractional accepted).OftTransferRule(line 102) silently truncates viatruncate transfer.amount, so a transfer offer for 1.5 tokens delivers only 1.BurnMintImpl(line 36) correctly rejects fractional amounts withassertMsg errOftBurnMint_AmountNotWholeNumber. The same protocol has inconsistent validation for the same property — transfers silently lose precision while burn/mint correctly validates.Recommendation
Add
transfer.amount == intToDecimal (truncate transfer.amount)toOftTransferOfferensure clause. -
L-30 Low Malformed enforcedOptions brick sends Validation Pending
Description
The SetEnforcedOptions choice validates enforced options only as valid hex plus starts with 0003 (type-3 prefix). It does not validate the internal worker-option TLV structure within the type-3 envelope. An admin can store malformed type-3 enforced options that look valid at config-update time. The ensure clause accepts them and the config is written without error. Later, once cost assertions are enabled for the same destination endpoint ID, every lzSend call triggers assertOptionsCost, which decodes the combined options. The malformed worker option causes decodeExecutorOptions to abort with ERR_COST_ASSERTS_UNSUPPORTED_OPTION_TYPE. A syntactically accepted config silently bricks all sends for the affected endpoint. The failure only appears on live send traffic, not when the config is written.
Recommendation
Validate the full type-3 worker-option structure in SetEnforcedOptions and in the OftFactoryConfig ensure clause, not just hex shape and prefix. Run the same decodeExecutorOptions logic at config-write time so malformed entries are rejected immediately rather than deferring failure to the send path.
-
L-31 Low RequestFactory accepts foreign-gateway IOApp Validation Pending
Description
Request_Create fetches the caller-supplied IOApp and checks only that its instrumentId.admin is a signatory and matches the caller-supplied instrumentAdmin. It never checks that the IOApp actually belongs to the factory's handler — specifically, it does not verify IOAppView.oappGateway == RequestFactory.handler. Because of this, a handler-A RequestFactory accepts an IOApp whose own view says it belongs to gateway-B. Handler-A can then accept that request and create a PendingCallback carrying the foreign app's appUID. Impact: request creation is not bound to the handler's own app trust anchor. A foreign-gateway IOApp can be injected into another handler's live Request and PendingCallback flow. This breaks the intended isolation that each handler only processes requests for its own registered applications.
Recommendation
After fetching the IOApp view in Request_Create, add a check that the IOApp's oappGateway matches the RequestFactory's handler: require (view ioApp).oappGateway == handler. This ensures each handler can only create requests for IOApps that are registered under its own gateway.
-
L-32 Low Request has no timeout or sender cancel DoS Pending
Description
The Request template defines exactly two choices: IRequest_Accept and IRequest_Reject, both controlled exclusively by the handler party. The sender — who pays the fee — is a signatory but has zero choices to exercise. There is no expiry field, no timeout mechanism, no sender-cancel choice, and no admin override. If the handler party goes offline permanently (participant failure, key loss, operational shutdown), all pending Requests for that handler are permanently stuck. The fee amulets (owned by treasury, referenced by Request.amuletCid) are locked with no recovery mechanism. Every other contract in the protocol's 3-phase lifecycle has a fallback path for stuck states:
- PendingCallback: any party with visibility can call FinalizeRequest (controller is caller-supplied actor)
- OftTransferOffer: sender can call TransferInstruction_Withdraw at any time
- Governance: any member can call Governance_Expire after the proposal deadline passes Request is the only contract with zero fallback paths. The handler is the sole exit. In the DelegateLzReceive flow, the fee payer (the executor) is not even a signatory or observer on the Request. They pay via refundAddress but have no control if the handler abandons the Request.
Recommendation
Add a sender-controlled timeout choice to the IRequest interface, such as IRequest_Cancel with controller sender, gated by a time check (e.g., now > createdAt + timeoutPeriod). On cancellation, the fee amulet should be returned to the sender via the same distributeAmulet mechanism with empty payouts. Alternatively, add a createdAt and expiry field to the Request template and allow any signatory to cancel after expiry.
-
L-33 Low Unchecked RequestFactory Anchor Validation Pending
Description
OftFactoryConfig_SetRequestFactorylets the OFT admin install anyIRequestFactoryCID without fetching or validating it. UnlikeOftFactoryConfig_SetTransferRule, it does not verify any identity fields before rebinding the config.OftFactory.oApp_createRequestImpllater trustsconfig.requestFactoryCiddirectly and exercisesRequest_Createon whatever factory is currently stored there. Because of that, a same-handler siblingRequestFactorywith a differenttreasuryor differentpermanentObserverssilently becomes the lifecycle instance for all future requests created through that OFT. This means OFT config does not bind request creation to a canonical request factory. An admin-side wrong-instance swap can silently reroute fee escrow and request visibility to a different lifecycle contract without changing the OFT instance itself.Recommendation
Validate
newRequestFactoryCidbefore storing it. At minimum, fetch the target and require the expected handler and DSO to match the OFT's trust boundary; ideally bind a canonical request factory per OFT and reject sibling substitutions. -
L-34 Low Treasury fee blocks governance cleanup Access Control Pending
Description
M-03 reported that the leader-held proposal fee could freeze governance. The team added
treasury /= multisigto the ensure clause, correctly separating fee custody from decision authority. However,Governance_Expirestill depends on the treasury-ownedamuletCiddisclosure.Governance_Expireiscontroller submitter(any single member), but callsslashProposalFeewhich doesfetch p.amuletCidwhereamuletCidis owned bytreasury. In Canton's privacy model, the submitting participant must have disclosure of the amulet contract. Even though DAML authorization succeeds (the Governance contract hassignatory multisig, treasury), the exercising member's participant cannot submit the transaction without first obtaining the amulet disclosure from treasury's participant.Governance_Expireis documented as individually exercisable by any member, but in practice requires treasury cooperation to obtain the amulet disclosure. If the treasury party's participant is offline or uncooperative, no member can expire a stale proposal. The same dependency applies toGovernance_Reject,Governance_Accept, auto-slash inGovernance_Propose, andGovernance_SetMembers.Recommendation
Either make the fee amulet visible to all members at proposal creation time (e.g., add members as observers on the amulet or store a disclosure reference in the proposal) so any member can independently exercise
Governance_Expirewithout treasury cooperation, or update the documentation to reflect thatGovernance_Expirerequires treasury cooperation and is not unilaterally exercisable by any single member. -
L-35 Low Direct sender refunds revert below create fee Unexpected Behavior Pending
Description
distributeAmuletrefunds a positive remainder tosenderby callingtransferAmuletWithFeeRatiowithreceiverFeeRatio = 1.0. That makes the refund output pay its full output fee from the refunded amount itself. This is unsafe when the remainder is positive but smaller than the current amuletcreateFee. The underlying Splice transfer rules reject receiver-paid outputs unlessreceiverOutputFee < amount, while native sender change handles sub-fee dust by consuming it and returning no change. Consequently,Requestsettlement can fail on a small leftover amount even though the same dust could have been absorbed safely by the provider. The request is left live and must be retried or handled operationally.Recommendation
Do not attempt a direct sender refund unless the remainder can cover the receiver-paid output fee. If it cannot, skip the direct refund and leave the dust with the provider, matching Splice's native sender-change behavior.
-
L-36 Low DiscoveryRegistry stores one unbounded URL map Unexpected Behavior Pending
Description
DiscoveryRegistrykeeps everyAppUID -> URLentry in one sharedurlMap.SetUrlupdates that map by recreating the whole contract withTextMap.insert hash url urlMap. It does not limit how many entries the map may hold, and it does not limit the size ofurl. Consequently, successful registrations grow one shared contract forever. Every new writer must consume and recreate the same state object. In Canton, that is an operational scalability problem as the registry gets larger over time, update payloads get heavier and concurrent writers contend on the same singleton.Recommendation
If
DiscoveryRegistryremains a singleton, add a maximum URL length and a maximum number of entries. Consider also to stop rewriting one sharedTextMapand store each app's discovery record separately, for example behind a keyed per-app contract. -
I-01 Informational Fingerprint-only identity is not party-unique Unexpected Behavior Pending
Description
A
Partyis the ledger identity that owns contracts and authorizes actions. Atemplateis an on-ledger contract type and achoiceis the operation a party exercises on an existing contract.IAllowlistis a DAML interface that decides whether a givenPartymay use restricted token flows.Registryis the contract that records how inbound cross-chain transfers should be resolved to a local Canton party.Oftis the token holding itself and itsownerfield is aParty. That means this issue is not about labels or metadata. If the protocol resolves the wrongParty, it creates or authorizes the wrong legal owner on the ledger. The relevant Canton detail is that a full party ID is text of the form<hint>::1220<64 hex chars>. The left side is a human-readable identifier, often called the hint. The right side is the cryptographic namespace-sized suffix that this codebase calls a fingerprint. The helperextractFingerprintdiscards the hint and keeps only that 64-character suffix. In other words, the protocol takes a full DAMLParty, throws away the human-readable identifier and then treats the remaining suffix as if it were a unique address for that party. That assumption conflicts with the official Canton identity model. Canton documentation describes a party identifier as an identifier within a namespace in the topology docs (https://docs.digitalasset.com/overview/3.4/explanations/canton/topology.html) and separately notes that several unique identifiers can share the same namespace in the getting started docs (https://docs.digitalasset.com/operate/3.4/tutorials/getting_started.html). The DAML SDK documentation also advises applications to treat party IDs as opaque identifiers rather than parsing them and making format assumptions. The rest of the protocol is built around that reduced value.IAllowliststores whitelist and blacklist entries as extracted fingerprints, so theIsAllowlistedcheck compares only the suffix extracted from each party ID. In DAML terms,IsAllowlistedis anonconsuming choice, which means it performs a permission check without rewriting the contract.MessageCodecalso serializes the cross-chain recipient as a 32-byte fingerprint rather than as a full CantonParty.Registry.SetPartyIdis a state-changing choice controlled by the registering user and it stores a singlefingerprint -> hintmapping.LzReceivelater decodes the incoming message, reads the recipient fingerprint, combines it with the stored hint, reconstructs a full party ID ashint::1220<fingerprint>and mints a newOftholding to that reconstructedParty. This design is only safe if the extracted fingerprint uniquely identifies exactly one party. The official Canton documentation shows that this is not true, because a party is identified by the full pair of identifier and namespace, not by the namespace alone. Once the protocol has discarded the identifier, it can no longer distinguish sibling parties that share the same namespace. An allowlist entry intended for one party becomes an allowlist entry for every party that shares that suffix. A registry entry intended to help resolve one party becomes a single mutable slot for that entire suffix. The sibling party that most recently updates the registry controls which full party ID will be reconstructed during inbound settlement. For example, imagine a bank that operates bothTreasuryDesk::NandSandboxTrader::N. The bank wants only the treasury desk to use a restricted OFT, so the treasury party is added through the allowlist admin flow. However,IAllowliststores onlyN, not the full party ID, soSandboxTrader::Nalso passesIsAllowlisted. Later, the sandbox trader exercisesRegistry.SetPartyId, which is the normal DAML mechanism for a party to register or refresh its own receive hint and overwrites theN -> hintmapping withSandboxTrader. When a remote chain sends tokens to the treasury's cross-chain address, the message still contains onlyN.LzReceivethen reconstructsSandboxTrader::Nfrom the latest registry hint and creates the newOftholding withowner = SandboxTrader::N. The impact is direct ledger misdelivery and broken party-scoped access control: the wrong DAML party becomes token owner and controls meant for one party silently expand to all parties sharing that suffix.Recommendation
Stop using the extracted fingerprint alone as the canonical address anywhere the protocol needs party-specific behavior. The safest fix is to carry and validate a party-unique identifier end to end in the allowlist, registry and cross-chain receive path, so that resolution never depends on a mutable
fingerprint -> hintslot shared by sibling parties. If the design intentionally wants namespace-scoped identity, then that constraint must be enforced operationally by allowing only one party per namespace and by documenting clearly that allowlists and receive resolution apply to namespaces, not to individual DAML parties. -
I-02 Informational FinalizeRequest trusts settlement-time context Trust Assumptions Acknowledged
Description
PendingCallback_FinalizeRequestdoes not settle against a fully bound request. Instead, it accepts anexecuteContextat finalization time and forwards that runtime payload into the OFT callback path. The interface intentionally allows any visible party to finalize, so the first actor who sees the pending callback can also choose the context that settlement will use. The dispatcher then readsconfigCid,rateLimiterStateCid, and, for inbound receives,receiverHintdirectly from that finalizer-supplied context. That means important settlement inputs are still live when the callback is finalized. The code rejects obviously unrelated instruments, but it does not prove that the chosen config or rate-limiter state is the exact instance that was supposed to govern the request. If multiple compatible instances exist, the finalizer can pick the one it prefers. On the receive side the problem is sharper:LzReceivereconstructs the recipient party from the message fingerprint plus thereceiverHintsupplied during finalization, instead of resolving the recipient from authoritative on-ledger state at the moment of settlement. For example, imagine an inbound transfer is waiting inPendingCallbackand both the intended recipient and another visible party can see that callback. If the other party finalizes first, it can supply a differentreceiverHintfor the same namespace or choose a different compatible config and rate-limiter state. If that hint points to an existing sibling party under the same namespace, the transfer settles to that sibling. If the hint is simply wrong, finalization fails and successful settlement depends on off-chain retry behavior. In both cases, the outcome is being shaped at settlement time by whoever finalized first, not by data that was fixed when the request was accepted. A callback settlement path should not depend on mutable runtime context like this. By the time finalization starts, the recipient and the security-relevant policy objects should already be fixed.Recommendation
Bind the settlement context before the callback becomes finalizable. The pending callback, or a signed payload attached to it, should contain the exact config, state, and receive identity that were approved earlier, and finalization should verify those bindings rather than trust fresh
executeContextfields. On the receive path in particular, stop takingreceiverHintfrom off-chain context and resolve the final local party from authoritative on-ledger state during settlement. -
I-03 Informational Settlement can use caller-chosen live instances Trust Assumptions Acknowledged
Description
Some settlement paths take config or state contract ids directly from the caller. The code then checks only that the chosen contract matches the same admin or instrument. It does not prove that this is the one live instance the protocol actually meant to use. That matters if duplicate compatible instances exist on-ledger. In that case, the caller can steer execution toward the instance with the policy they want. There is no canonical lookup, singleton guard, or parent-held reference that pins settlement to one authoritative config or one authoritative rate-limiter state. For example, imagine an operator leaves two live
OftFactoryConfigcontracts behind after a migration. One keeps transfers paused and enforces the stricter allowlist. The other is older and more permissive. A user who creates the request, or later finalizes the callback, can point the flow at the permissive config and still pass validation because the contract is for the same admin and instrument. The transaction then settles under a valid but unintended policy. This turns duplicate-instance mistakes into real policy bypasses. Fees, pause state, allowlist checks, peer settings, and rate-limit behavior can all end up coming from the caller's chosen instance instead of from the intended live one.Recommendation
Do not let callers choose security-relevant config or state instances directly. Settlement should resolve those references from one canonical on-ledger source, such as a parent-held pointer or a registry that guarantees a single active instance. The current same-admin and same-instrument checks can stay, but only as a sanity check on a contract the protocol already selected for itself.
-
I-04 Informational State-commitment chain is not canonical Unexpected Behavior Acknowledged
Description
The code records a predecessor pointer in
prevStateCommitmentCid, but it does not enforce that there is only one live head.CreateStateCommitmentandCommitBatchare both nonconsuming choices onCommitterandcreateStateCommitmentImplonly archives the predecessor if the caller explicitly supplies one. If the caller passesNone, the newStateCommitmentis created and every existing one stays live. That makesprevStateCommitmentCidjust a backlink, not a canonical chain rule. The ledger can end up with multiple activeStateCommitmentcontracts for the same owner at the same time and nothing on-ledger tells you which one is the current head. For example, an operator could use the shippedCantonChainClient.buildCommitBatchhelper twice in a row. That helper currently sendsprevStateCommitmentCid = undefined. The first batch creates one active state commitment. The second batch creates another one without archiving the first. Now both commitments are live, both look current, and any off-ledger component that expects a single canonical root has to guess which one to follow. This turns the state-commitment chain into an ambiguous set of live roots instead of one enforced history.Recommendation
The protocol should track one explicit chain head on-ledger and require every new commitment to advance that head.
Noneshould only be valid for genesis. If a new commitment is created, the previous head should be consumed or otherwise proven to be the unique current one in the same transaction. The client helper should also stop defaultingprevStateCommitmentCidtoundefinedand fetch the current head before building a batch. -
I-05 Informational Blacklist-after-creation bricks payouts Logical Error Acknowledged
Description
lzSendvalidates sender and receiver against the allowlist at offer creation time. ButILockedPayout_ForceAcceptre-validates viafinalizeTransfer→assertAllowlistedat execution time. If the admin blacklists the receiver between creation and callback finalization,force-acceptfails withERR_OFT_NOT_ALLOWLISTED. Because DAML transactions are atomic, the entire accept callback reverts — including the burn. No partial success is possible. The reject path (forceRejectImpl) does NOT check the allowlist, so it always succeeds as recovery. Net impact: a post-creation blacklist forces the reject path (sender gets tokens back), not permanent fund loss. But the accept callback fails unnecessarily — the admin is already authorizing the transfer viaforce-accept, so re-checking the allowlist adds no security value.Recommendation
Skip allowlist checks on
ILockedPayout_ForceAccept. The admin is already authorizing the transfer by exercising the force choice. Alternatively, snapshot the allowlist decision at offer creation time soforce-acceptis not affected by later policy changes. -
I-06 Informational stateRoot accepts any hex length Validation Pending
Description
StateCommitment.ensureonly validatesisValidHex stateRoot. It does not enforce a fixed length. A 2-char or 128-char hex string both pass validation. The codebase already hasisBytes32Hex(used for peer addresses inOftFactoryConfig.ensure) but does not apply it tostateRoot. If downstream verifiers or bridge proofs assume a 32-byte (64-char) root, this creates an integration mismatch. Impact is limited because only the committer (owner, the trusted sequencer) can createStateCommitmentcontracts. This is a defense-in-depth gap, not a direct exploit.Recommendation
Add
isBytes32Hex stateRootto theensureclause (orT.length (strip0x stateRoot) == 64) if bytes32 is the intended format. The existingisBytes32Hexhelper fromDA.Crypto.Textcan be reused directly. -
I-07 Informational Registry SetPartyId accepts mismatched hints Validation Acknowledged
Description
SetPartyIdaccepts anyTextas thehintparameter (line 95) without validating it matches the caller's actual party hint. The fingerprint is correctly extracted from the caller (line 108), but the hint is free-form user input. If a caller passes a hint that differs from their real party prefix — whether by typo, mistake, or intent — the Registry stores the wrong mapping. Later,GetPartyIdreconstructs the party ID ashint <> "::1220" <> fingerprint. With a mismatched hint, this produces a different party than the caller. The fee is charged, the wrong hint is stored permanently, and all future lookups for this fingerprint silently return the wrong party. If an off-chain executor uses the Registry to resolve thereceiverHintforlzReceive, the reconstructed party would differ from the intended receiver, and tokens would be minted to the wrong party. Example: Caller isAlice::1220abcd.... They callSetPartyIdwithhint = "WrongHint". GetPartyId now reconstructsWrongHint::1220abcd...— a different party. No error, no revert.Recommendation
Derive the hint from the caller's party ID instead of accepting it as user input. The pattern already exists in the codebase (
getPartyPrefixin TestUtils.daml):partyToText caller→ split on"::"→ take the first part. Additionally, validate that the extracted hint is non-empty (length > 0). This eliminates the mismatch entirely.choice SetPartyId : ContractId Registry with caller : Party amuletCid : ContractId Amulet amuletContext : ChoiceContext controller caller do let partyStr = partyToText caller let parts = T.splitOn "::" partyStr let hint = case parts of (prefix :: _) | prefix /= "" -> prefix _ -> error "ERR_REGISTRY_EMPTY_HINT" let fingerprint = extractFingerprint caller ... -
I-08 Informational External payouts remain sender-cancellable Unexpected Behavior Acknowledged
Description
distributeAmuletdoes not actually finish an external payout. If the recipient does not have a matchingTransferPreapproval, it only creates anAmuletTransferInstructionbacked by a locked amulet and then returns. The calling flow still behaves as if the money was paid:Requestis archived, governance slashing moves on andExecutor.NativeDropreports success. That instruction is still pending and the sender side can later unwind it through the normalTransferInstruction_Withdrawand lock-expiry path. So these payouts are not final when the parent contract records them as done. BecausedistributeAmuletdoes not persist the created instruction ids or keep the parent workflow open, the protocol can record a completed payout even though the transfer can still be reversed later. For example, lets imagine a rejected governance proposal. The proposal fee is supposed to be split among committee members, but if the leader is the payout provider and some members are external recipients, those members do not receive final amulets. They only get pending transfer instructions. The protocol still treats the slash as paid out, yet the leader remains on the sender side of a flow that can later be unwound through the expiry path. In practice, the slash distribution becomes a soft promise instead of a final transfer. The same problem affects request payouts and native drops whenever an external recipient is expected to rely on immediate delivery.Recommendation
Do not mark an external payout as complete unless it has actually settled. The cleanest fix is to require
TransferPreapprovalfor payouts that must be final in the same transaction. If the two-step transfer model has to stay,distributeAmuletshould return and persist the created instruction ids and the parent flow should stay pending until those instructions are accepted or otherwise reach a state that cannot be cancelled by the sender side anymore. -
I-09 Informational DEFAULT_ADMIN_ROLE exceeds documented RBAC Warning Pending
Description
The docs describe separate operational roles like
oappAdminandallowlistAdmin, which makes it sound like peer setup, observer updates and allowlist mode can be delegated separately. The code does not actually enforce that split. Those actions all sit behindDEFAULT_ADMIN_ROLEand the same role can also changefeeDeposit, create arbitrary OApp requests and callRecoverFunds. So in practiceDEFAULT_ADMIN_ROLEis not a narrow config role. It is a broad operator role with both configuration and economic power. A team can hand out what they think is routine OApp admin access and accidentally also give that party the ability to redirect fee flows or recover funds from non-allowlisted holders.Recommendation
Split
DEFAULT_ADMIN_ROLEinto narrower roles that match the documented permission model. At minimum, keep OApp configuration, allowlist mode, fee recipient changes, request creation and fund recovery separate. If the broad scope is intentional, the docs and runbooks should say clearly thatDEFAULT_ADMIN_ROLEis a super-admin role and should not be delegated as if it were justoappAdmin. -
I-10 Informational SEND_AND_CALL compose payload is ignored Unexpected Behavior Acknowledged
Description
The send side treats a non-empty
composeMsgas a realSEND_AND_CALLmessage. It switches message type, encodes the extra payload and includes compose-related options. But on receive,LzReceiveonly decodes the message, resolves the recipient, applies rate limiting and mints tokens. The compose payload is never used. So the protocol accepts a message that looks like "send tokens and then run follow-up logic", but settles it as a plain token transfer. It does not fail closed. The transfer can succeed and the compose leg is simply dropped. For example, an integrator can send tokens to a remote chain expecting the compose payload to deposit them into a vault or trigger some follow-up action on receipt. The receive side mints the tokens to the user, but the compose action never runs. From the sender's point of view theSEND_AND_CALLtransfer succeeded, but the destination-side workflow was silently skipped.Recommendation
Reject any non-empty
composeMsguntil the receive side actually supports it. If compose support is intended, the receive path needs to carry the decoded payload through settlement and dispatch the follow-up action instead of silently minting and stopping there. -
I-11 Informational Admin paths bypass holding lock invariants Validation Acknowledged
Description
The lock model (
Lockwithlockerslist) is meant to prevent holdings from being consumed without the lock holder's consent. Three admin paths bypass this invariant in different ways:RecoverFunds(OftFactory.daml:378): Archives locked holdings without any lock check. Callable by admin orDEFAULT_ADMIN_ROLEholder.BurnMint(BurnMintImpl.daml:30): Archives locked holdings without any lock check. Callable by admin.TransferRule_ExecuteAsAdmin(OftTransferRule.daml:48): CallsapplyHoldingAction(UnlockMergeSplitTransfer)which checks that the holding IS locked (mustBeLocked) but does not verify the caller is one of thelockers. The admin can unlock and transfer a holding locked by a different lock holder. Every other holding-consuming operation checks lock state viamustBeLocked/mustBeUnlocked. These three admin paths are the only ones that skip or weaken the check. Impact (paths 1-2): If a holding is locked by a pendinglzSendcallback,RecoverFundsorBurnMintcan archive it. The holding CID inPendingCallback.callbackContextbecomes stale, so neither accept nor reject finalization succeeds. ThePendingCallbackis permanently orphaned and the sender loses tokens with no recovery path. Impact (path 3):TransferRule_ExecuteAsAdminis the path exercised byILockedPayout_ForceAcceptduring callback settlement. The lock is supposed to ensure only the designated lock holder can settle the payout. ButapplyHoldingActiononly callsmustBeLocked— it does not verify the caller is one of thelockers. In the current single-admin-per-instrument design this is benign, but if the lock model is ever extended to support third-party lock holders (e.g., escrow agents, DvP coordinators), any admin could settle payouts locked by a different party.
Recommendation
RecoverFundsandBurnMint(paths 1-2): AddmustBeUnlocked oft.lockbeforearchive oftCid. This prevents these functions from consuming holdings locked by pending callbacks.TransferRule_ExecuteAsAdmin(path 3): Verify that the exercising admin is one of the currentlockersbefore proceeding withUnlockMergeSplitTransfer. If the protocol needs a special admin-settleable lock type, mark it explicitly and restrict the admin path to that lock type only.
-
I-12 Informational Batch commitments leak request linkage Warning Pending
Description
Committer.CommitterImpl.commitBatchImplfetches each request in the batch, collects every request'sobservers, unions them intoallViewersand then creates a singleStateCommitmentthat stores the fullrequestCidsarray. BecauseStateCommitmentis visible to all of thoseviewers, any observer of one request becomes able to see the contract identifiers of every other request that was included in the same batch and to learn that they were processed together. This leaks cross-request linkage that did not exist at the individual request layer. An observer that was only meant to track request A can infer the existence of requests B, C and D and correlate them as part of one settlement batch. The leak does not expose the full payloads of the other requests, but it does reveal metadata that Canton normally tries to minimize across unrelated visibility domains. In the standard OFT send and receive flows the request observers are usually internal, so the practical impact depends on whether a deployment batches requests that have external observers. Where that does happen, batching creates an avoidable privacy downgrade.Recommendation
Do not expose the raw full-batch
requestCidslist to the union of all request observers. If observers from different visibility domains need proof that their request was committed, publish per-request inclusion artifacts instead, or store only a commitment such as a batch hash or Merkle root and provide request-specific inclusion proofs off-ledger. If the current full-list design must remain, restrict batching so that only requests with the same intended visibility set are committed together. -
I-13 Informational State commitments are not isolated per committer Warning Pending
Description
createStateCommitmentImplaccepts any visibleprevStateCommitmentCid, archives it and creates a successor without checking whichCommitteroriginally created that predecessor.StateCommitmentitself does not record a committer identity either, so commitments are keyed only by owner. That means two committers owned by the same party do not have isolated histories. If one owner deployscommitterAandcommitterB,committerBcan take a live predecessor that was created bycommitterA, archive it and continue the chain as if it belonged to the same lineage. This is best understood as a design constraint that is not enforced explicitly. The current implementation behaves like "one active commitment chain per owner", not "one chain per committer". That can surprise upgrades, redeployments, or multi-committer setups if operators assume eachCommitterinstance has its own independent head. The off-ledger SDK follows the same owner-scoped model because it resolves the latest commitment by owner rather than by committer identity. That lets two committers owned by the same party operate on the same chain head. If one owner deployscommitterAandcommitterB,committerBcan take a live predecessor that was created bycommitterA, archive it and continue the chain as if it had produced that earlier state itself. This breaks committer isolation. A redeployed or misconfigured committer can take over another committer's head and the off-ledger SDK makes the problem easier to hit because it also discovers the latest commitment by owner rather than by committer identity.Recommendation
If one active chain per owner is the intended design, document that constraint clearly and avoid running multiple committers for the same owner. If per-committer isolation is intended, bind every
StateCommitmentto a stable chain identity and reject predecessors from any other chain. The SDK should resolve the latest commitment by that same identity instead of scanning every active commitment for the owner. -
I-14 Informational DiscoveryRegistry accepts foreign gateway IOApps Validation Pending
Description
SetUrl validates that the caller is the IOApp's instrumentId.admin and a signatory, but never checks that
oappGateway == handler. A handler-A registry can store discovery URLs forIOAppsbelonging to a different gateway. No on-chain economic impact — off-chain trust-anchor integrity issue only.Recommendation
Add
oappGateway == handlercheck in SetUrl after fetching the IOApp view. -
I-15 Informational Uppercase 0X options fail TS bytes schema Validation Pending
Description
When no enforced options are configured for a route,
combineOptionsreturnsextraOptionsunchanged.prepareSendforwards that text into bothOftFactory_BuildQuoteand the outbound request path. That means a caller can supplyextraOptions = "0X000301..."and DAML will treat it as acceptable outbound data. The offchain side does not accept the same syntax. Thebytescodec used by the validator container only allows lowercase0xor no prefix at all. Its schema is/^(0x)?[a-fA-F0-9]*\$/, so0X...is rejected before any byte decoding happens. The result is a boundary mismatch: quote/request creation succeeds on-ledger, but the validator-side send path rejects the payload later as malformed input.Recommendation
Canonicalize raw option hex before it leaves DAML. The smallest safe fix is to strip both
0xand0Xat the send boundary and emit one canonical form or reject uppercase-prefixed input explicitly in send-param validation. -
I-16 Informational RequestFactory allows handler==treasury Access Control Pending
Description
RequestFactoryhassignatory handler, treasurywith noensureclause enforcinghandler /= treasury. When both are the same party, a single party controls request acceptance AND fee custody — it can accept requests and route all fees to itself with no second-party oversight.OftTransferOfferenforcessender /= receiver(line 54), butRequestFactoryomits this check.Recommendation
Add
ensure handler /= treasurytoRequestFactory. -
I-17 Informational Governance member list validation gaps Validation Pending
Description
The Governance template ensure clause only checks
not (null members) && minFee > 0.0. Three validations are missing:- No duplicate members check:
members = [alice, alice, alice]is accepted. The apparent 3-member committee is actually 1-member. TheslashProposalFeehelper deduplicates internally viadedup \$ sort \$ filter, so fee distribution sees only 1 unique member while observers see 3 entries. This misrepresents committee diversity. - No multisig-not-in-members check: the multisig party can appear in the members list. Since multisig is both a member (passes the
elem memberscheck in Governance_Propose) and the controller for Governance_Accept, a single party can propose and accept its own governance proposals without any other member's involvement. - Single-member slash silently burns fee: when the proposer is the only member,
filter (/= proposer) [proposer]yields an empty list,divisor = 0, and theif divisor > 0branch is skipped. The fee stays in the treasury with no distribution and no error. The proposer loses their fee and no one receives it.
Recommendation
Add validation to the Governance ensure clause:
length (dedup members) == length membersto reject duplicates, andmultisig \notElem` members` to prevent the multisig party from appearing in the member list. - No duplicate members check:
-
I-18 Informational SetTransferRule accepts unchecked ITransferRule Validation Pending
Description
OftFactoryConfig_SetTransferRulelets the OFT admin install anyITransferRulewhose view reports the sameinstrumentId. It does not verify that the rule is the intended canonical transfer-settlement backend for the OFT.TransferFactory_Transferlater storesconfig.transferRuleCidon the createdOftTransferOffer, andTransferInstruction_Acceptthen trusts that stored rule to execute settlement. Because of that, an admin-side wrong-instance or custom-rule swap can redirect accepted transfers away from the intended receiver while still satisfying the current setter checks. This is an admin-controlled trust-anchor bug, not a user-triggerable path. The impact is that OFT transfer settlement is not bound to a canonical rule implementation.Recommendation
Validate
newTransferRuleCidagainst a stronger trust boundary thaninstrumentIdalone. At minimum, require the expected admin/signatory and canonical implementation family; ideally bind one canonical transfer rule per OFT and reject custom or sibling substitutions. -
I-19 Informational MINTER_ROLE and BURNER_ROLE are never enforced Trust Assumptions Pending
Description
validRoleNamesacceptsMINTER_ROLEandBURNER_ROLE, so the admin can grant and revoke those roles throughIAccessControl. The problem is that no production choice checks either role.OftFactoryConfiguses the other role constants throughout its delegated admin choices, but there is no correspondingMINTER_ROLEorBURNER_ROLEauthorization gate in that file. The burn/mint implementation also runs without consultingroles. Consequently, operators can assign these roles and believe mint or burn authority has been delegated when it has not. This is especially misleading because the test suite grants both roles indaml/tests/daml/Oft/RbacTest.daml:182-206, and the TypeScript config serializer also treats both names as valid input inscripts/src/contracts/oft-adapter/oft-factory-config.ts:196-219. Granting either role also makes the assignee an observer of the full config throughroleParties, so the dead roles widen disclosure without enabling any real capability.Recommendation
Remove
MINTER_ROLEandBURNER_ROLEfrom the accepted role namespace until they are wired to concrete authorization checks. If delegated mint or burn authority is intended, add explicit gates for those roles in the relevant production choices. -
I-20 Informational DEFAULT_ADMIN_ROLE need separate disclosure Trust Assumptions Pending
Description
OftFactorytrustsDEFAULT_ADMIN_ROLEholders in several implementation checks, includingoApp_createRequestImpl,oApp_setPeerImpl, andOftFactory_RecoverFunds. However, the template only declaresoappGatewayas an observer. Role assignment happens onOftFactoryConfig, where role holders become observers of the config contract, not of the factory contract or the holdings they may need to fetch. Consequently, grantingDEFAULT_ADMIN_ROLEdoes not by itself let the grantee execute every delegated admin action. The grantee still needs a stakeholder to discloseOftFactoryand, for some operations, related holdings. The tests make this explicit.testRoleHolderCanRecoverFundsfirst obtains disclosures for the factory, config, and holding before the role holder can exerciseOftFactory_RecoverFundsindaml/tests/daml/Oft/RbacTest.daml:827-839. If operational runbooks assume that role grant alone is enough, time-sensitive actions can fail until a separate disclosure step happens.Recommendation
Treat visibility as part of role assignment. Either make the relevant delegated operators observers of
OftFactoryand any other contracts they must fetch, or document and automate the disclosure workflow wheneverDEFAULT_ADMIN_ROLEis granted.
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.