Guardian's review of Canton OFT for LayerZero, published February 2026. The report records 58 findings, including 2 critical and 5 high.
- Published
- Review window
- January 19 to 28, 2026
- Language
- Daml
- Chains
- Ethereum, Solana, Stellar, Canton
- Sector
- Cross-chain
- 2 Critical
- 5 High
- 15 Medium
- 24 Low
- 12 Informational
Findings 58
-
C-01 Critical Message encoding length not enforced Logical Error Acknowledged
Description
encodeMessagebuilds the OFT payload by paddingsendParam.toto 64 hex chars and then appendingamountSDand optional fields. The hex validation only checks characters and strips 0x, andpadLeftdoes not truncate, so a caller can supply a to value longer than 32 bytes. The encoded message then places the overflow bytes before the amount field. The receiver decoder uses fixed offsets forsendToandamountSDand only checks a minimum length, so it will readamountSDfrom those overflow bytes rather than from the intended amount field. This is validated against the endpoint TypeScript decoder, which readsamountSDat fixed offsets and only enforces a minimum message length. The decoder implementation is inpackages/endpoint/src/oapp-common/oft-message-codec.tsand the minimum length check is inpackages/endpoint/src/oapp-common/oft-receiver.ts. An attacker can therefore control the decoded amount independent of the amount burned or locked on the source, which breaks cross-chain accounting.Impact: Cross-chain mint amounts can be attacker-controlled and exceed the locked amount, leading to inflation or theft on the destination chain.
Recommendation
Enforce canonical length and even-length hex for
sendParam.toafter stripping 0x, rejecting values longer than 64 hex chars and odd-length inputs. If 20-byte addresses are supported, explicitly left-pad to 32 bytes before encoding. ValidatecomposeMsgas hex bytes or remove it from the on-ledger message encoding and add strict length checks on the receiver decoder so it requires exact lengths for the non-compose and compose layouts. -
C-02 Critical
LzSendRequest_Createcan be called manually Access Control AcknowledgedDescription
The
LzSendRequest_Createchoice allows users to bypass critical validation by calling it directly instead of going through the intendedOftFactory.lzSendflow.The choice has
controller sender, issuer, meaning both parties must authorize. However, an attacker can setissuer = sender(themselves), satisfying the controller requirement without actually being the token issuer. The implementation does not validate that theissuerparameter matches the actual token issuer (instrumentId.admin).lzSendRequest_CreateImpl ILzRequestFactory.LzSendRequest_Create{..} = do validateFee feeCid sender minFee minLockDuration create LzSendRequest.LzSendRequest with sender issuer -- Can be set to sender, bypassing authorization lzMessage -- Can be arbitrary, bypassing encoding validation -- ...The normal flow calls:
MessageEncoding.encodeMessagewhich:- Validates hex format of destination address (
assertValidHex) - Converts amount to shared decimals
- Properly formats the message according to LayerZero protocol
- Validates hex format of destination address (
OftFactory.lzSendcalculates and pays issuer fees:- Calculates fee:
oftFeeAmount = (amountLD * issuerFeeBps) / bpsDenominator - Creates payout holdings for the issuer
- Creates transfer offers for the issuer
- Passes these as
payoutCidstoLzSendRequest_Create
- Calculates fee:
By calling
LzSendRequest_Createdirectly, attackers can passpayoutCids = [](empty), completely bypassing issuer fee payments, and can craft arbitrarylzMessagevalues that skip all this validation. This allows an attacker to create an encoded message claiming a significant amount of tokens, craft thelzMessagethemselves, and cause minting on the destination chain without actually locking the corresponding tokens on Canton.Exploitation Scenario
Actors
- LegitIssuer: The real token admin (instrumentId.admin), e.g., "USDC on Canton".
- Attacker: Owns some USDC holdings.
- FakeIssuer: A party controlled by the attacker (could be another wallet identity).
- lzEndpoint: The relayer / operator that watches LzSendRequest and mints/burns on the destination chain.
Preconditions
- LzRequestFactory is disclosed to the user (required for normal lzSend flow).
- The attacker can sign as sender and issuer.
- They set issuer = FakeIssuer (which they control), so they can sign both.
———
Step‑by‑Step Exploit
- Attacker owns a real holding of the legitimate token:
- oftHoldingCid corresponds to instrumentId.admin = LegitIssuer
- Example: USDC holding owned by attacker.
- Attacker creates a request directly (bypassing OftFactory):
- Calls LzSendRequest_Create on the request factory.
- Supplies:
- sender = Attacker
- issuer = FakeIssuer (attacker‑controlled)
- oftHoldingCid = USDC holding (admin is LegitIssuer)
- valid feeCid
- This succeeds because the factory never checks that issuer == holding.instrumentId.admin.
- Ledger now contains a request that claims:
- issuer = FakeIssuer
- but the locked holding is actually a LegitIssuer asset.
- Off‑chain relayer reads the request and uses issuer as the routing key:
- This is common: issuer or admin is used to pick the destination token contract, fee schedule, allowlists, or compliance rules.
- The relayer now believes this is a FakeIssuer token.
- Bad outcomes happen depending on off‑chain logic:
- Wrong asset minted on the destination chain (mismatch between real token and issuer metadata).
- Compliance / allowlist bypass: relayer may only enforce issuer‑based constraints.
- DoS / funds stuck: relayer might refuse to process if issuer isn't recognized, leaving user funds locked or burned.
- Accounting errors: issuer-based fee splitting or limits are applied incorrectly.
Additional Impact Example (Allocation Escrow Bypass)
This is a distinct sink‑validation risk that becomes reachable when arbitrary
oftHoldingCidcan be supplied. Consider a DvP allocation: Alice allocates to Carol, and Bob is the executor. The allocation contract storeslockedHoldingCid, so Bob can see the escrow CID. Bob then creates a manualLzSendRequestreferencing Alice’s allocation escrow asoftHoldingCid. When the endpoint accepts, the callback burns/unlocks that allocation escrow even though it was not created for LzSend, bypassing the allocation settlement rules.POC for this example: https://gist.github.com/GuardianAudits/c85fa03962d1d126f3e2b057f33a5b00
Recommendation
Consider adding validation to ensure the
issuerparameter matches the actual token issuer. Additionally, bind callback consumption to the specificLzSendRequestand validate thatoftHoldingCid(andpayoutCids) were created for that request: enforce LzSend lock context, expected lockers (admin), and sender/instrument matching before burn/unlock. -
H-01 High Execute deadlines not enforced Unexpected Behavior Acknowledged
Description
The transfer and allocation flows do not enforce execution time windows at the point of acceptance or settlement. Transfers only validate time ordering at creation, but the accept path executes without checking that the current ledger time is before
executeBefore. Allocations only enforcerequestedAt < allocateBefore < settleBeforeat creation, but theexecuteTransferpath does not checknow <= settleBefore. If callers treat these fields as enforceable deadlines, a receiver can accept a transfer after itsexecuteBeforeor an executor can settle a DvP leg aftersettleBefore, which violates sender intent and higher level scheduling or compliance assumptions.Impact: late acceptance or settlement can be exploited opportunistically after market moves or used to bypass time based controls expected by application logic. For example:
- 15:55 Alice initiates a 1,000,000 USDC transfer to Bob with executeBefore = 16:00 as part of an OTC swap and expects it to expire if not accepted by the cutoff.
- 16:05 USDT depegs to $0.97; Bob now wants the USDC only after the move.
- Bob accepts at 16:10; transferInstruction_acceptImpl in daml/Layerzero/Oft/daml/Transfer/OftTransferOffer.daml doesn’t check the deadline, so the ledger still completes the transfer.
- Outcome: Bob gets a free option (accept only when favorable), Alice is forced into an off‑market settlement and time‑based compliance controls are bypassed.
Recommendation
Add ledger time checks in the accept and execute paths. In the transfer flow, enforce
now <= transfer.executeBeforeand optionallytransfer.requestedAt <= nowbefore executing the rule, either in the offer accept implementation or inside the transfer rule. In the allocation flow, enforcenow <= allocation.settlement.settleBeforeinallocation_executeTransferImpl. Decide desired semantics on expiry, such as reject with a specific error or auto fail and unlock the holding, and implement consistently. -
H-02 High Single Manual Payout Action Locks The Rest DoS Acknowledged
Description
Issuer can always exercise
ILockedPayout_ForceAcceptandForceRejectdirectly (e.g., teststestLockHolderCanForceAccept/Rejectalready do this), and nothing prevents early settlement of any payout offer.LzSendRequeststores the entirepayoutCids list when the send is created, and the endpoint later hands that list toOftFactoryduring Accept/Reject/Expire. The endpoint cannot remove or replace entries even if they’re no longer valid.In each callback OftFactory loops
forA_ payoutCids (exercise … ILockedPayout_Force*)without checking whether the payout contract still exists. If the issuer already force-settled any entry, that exercise fails due to contract being archived.Because the callbacks are atomic, the first archived payout aborts the entire flow: the cross-chain sending holding never burns/unlocks, remaining payouts never settle, and even expiry refunds get stuck.
Until the code tolerates missing CIDs (or forbids manual exercises), a single manual payout action by the issuer can block every other payout and the overall request.
Recommendation
Wrap the
ILockedPayout_Force*loop in a safe lookup so stale CIDs are skipped instead of aborting the entire cross-chain flow or block manual settlements. -
H-03 High Stale OftFactory in LzSendRequest, causes DoS DoS Acknowledged
Description
In Daml, when a contract is updated using a consuming choice that returns a new
ContractId, the original contract is archived and a new contract with a differentContractIdis created. TheLzSendRequesttemplate stores aContractId IOAppreference to the factory, but this reference becomes invalid if the factory contract is updated.When the
OftFactorycontract is updated via admin choices (e.g.,SetIssuerFeeBps,SetRequestFactory,AddToBlacklist, etc.):-- | Update the issuer fee in basis points choice SetIssuerFeeBps : ContractId OftFactory with newIssuerFeeBps : Int controller instrumentId.admin do assertMsg "ERR_INVALID_FEE_BPS" $ newIssuerFeeBps >= 0 && newIssuerFeeBps <= 10000 create this with issuerFeeBps = newIssuerFeeBpsThis choice archives the old factory and creates a new one with the updated fee.
The problem is that if a
LzSendRequestis pending, was initiated before the update, the storedContractIdin the request becomes stale:-- Create the LzSendRequest contract create LzSendRequest.LzSendRequest with sender issuer lzEndpoint = owner oftHoldingCid dstEid lzMessage extraOptions feeCid hasCompose composeMsg expirableAfter = lzSendRequestExpirableAfter oftFactoryCid payoutCidsThis causes callback exercises to fail because they attempt to exercise choices (callbacks) on an archived contract:
-- Callback to OftFactory (via OApp interface) to burn the holding and force-accept payouts exercise oftFactoryCid OApp_OnLzSendAcceptedCallback with oftHoldingCid payoutCidsRecommendation
Consider replacing stored
oftFactoryCidinLzSendRequestwithinstrumentIdand resolving the currentOftFactoryat callback time via a contract key (fetchByKey). This avoids staleContractIdfailures after consuming updates (e.g.,SetIssuerFeeBps,AddToBlacklist) and keeps pending requests functional across factory rotations.-- OftFactory key instrumentId : Holding.InstrumentId maintainer key.admin -
H-04 High Allocation escrow bypass via transfer rule Unexpected Behavior Acknowledged
Description
Allocations are meant to implement delivery versus payment style settlement flows where funds are reserved in escrow while a coordinator or executor orchestrates the final settlement. The purpose is to lock the sender's holdings so the assets cannot be moved through ordinary transfer paths until the allocation is either settled, canceled or withdrawn according to the allocation rules. In other words, the allocation lock is supposed to be the boundary that guarantees atomicity, approvals and any settlement-time policies such as fees or checks enforced by the settlement workflow.
The intended flow is that a sender creates an allocation, the holding is locked under an allocation specific context, and only the allocation settlement path releases or transfers the locked holding. A simplified view of the intended flow is:
Alice -> create allocation -> [Locked Holding: Allocation Pending] | v Executor settles allocation | v Bob receives fundsIn the current implementation, that escrow boundary can be bypassed because the normal transfer rule accepts any locked holding without checking why it is locked or who is allowed to unlock it. The transfer rule simply verifies that the input holding is locked and owned by the sender, then unlocks it and transfers it. This means an allocation locked holding can be spent directly via the standard transfer rule, even though it was created for a different workflow.
A concrete exploitation path looks like this. First, Alice creates an allocation to Bob for a fixed amount, which creates a locked holding that is marked as allocation pending. That locked holding is intended to be released only when the executor settles the allocation or cancels it according to the allocation rules. Next, before any executor action happens, Alice and Bob submit a normal transfer that uses that locked holding as input. The transfer rule accepts the locked holding, unlocks it and transfers the funds to Bob immediately. The allocation contract is now stranded because its locked holding has been consumed and the escrow guarantee has been bypassed.
This bypass also skips any allocation specific enforcement that would normally happen during settlement, such as coordinator approval, time window checks, or fees that a settlement service charges during allocation execution. Because the transfer rule path never invokes the allocation settlement logic, any fee or approval that is applied there is effectively avoided. The impact is that escrowed funds can be moved early, allocation workflows can be sidestepped, and any policies that rely on the allocation settlement step are no longer enforced. This breaks settlement integrity and can violate compliance or commercial expectations.
The same design weakness can also affect other escrowed holdings that use different lock contexts, such as the
LzSendflow. A holding locked with context "LzSend pending" can be unlocked and transferred through the generic transfer rule, leaving a pendingLzSendRequestthat cannot be finalized because its locked holding was consumed. This is not a theft by a third party, but it does allow the sender and receiver to bypass the intended cross chain escrow path and can brick the request and fee settlement.Recommendation
Reject allocation locked holdings in the transfer rule or enforce a lock context whitelist so only transfer related locks can be unlocked by the transfer rule. Another safe option is to add context or lock holder validation inside the shared holding action helper so
UnlockMergeSplitTransferonly succeeds for locks intended to be released by that path. One concrete fix is to gateUnlockMergeSplitTransferin the transfer rule based on the lock context, for example:let lockedOftCids = map fromInterfaceContractId transfer.inputHoldingCids forA_ lockedOftCids $ \cid -> do h <- fetch cid case h.lock of Some l -> assertMsg errOftTransfer_LockContextNotAllowed $ isTransferLockContext l.context None -> assertMsg errOftTransfer_ExpectedLocked False result <- applyHoldingAction (UnlockMergeSplitTransfer transfer.receiver) transfer.sender instrumentId lockedOftCids transferAmount -
H-05 High Sender can redirect fees via feeCid receiver Validation Acknowledged
Description
The
validateFeefunction inFeeUtils.damldoes not check that the fee instruction receiver islzEndpoint.In Daml, when a contract exercises a choice on another contract, the authorization set is built from:
- The controllers of the exercised choice.
- The signatories of the contract on which the choice is exercised (automatically included for all nested exercises).
LzSendRequest:template LzSendRequest ... where signatory sender, lzEndpointAmuletTransferInstruction:template AmuletTransferInstruction ... where signatory transfer.instrumentId.admin, transfer.senderThe
TransferInstruction_Acceptchoice is controlled bytransfer.receiver.When
lzEndpointaccepts anLzSendRequest, the flow is:- Top-level submission:
submit (actAs lzEndpoint <> disclose ...) do exerciseCmd lzSendRequestCid ILzSendRequest_Accept with amuletContextAuthorization set:
{lzEndpoint}.- Exercising
lzSendRequest_AcceptImpl:
lzSendRequest_AcceptImpl ... = do acceptFee feeCid amuletContextThe authorization set now includes:
{lzEndpoint}fromactAs{sender, lzEndpoint}fromLzSendRequestsignatories Final set:{sender, lzEndpoint}.
- Exercising
acceptFee:
acceptFee feeCid amuletContext = do exercise (toInterfaceContractId @TransferInstruction.TransferInstruction feeCid) TransferInstruction.TransferInstruction_Accept ...Since
TransferInstruction_Acceptis controlled bytransfer.receiver, andsenderis already in the authorization set (due to being a signatory ofLzSendRequest), this exercise succeeds even if the receiver is notlzEndpoint.As a result, a sender can create a fee instruction where the receiver is themselves. When
lzEndpointaccepts theLzSendRequest, the fee is accepted and transferred to the sender instead oflzEndpoint, even thoughlzEndpointis the only party that explicitly submitted the transaction.This is caused by:
- Missing validation that
fee.receiver == lzEndpoint. - Daml’s authorization model automatically extending the authorization set with signatories during nested exercises.
Together, they allow fee redirection.
Recommendation
Consider adding a validation to the
validateFeefunction to check that the fee instruction's receiver islzEndpoint. -
M-01 Medium Shared decimal truncation burns dust Rounding Acknowledged
Description
The cross-chain send path converts a local unit amount into shared decimals using integer division and then encodes that truncated shared amount in the message. There is no on-ledger check that the local amount is divisible by the conversion rate and no refund of the remainder, so any fractional part is silently discarded. The locked and later burned local holding reflects the full local amount, while the payload only represents the truncated shared amount. The receive-side decoder derives the shared amount from the payload and passes it forward as the receive amount, and the on-ledger receive mints exactly the amount it is given. This means the destination side only receives the truncated shared amount while the source burns the full local amount, leaving the remainder unrecoverable as dust. The truncation happens in the conversion helper:
toSharedDecimals : Int -> Int -> Int -> Int toSharedDecimals amount localDecimals sharedDecimals = let decimalConversionRate = localDecimals - sharedDecimals in amount / 10 ^ decimalConversionRateThe send flow locks the full local amount and then encodes the truncated shared amount:
result <- applyHoldingAction (MergeSplitLock lzSendLock) sender instrumentId [oftCid] oftAmountToSendCrossChain let (encodedMessage, hasCompose) = encodeMessage sender oftAmountToSendCrossChain localDecimals sharedDecimals sendParamThe receiver extracts
amountSDfrom the payload and forwards it as the receive amount:const amount = oftMessageCodec.amountSD(msgBytes); const instruction = { type: 'lzReceive', toAddress: receiver, amount }; context.addInstruction(instruction);The on-ledger receive mints exactly that amount:
lzReceive instrumentId localDecimals receiver amount = do create Oft with owner = receiver amount instrumentId localDecimals lock = NoneAs a concrete example with
localDecimals = 8andsharedDecimals = 6, a user sending 105 local units will haveamountSD = 1and the message represents 100 local units, while the locked holding is still 105. The receive-side amount is 1, so only the equivalent of 100 local units is represented on the destination and the remaining 5 are burned with no refund. For amounts smaller than the conversion rate,amountSDbecomes 0 and the receive-side mint fails because holdings requireamount > 0, while the source may already have burned the full local amount. This is demonstrated in the PoC, which shows the encoded message contains the truncated shared amount while the locked holding remains the full local amount.Finally, do notice that the
minAmountLDcheck does not guard against truncation because it compares local amounts before conversion, so it can still pass while the encoded shared amount drops below expectations.Recommendation
Enforce a canonical conversion boundary in the send flow. Compute the conversion rate, then either reject any amount with a nonzero remainder or split the holding so only the divisible portion is locked and burned while the remainder is returned to the sender as a new unlocked holding. Ensure the encoded shared amount always corresponds exactly to the local amount that will be burned or minted on the destination.
-
M-02 Medium Allocation visibility blocks settlement Trust Assumptions Acknowledged
Description
In the allocation flow, the locked holding created for the DvP leg does not add any observers, and the allocation contract itself only lists the executor as an observer. Under Canton privacy, a party must see a contract to authorize a choice on it, so the receiver and executor may not be able to authorize settlement or cancellation unless they are explicitly disclosed both the allocation and the locked holding. This creates a liveness dependency on disclosure infrastructure rather than the on-ledger contracts.
The allocation lock is created with no observers, which means only the issuer (signatory) and the sender (owner) see the locked holding by default, while the receiver and executor do not.
lockers = [instrumentId.admin] context = "Allocation " <> show allocation.settlement.settlementRef <> ";" <> show allocation.transferLegId observers = NoneThe allocation contract itself makes only the executor an observer, while the receiver is not. That is incompatible with the interface requirement that the receiver must authorize settlement or cancellation.
template OftAllocation with allocation : AllocationV1.AllocationSpecification lockedHoldingCid : ContractId Oft where signatory allocation.transferLeg.instrumentId.admin, allocation.transferLeg.sender observer allocation.settlement.executorThe interface requires joint authorization by executor, sender, and receiver for both execute and cancel. If the receiver cannot see the allocation, they cannot co-authorize these choices, and the transaction fails even when all parties intend to settle.
allocationControllers AllocationView{..} = [allocation.settlement.executor, allocation.transferLeg.sender, allocation.transferLeg.receiver] choice Allocation_ExecuteTransfer : Allocation_ExecuteTransferResult controller allocationControllers (view this) do allocation_executeTransferImpl this self arg choice Allocation_Cancel : Allocation_CancelResult controller allocationControllers (view this) do allocation_cancelImpl this self argThe test probe shows the receiver cannot see or exercise the allocation without disclosure, confirming the dependency on off-ledger disclosure.
None <- queryContractId bob (fromInterfaceContractId @OftAllocation allocationCid) submitMustFail (actAs bob) do exerciseCmd allocationCid Allocation.Allocation_ExecuteTransfer with extraArgs = ExtraArgs with context = ChoiceContext TM.empty; meta = emptyMetadataThis creates a production liveness risk. If disclosure infrastructure is down or delayed, allocations can neither settle nor cancel. Locked funds remain in escrow until the sender withdraws or the issuer intervenes, which can stall DvP settlements and strand assets during outages.
Recommendation
Include the receiver and executor as observers on both the allocation contract and the allocation lock so they can see the allocation and locked holding by default and co-authorize settlement choices. A minimal change is to add those observers in the allocation and lock creation logic.
observer allocation.settlement.executor, allocation.transferLeg.receiver let allocationLock = Lock with lockers = [instrumentId.admin] context = "Allocation " <> show allocation.settlement.settlementRef <> ";" <> show allocation.transferLegId observers = Some [allocation.transferLeg.receiver, allocation.settlement.executor] -
M-03 Medium LzSendRequest expire needs disclosure Trust Assumptions Acknowledged
Description
The sender-driven expire flow depends on contracts that the sender does not see by default. The
LzSendRequest_Expirechoice is exercised on the request factory interface, but the request factory contract is owned by the endpoint party, so it is not visible to the sender unless the sender attaches an explicit disclosure payload.nonconsuming choice LzSendRequest_Expire : () with sender : Party lzSendRequestCid : ContractId ILzSendRequest amuletContext : ChoiceContext controller sender do lzSendRequest_ExpireImpl this argThe factory implementation immediately exercises the
ILzSendRequest_Expirechoice on the referenced request contract. This requires the request contract to be visible or explicitly disclosed to the submitter.lzSendRequest_ExpireImpl ILzRequestFactory.LzSendRequest_Expire{..} = do exercise lzSendRequestCid ILzSendRequest_Expire with amuletContextInside
LzSendRequest, the expire path withdraws the fee and then calls back into the OFTIOAppto unlock the locked holding and reject payouts. This introduces dependencies on the fee instruction and amulet context, plus theIOAppcontract referenced byoftFactoryCid.lzSendRequest_ExpireImpl ILzSendRequest_Expire{..} = do now <- getTime assertMsg errLzSendRequest_ExpireTooEarly $ now >= expirableAfter withdrawFee feeCid amuletContext create LzSendRequestExpiredEvent with lzEndpoint dstEid lzMessage extraOptions feeCid oftHoldingCid hasCompose composeMsg sender exercise oftFactoryCid OApp_OnLzSendExpiredCallback with oftHoldingCid payoutCidswithdrawFee feeCid amuletContext = do _ <- exercise (toInterfaceContractId @TransferInstruction.TransferInstruction feeCid) TransferInstruction.TransferInstruction_Withdraw with extraArgs = ExtraArgs with context = amuletContext meta = emptyMetadata pure ()The tests already reflect this dependency by attaching explicit disclosures for the request factory, OFT factory, request contract, and amulet infrastructure before expiring, which means the flow assumes an off-ledger disclosure service is operational.
submit (actAs alice <> disclose factoryDisclosure <> disclose requestFactoryDisclosure' <> disclose amuletRulesDisclosure <> disclose openRoundDisclosure <> disclose lockedAmuletDisclosure <> disclose lzSendRequestDisclosure) do exerciseCmd requestFactoryCid LzSendRequest_Expire with sender = alice lzSendRequestCid = toInterfaceContractId @ILzSendRequest lzSendRequestCid amuletContextIf disclosure delivery is delayed or unavailable, the expire transaction fails with visibility errors and the sender cannot unlock the locked holding or withdraw the fee. This can strand funds until the endpoint acts, creating an availability risk tied to off-ledger disclosure plumbing rather than on-ledger observers.
Recommendation
Make the expire path independent of off-ledger disclosure by adding the sender (and any party expected to exercise expire) as an observer to the request factory and the
IOAppcontract, or by adding a dedicated on-ledger delegation contract that grants the sender the ability to exercise expire without explicit disclosure. If explicit disclosure remains the intended approach, document it as an operational requirement and add runtime checks plus monitoring to detect disclosure failures before requests accumulate and funds are locked. -
M-04 Medium Missing RequestType Validation In Committer Validation Acknowledged
Description
Request_CreaterecordsrequestTypeandpayload, butRequest_AcceptandRequest_Rejectblindly emit whateverrequestType,acceptPayload,rejectPayloadthe caller supplies. No fetch/assert ensures these values match the contract being consumed.AcceptRequestandRejectRequestin Committer take therequestTypeandpayloadparameters from the caller and forward directly without checking whether it's the same type as the original request. Anyone with committer privileges can falsify the RequestAcceptedEvent/RequestRejectedEvent metadata and mislabel which action was processed, making it impossible to trustrequestTypeorpayloadin downstream monitoring.Similarly, there is no check when the sender performs
Request_Expireon an expired request, and can provide any value.Recommendation
When accepting/rejecting, fetch the target
Requestcontract and assertrequest.requestType == requestType(and optionally that the payload matches) before exercising, or eliminate the caller-supplied fields and derive them directly from the stored contract to guarantee consistency. -
M-05 Medium State Commitments Can Be Arbitrarily Ordered Validation Acknowledged
Description
Committer’s
CreateStateCommitmentonly fetches the suppliedprevStateCommitmentContractIdto confirm the contract exists but it never records or compares against a head before creating a new commitment.The template claims this field represents “Previous state (None for genesis)” but nothing prevents the owner from pointing to any historical commitment—or even None—when creating fresh state. Since AcceptRequest and RejectRequest merely call
CreateStateCommitment, the endpoint can create arbitrarily ordered commitments, introduce forks, or replay stale roots. Downstream readers cannot rely on these contracts to form a single append-only chain of verified state roots since there is no guarantee thatprevStateCommitmentContractIdis actually the previous state commitment.Recommendation
Track the latest commitment on the Committer contract, and require every new StateCommitment to reference that previous one before updating it. This would require the Committer contract to be archived and re-created with the updated commitment and ensures each new commitment points to the previous one.
-
M-06 Medium Empty signatures accepted in StateCommitment Signatures Acknowledged
Description
StateCommitmentvalidation accepts a commitment even when the signatures list is empty. This means a commitment can be created and processed without any cryptographic approvals, even though the protocol intent is that signers attest to the payload and authorize the state transition. When downstream logic treats the presence of aStateCommitmentas sufficient, an actor who can submit or relay a commitment can advance state without the intended quorum or any signer participation.This breaks the security model that commitments represent authenticated, signed state updates.
Impact: unauthorized state transitions and the potential for inconsistent or forked state acceptance, which can undermine trust and lead to incorrect state or financial outcomes.
Recommendation
Reject commitments with an empty signatures list at the earliest validation boundary. Enforce a minimum signature count and verify each signature against the required signer set and threshold before accepting or applying the commitment.
-
M-07 Medium lzSend fails when sender is a payout DoS Acknowledged
Description
The cross chain send flow creates payout transfer offers for each receiver. Those offers use the standard transfer offer template, which enforces a strict invariant that the sender and receiver must be different. When the payout receiver list includes the sender, the offer creation fails and the entire lzSend transaction aborts. This is a hard failure, not a graceful rejection, and it occurs after the flow has already validated amounts and split holdings, so the caller just sees a failed send with no clear intent level error.
Example scenario: Alice is both the sender and an eligible payout receiver. This can happen if the issuer fee is enabled and the issuer is the sender, or if Alice is a service provider who receives a rebate. Alice submits a valid
lzSendwith a payout list that includes herself. The system attempts to create a payout offer from Alice to Alice, which violates the self transfer invariant and the transaction fails. In practice this allows a misconfiguration or crafted payout list to reliably blocklzSendoperations for a sender.Impact:
lzSendis unavailable in realistic configurations where the sender can also be a fee recipient, resulting in denial of service for cross chain transfers and operational disruption.Recommendation
Handle sender payouts explicitly before creating offers. If the sender appears in the payout list, net that amount into the sender refund or change holding instead of creating a self transfer offer. If the intended policy is to disallow sender payouts, reject early with a clear error before any holding operations.
-
M-08 Medium Lock holders cannot unlock holdings Unexpected Behavior Acknowledged
Description
The lock design allows a holding to be locked by one set of parties but only exposes the holding to the owner and optional lock observers. The unlock choice is controlled by the lock holders, yet those parties are not automatically observers. If a lock is created with lockers that are not also observers, the authorized lockers cannot see the holding to exercise the unlock choice. The result is a locked holding that no authorized party can unlock.
Example scenario: Alice receives a holding locked for an external executor Bob. The lock lists Bob as the sole locker and observers is left empty. Bob is the only party authorized to unlock, but Bob cannot see the holding because he is not an observer or signatory. The holding stays locked indefinitely, even though the system believes it is unlockable. This can be triggered by misconfiguration or by any workflow that uses non admin lockers without explicitly adding them as observers.
This is most likely to occur when an operator uses
Oft_Lockdirectly for manual or operational holds and sets lockers to a third party without also adding them as observers. It also applies to any custom or future flow that reuses the lock type and assigns non admin lockers. The built in flows that create locks in this codebase set lockers to the instrument admin, so they do not hit the visibility gap, but any deviation from that pattern will.Impact: holdings can be permanently stuck and unusable, breaking transfers and settlement flows that rely on external lockers.
Recommendation
Ensure lock holders always have visibility. Add
lockHoldersas observers on the holding, or enforce a rule at lock creation that the lockers set is included in observers. If visibility cannot be guaranteed, reject the lock creation to avoid creating un-unlockable holdings. -
M-09 Medium Blacklist bypass through pending contracts Logical Error Acknowledged
Description
The blacklist mechanism is enforced only at action initiation time (when creating a transfer, allocation, or cross-chain send), but it is not re-applied during execution of already created offers. In particular, blacklist checks are not performed when accepting or finalizing pending operations, allowing blacklisted parties to still receive tokens if the offer was created before blacklisting.
assertNotBlacklistedis applied inOftFactoryentry points such as:TransferFactory_TransferAllocationFactory_AllocateBurnMintFactory_BurnMintOApp_LzSendOApp_LzReceiveHowever, execution paths such as TransferInstruction_Accept and related settlement logic do not re-validate the blacklist.
Recommendation
Enforce blacklist checks at execution time, not only at initiation time.
-
M-10 Medium Withdraw bypasses allocateBefore; mirrors Cancel Validation Acknowledged
Description
allocation_withdrawImpllets the sender unlock and reclaim the allocated holding without enforcingallocation.settlement.allocateBefore. As a result, the sender can withdraw even after the allocation window has closed, when the protocol expects the allocation to be either executed or jointly cancelled.Practically, the current
withdrawimplementation is equivalent tocancel: both choices justOft_Unlockthe same locked holding and return it to the sender.This contradicts the
Allocation_Withdrawspecification, which states that withdrawal "SHOULD not fail settlement if the sender still has time to allocate again; i.e., the settlement.allocateBefore deadline has not yet passed.” https://docs.sync.global/app_dev/api/splice-api-token-allocation-v1/Splice-Api-Token-AllocationV1.html#type-splice-api-token-allocationv1-allocationwithdraw-60458Currently, the sender can unilaterally withdraw an allocation after
allocateBefore, breaking the settlement flow when re-allocation is no longer possible.Recommendation
Consider making
Withdrawtime-bounded and keepCancelas the coordinated abort path. Add a ledger-time check:now <- getTime assertMsg "Withdrawal not allowed after allocateBefore deadline" (now < allocation.settlement.allocateBefore) -
M-11 Medium Integer Floor Division Causes Fee Precision Loss Rounding Acknowledged
Description
The fee calculation uses integer floor division, which truncates fractional amounts instead of rounding them up.
let oftFeeAmount = (amountLD * issuerFeeBps) / bpsDenominatorBug Scenario:
- localDecimals = 0 → token has no fractional units
- amount = 19 tokens
- issuerFeeBps = 500 (5%)
Fee = (amountLD × issuerFeeBps) / 10,000 = (19 × 500) / 10,000 = 9,500 / 10,000 = 0.95 → 0 (integer floor)
So the fee collected will be 0. Loss 100% of the intended fee.
Recommendation
Use ceiling division instead of floor division to ensure fees are always rounded up to favor the protocol.
-
M-12 Medium Fractional offer/allocation amounts Logical Error Acknowledged
Description
Transfer and allocation amounts are
Decimalbut are truncated toIntwithout validating they are whole-number amounts. The offer/allocation record preserves the original decimal (e.g.,10.9), but execution usestruncate(i.e receiver gets10).This is a silent underpayment risk and can confuse UIs or off-chain accounting. A malicious user can also craft DvP allocations with fractional amounts to make a counterparty settle on the displayed value while actually receiving less.
Recommendation
Reject non-integer
Decimalamounts at the boundary (e.g., assertamount == intToDecimal (truncate amount)), or round explicitly and document the behavior to all clients. -
M-13 Medium Commitments Are Not Tied To Requests Fund Them Validation Acknowledged
Description
When
Committer.AcceptRequestorRejectRequestfinishes processing a Request, it immediately callsCreateStateCommitmentwith whateverstateRoot,tx, and signatures the caller supplied. The StateCommitment template stores only those raw fields plus the optional previous commitment but it never records whichrequestCidproduced the update.Consequently, there is no on-ledger evidence linking a fee-paying request to the state commitment that supposedly resulted from it. The committer can accept request A (consuming its fee) but publish the state root/transaction for request B, or even fabricate entirely new data, and downstream observers have no way to detect the mismatch because the commitment lacks any reference back to the originating request.
Recommendation
Store the
requestCid(and optionally its requestType/payload hash) insideStateCommitmentand have Committer populate it directly from the exercised Request, so every commitment can be traced back to the exact request that funded it. -
M-14 Medium Unsupported Token Amounts Logical Error Acknowledged
Description
OFT balances, transfers and allocation amounts are stored as
Int. The OFT ensures token decimals are positive but it can support any value, like 18-decimal tokens. Even modest human amounts (e.g., 1000 USDS) require base-unit values like1000 * 10^18, which exceed the Int64 range (2^63 -1 or ~9.22e18).This causes runtime errors such as
Int literal out of bounds, blocking mint/transfer/allocate flows for common 18-decimal assets.Similarly, the
isValidTxParaminStateCommitment.damlvalidates that everyIntParamis greater than 0, but due to the max value ofInttypes, transaction fails for values above 2^63 - 1Recommendation
Use
Numeric/Decimalfor on-ledger amounts or enforce a lowerlocalDecimalsceiling (and document the max supported supply). Alternatively, scale tokens to fit Int64 and expose that limit in client UIs. -
M-15 Medium Receiver Needs Transfer Rule Disclosure Trust Assumptions Acknowledged
Description
OftTransferOfferaccepts by exercisingOftTransferRule_Execute, which requires theOftTransferRulecontract data. The rule is only visible to the admin (no observers), so receivers will not see it unless the admin explicitly discloses it.If disclosure delivery is delayed or unavailable, accept/reject/withdraw can fail due to missing contract visibility, leaving offers stuck.
Recommendation
Make the transfer rule publicly discoverable (e.g., add observers, a registry, or a public fetch/disclosure flow) or document and automate disclosure to all potential receivers.
-
L-01 Low Observers Are Not Propagated In Commitment Validation Acknowledged
Description
Requests capture an observers list but Committer choices take a fresh
observersargument and never read the observers stored on the Request. The owner can therefore collect a fee from a requester but omit that requester (and anyone else) from the resultingStateCommitment, preventing them to observe their own requests' state commitment and undermining the transparency.Recommendation
Pull the observer list from the Request contract when accepting/rejecting and reuse it or its superset for the resulting
StateCommitment -
L-02 Low Cancelled Amulet Transfers Cause Locked Funds Unexpected Behavior Acknowledged
Description
Users lock their OFT and must provide a valid Amulet transfer instruction,
feeCid, to be able to send cross-chain token transfer.Because
AmuletTransferInstructionexposes the genericTransferInstruction_Withdrawchoice to its sender, users can cancel their transfer instruction and get their amulet back. Doing so archives thefeeCidand the request cannot be accepted or rejected.There’s no fallback path that unlocks the OFT when fee settlement fails, and nothing prevents users from calling
TransferInstruction_Withdraw. As a result, a user who cancels their fee, whether intentionally or unintentionally, without understanding the potential consequences, ends up bricking their own cross-chain transfer.Recommendation
Warn or prevent users from withdrawing Amulet instructions while a request is pending. Alternatively, consider allowing users to provide new
feeCids to unlock their OFTs in case of failure. -
L-03 Low TransferImpl bypasses blacklist Unexpected Behavior Acknowledged
Description
The core transfer routine accepts any sender and receiver without enforcing the blacklist. The only blacklist validation occurs at higher level entry points, but the shared transfer implementation itself never re-validates the parties. This creates a direct bypass path: any template or helper that calls the transfer implementation directly can move value to a blacklisted receiver even if that receiver was already blacklisted at the time of initiation.
Example scenario: Alice is added to the blacklist after prior policy violations. Bob still wants to pay Alice. An internal helper template or admin tool that calls the core transfer implementation directly constructs a transfer from Bob to Alice and executes it without any blacklist checks. The transfer succeeds and Alice receives a holding despite being blacklisted, because the only guard was at the factory entry point and that guard was bypassed.
Because the transfer implementation is reusable within the package, this is a defense in depth gap that can easily surface through new features, admin tooling, or alternative entry points. The policy becomes dependent on every caller remembering to apply blacklist checks, rather than being enforced where value actually moves.
Impact: blacklisted parties can still receive holdings when a call path reaches the core transfer logic without the factory guard, undermining compliance and governance rules.
Recommendation
Enforce blacklist checks inside the core transfer implementation or within the execution rule that applies the holding action. Pass the blacklist set into the core transfer routine and reject any sender or receiver that appears in it. This ensures blacklist policy is enforced at the point of value movement regardless of the caller.
-
L-04 Low Non-hex compose payload accepted Validation Acknowledged
Description
The message encoder appends
composeMsgdirectly to the encoded payload without validating that it is hex. WhencomposeMsgis non empty, the resulting lzMessage contains the fixed hex fields followed by arbitrary text. This produces a malformed payload that downstream systems expecting hex encoded bytes cannot decode.A caller can submit a cross chain send with
composeMsgset to non hex content. The on ledger flow succeeds and creates the request, but the relayer or destination decoder can reject the payload as invalid. This leaves the request in a stuck or failed state until it is explicitly rejected or expired, and creates a mismatch between on ledger intent and off chain processing.Impact: cross chain sends can be made unprocessable by supplying non hex compose payloads, causing denial of service and operational failures. Funds may remain locked until the request is rejected or expired.
Recommendation
Validate
composeMsgas hex before encoding, including even length and maximum size checks. Alternatively, treat composeMsg as raw bytes and hex encode it in the encoder so the message is always well formed. -
L-05 Low feeCid Reuse Can Lock Cross-Chain Requests Unexpected Behavior Acknowledged
Description
The request factory accepts a
feeCidand callsvalidateFeeduring request creation, but it does not reserve that fee instruction or enforce a one-to-one relationship between a fee and a live request. This affects both request types exposed by the factory: the cross-chain send flow (LzSendRequest) and the generic request flow (Request). Both creation paths validate the fee and then store the samefeeCidon a newly created request without any uniqueness guard.As a result, multiple requests can be created using the same fee instruction, including mixed reuse across request types. When any one of these requests is later finalized (accept, reject, or expire), the referenced fee is consumed through the transfer instruction interface. All remaining requests that reference the same fee then become permanently unfinalizable because fetching or exercising the fee fails.
This produces a liveness failure mode. A sender can create a cross-chain request and then create a generic request with the same fee, or create two generic requests with the same fee. Whichever request is finalized first archives the fee instruction and effectively bricks the others. In the cross-chain case, the stuck requests can also leave associated OFT holdings locked. This behavior is also contradicted by regression intent that expects fee reuse to be rejected, including
test_P0_FEE_feeCid_reuse_rejected_for_lzSendRequest_then_generic_Request,test_P0_FEE_feeCid_reuse_rejected_for_two_generic_Requestsandtest_ILZRF_EDGE_02_fee_reuse_rejected.Recommendation
Introduce an explicit reservation or uniqueness guard keyed by
feeCidand enforce it in both request creation paths. A simple approach is to create a small reservation contract with a contract key onfeeCidduring request creation, and to archive that reservation during accept, reject, or expire. This causes fee reuse to fail early and deterministically across both request types. -
L-06 Low Update returns auth error not constant Unexpected Behavior Acknowledged
Description
The
TransferInstructionUpdatechoice is controlled by the instrument admin and every party listed inextraActors. When a caller includesextraActorsthat do not authorize the transaction, Daml rejects the exercise at the authorization layer before the contract code runs. In this codebase, the update implementation always aborts withERR_OFT_UPDATE_NOT_SUPPORTED, but that error is never reached if anyextraActordid not sign. As a result, callers can receive a generic authorization error instead of the expected constant.This creates inconsistent error behavior for the same unsupported operation. Systems that rely on error codes to detect unsupported updates can misclassify these failures, and tests that expect a specific error constant will fail unless all
extraActorsare also signers.Impact: no state change occurs, but error handling is unreliable. This can confuse off ledger automation and test suites that depend on specific error codes.
Recommendation
Ensure
extraActorsare included as authorizers whenever usingUpdate, or avoid specifyingextraActorsfor unsupported update calls. If a consistent error surface is required, constrain callers to pass only signers asextraActorsand document this behavior. -
L-07 Low Fee lock mismatch bricks request expiry DoS Acknowledged
Description
The request expiry flow can become permanently unavailable when the configured fee lock duration is shorter than the request expiration window. The
LzSendRequestexpire choice withdraws the fee as part of expiry, which calls theAmuletwithdraw path. If the underlying fee lock has already expired or is otherwise invalid, the withdraw step fails and the entire expire transaction aborts. Because expiry is only permitted after expirableAfter, this creates a trap window where the request is eligible for expiry but the fee can no longer be withdrawn. In that state, accept, reject, and expire can all be blocked by process and time constraints, leaving the request unfinalizable. A realistic example is an admin configuringminLockDurationto 30 seconds andexpirableAfterto 120 seconds, then Alice creates a cross-chain send to Bob. If Bob or the lzEndpoint does not finalize the request, Alice attempts to expire it after 120 seconds, but the fee lock has already expired at 30 seconds, so withdraw fails and the expire transaction aborts. The request remains active, Alice's locked OFT holding stays locked and her fee stays in limbo with no recovery path. The practical impact is a liveness deadlock where locked OFT holdings and fee funds remain stuck on ledger with no valid path to resolution. This can be triggered by configuration alone without a malicious actor.Recommendation
Enforce a configuration invariant that guarantees the fee lock remains valid through the expiry window, such as requiring
minLockDurationto be greater than or equal to expirableAfter at factory creation or request creation time. Alternatively, change the expire path to handle an already expired fee lock in a controlled manner, for example by allowing expiry to complete without withdrawing the fee and emitting a dedicated error or event that records the fee recovery failure. -
L-08 Low Local decimals mismatch not enforced Unexpected Behavior Acknowledged
Description
The cross chain send path accepts a holding whose
localDecimalsdo not match the factorylocalDecimalsand still proceeds to encode the message and lock or burn funds. The send logic uses the factorylocalDecimalsfor message encoding and conversion, but it does not verify that the holding is in the same unit system. This allows a misconfigured factory or misminted holding to produce a message that represents a different numeric value than the amount actually locked or burned on the source chain. Because the receive path accepts a raw amount parameter from the off chain worker and does not derive that amount from the encoded message on ledger, the ledger trusts whatever the off chain decoder produces. That means anylocalDecimalsmismatch silently shifts the encoded amount and can lead to incorrect minting on the destination chain without any on ledger correction. For example, a holding with 18 decimals sent through a factory configured for 6 decimals can encode an amount that is one trillion times larger than the true value, and the off chain worker will mint that value if it decodes the message literally. The result is cross chain accounting drift, silent inflation or deflation, and loss of supply integrity with no on ledger invariant tying the burned amount to the minted amount.Recommendation
Enforce
holding.localDecimals == factory.localDecimalsduringOApp_LzSendand reject the send if they differ. Consider also enforcing this invariant at holding creation or factory creation to prevent inconsistent instruments. If off chain decoding remains the source of the receive amount, add an on ledger binding such as a message hash or deterministic decode verification so that the amount supplied to OApp_LzReceive can be validated against the encoded message. -
L-09 Low Redundant Holding State Check During lzSend Logical Error Acknowledged
Description
The
applyHoldingActionfunction iterates overallHoldingsto ensure that all holdings share the same lock state and decimals. This loop executes regardless of the number of holdings; even when only a singleinputHoldingCidis provided, the validation redundantly comparesfirstHoldingwith itself. Since thelzSendflow always supplies exactly one input holding, this redundant check is performed on everylzSendaction.Recommendation
Perform the validation only when the allHoldings count is greater than one.
-
L-10 Low Empty lockers brick locked holdings Validation Acknowledged
Description
A lock with an empty lockers list creates a holding that no party can ever unlock. The unlock choice on an OFT holding is controlled by
lockHolders, which is derived directly from the lock's lockers. If lockers is empty, the controller set is empty and there is no authorized party who can exerciseOft_Unlock. The holding remains locked indefinitely even though the system believes it is unlockable. The same pattern affects locked payout offers because theILockedPayoutview derives thelockHolderusingheadlockers, which fails or produces an invalid controller when lockers is empty. As a result, force accept and force reject operations become unusable, leaving locked payout offers permanently stuck. This can be triggered accidentally by misconfiguration or by any workflow that constructs aLockwithout ensuring at least one locker.Recommendation
Reject empty lockers at lock creation by adding an invariant that lockers must be non empty. Alternatively, if empty lockers are not expected, default the lockers list to a safe party such as the instrument admin. Also make
ILockedPayoutview logic defensive by avoiding head on an empty list and rejecting force accept or force reject when no validlockHolderexists. -
L-11 Low Fee refund context crashes on missing keys Error Acknowledged
Description
The fee refund path relies on
ChoiceContextvalues that are looked up with unsafe pattern matches. InFeeUtils.acceptFeeWithRefund, the code assumes that amulet-rules and open-round keys exist and are of the correct type and it destructures them with a let Some pattern. If either key is missing or has the wrong type, the lookup returns None and the pattern match throws a runtime exception. This code path is exercised by reject with refund flows that callacceptFeeWithRefund, such asRequest_RejectandLzSendRequest_Reject. The failure mode is an opaque crash rather than a controlled error code, which makes integration debugging difficult and can lead to unexpected refund failures in production. The unsafe lookup logic is shown below.let Some (AV_ContractId amuletRulesCidAny) = TM.lookup "amulet-rules" amuletContext.values let amuletRulesCid : ContractId AmuletRules = coerceContractId amuletRulesCidAny let Some (AV_ContractId openRoundCidAny) = TM.lookup "open-round" amuletContext.values let openRoundCid : ContractId OpenMiningRound = coerceContractId openRoundCidAnyRecommendation
Replace the unsafe pattern matches with explicit validation and
assertMsgerrors for missing or malformed context entries. Add dedicated error constants for missing amulet-rules and missing open-round and for wrong value types, so integration clients receive deterministic failures instead of runtime exceptions. -
L-13 Low Missing requestedAt <= now in Allocation Validation Acknowledged
Description
The
allocationFactory_allocateImplfunction does not validate that therequestedAtparameter is in the past or present, unlike thetransferFactory_transferImplfunction which performs this check. It only validates:allocation.settlement.requestedAt <= requestedAt.This causes inconsistency with transfer function validation pattern.
Recommendation
Consider adding
requestedAt <= nowvalidation to ensure thatallocation.settlement.requestedAt <= requestedAt <= now. -
L-14 Low Missing Validations When Updating Factory Params Validation Acknowledged
Description
SetRequestFactoryandSetTransferRulesetters simply archive the current OftFactory and recreate it with the new contract IDs without performing any checks and blindly updates with admin provided values.A misconfiguration or compromised issuer can point the OFT to an
ILzRequestFactoryowned by a different party rather thanlzEndpointor to a transfer rule that doesn't point to the sameinstrumentId.Recommendation
Add validations to setters to ensure
newRequestFactoryCidis also owned by thelzEndpointandnewTransferRuleCidtargets the sameinstrumentId. -
L-15 Low Missing Replay Protection For
OApp_LzReceiveTrust Assumptions AcknowledgedDescription
OApp_LzReceivemints based solely on the providedreceiverandamount, with no on-ledger check for(origin, guid)uniqueness. The TypeScript endpoint library includes replay protection (verify → lzReceive with inbound payload hashes), but the HTTP controllers in this repo are TODO/501 and do not wire in those checks. If the endpoint submits the same message twice, the ledger will mint twice.Recommendation
Add on-ledger replay protection (store processed GUIDs or nonces per origin) or enforce the verify→lzReceive flow in the endpoint service before submitting the Daml command. Document this as a trust assumption if left off-ledger.
-
L-16 Low Request factory accepts mismatched
IOAppDoS AcknowledgedDescription
The request factory allows
LzSendRequestcreation with an arbitraryIOAppcontract ID and does not verify that the referencedIOAppbelongs to the same endpoint that owns the factory. As a result, a sender and issuer can create a request under one endpoint while pointing the callback target at a differentIOAppcontrolled by another endpoint. The request is successfully created and the sender's holding is locked, but the later accept, reject, or expire flow calls theIOAppcallback under the request factory owner. That callback requires authorization from theIOApp's own endpoint, so the transaction fails and the request becomes unfinalizable.A realistic scenario is a user or integrator submitting direct
LzSendRequest_Createcalls through an automated issuer signer. Alice submits a request via the endpoint A factory but supplies theIOAppfrom endpoint B. The issuer co-signs automatically, the request is created and Alice's holding is locked. When endpoint A attempts to accept or expire the request, the callback to the endpoint BIOAppcannot be authorized by endpoint A and the transaction aborts. Alice's funds and fee remain locked with no resolution path unless endpoint B cooperates and the endpoint operator sees repeated failures for a request that should have been rejected at creation.Recommendation
At request creation, validate that the provided
IOAppbelongs to the same endpoint as the request factory by fetching its view and comparing thelzEndpointto the factory owner. Additionally, validate that theIOAppmatches the instrument implied by the holding or derive theIOAppfrom a keyed factory lookup instead of accepting it as a free parameter. Reject creation if these checks fail to prevent unfinalizable requests. -
L-17 Low lzSend accepts negative dstEid values Validation Acknowledged
Description
dstEidis anIntand is passed directly fromSendParamintoLzSendRequestwithout validation. Negative or invalid EID values can be used to create requests that will never be processed off-ledger.Recommendation
Validate
dstEidas a non-negative, supported EID atOApp_LzSendtime (or in the request factory) and reject invalid values early. -
L-18 Low lzSend accepts unbounded extraOptions size Validation Acknowledged
Description
sendParam.extraOptionsis stored without any length checks. A sender can submit arbitrarily large option payloads, which can bloat requests and stress off-ledger processing. The current TypeScript implementation only parses these bytes (no dynamic code execution), so the realistic risk is resource exhaustion or parsing failures that stall processing rather than code execution.Additionally,
Request.payload(the[Text]payload stored on Request) is not bounded: callers can supply arbitrarily many elements or very large text elements, producing the same bloat and off-ledger processing risks.Recommendation
Enforce a maximum
extraOptionslength atOApp_LzSendtime (or in the request factory) and reject oversized inputs. ForsendParam.extraOptionsenforce a max byte length; forRequest.payloadenforce limits on both the number of elements and the max length per element (or a total combined size). -
L-19 Low No Cap or Refund On Fee Payments Validation Acknowledged
Description
validateFeeonly checks that amount is greater than or equal tominFee. When the factory owner accepts a request the code blindly exercisesacceptFee, transferring the entire locked amount to the owner. There is no maximum bound and no refund path on accept.A sender who mistakenly locks more Amulet than required (or is tricked by a malicious UI) permanently loses the excess once the admin accepts, even though the protocol only intended to charge the minimum fee. The only mechanism that can return funds is the reject flow (acceptFeeWithRefund), so any “successful” request becomes an all-or-nothing payment.
Recommendation
Consider introducing a
maxFeeparameter and enforcingminFee <= amount <= maxFeeinvalidateFee, and/or automatically refunding the excess during acceptance -
L-20 Low Expired fee not validated at request creation Validation Acknowledged
Description
The
validateFeehelper checks fee sender, minimum amount, and lock duration, but does not enforce the fee transfer’sexecuteBefore.As a result, requests can be created with an already-expired fee instruction. Later,
TransferInstruction_Acceptalways fails in Amulet (assertWithinDeadline), so accept/reject cannot proceed. Expire can still succeed onceexpirableAfterpasses if the caller provides a validexpire-lock/open‑round context; until then the request is effectively stuck.-- daml/Layerzero/Request/daml/FeeUtils.daml validateFee feeCid expectedSender minFee minLockDuration = do feeInstruction <- fetch feeCid assertMsg errRequestFactory_FeeSenderMismatch $ feeInstruction.transfer.sender == expectedSender assertMsg errRequestFactory_InsufficientFee $ feeInstruction.transfer.amount >= minFeeRecommendation
Add an
executeBeforecheck when validating the fee instruction (e.g., requireexecuteBefore > nowornow <= executeBefore), or reject request creation when the fee is already expired. -
L-21 Low stateRoot accept arbitrary Text not only bytes32 Validation Acknowledged
Description
stateRootis declared as Text and is not validated for hex format or fixed length. A committer can store arbitrary or malformed roots. Off‑ledger verifiers expecting a bytes32 hex value may fail to parse or silently accept invalid data, breaking integrity guarantees.Recommendation
Validate
stateRootas hex and enforce exact length (64 hex chars = bytes32) after stripping0x. Optionally validate signature formats at the same boundary. -
L-22 Low requestFactoryCid Becomes Stale on Upgrade DoS Acknowledged
Description
The Committer contract stores a reference to a specific
ILzRequestFactorycontract viarequestFactoryCid. If the referenced factory contract is archived (for example, callingsetMinFee), all choices in Committer that rely on exercisingrequestFactoryCid(such asAcceptRequestandRejectRequest) will fail with a "contract not active" error. This tightly couples the Committer to a single, immutable factory instance, causing all existing Committer contracts to become unusable.Impact:
- All Committer contracts referencing an archived factory become permanently unusable for request processing, this requires a new commitment contract
Recommendation
Refactor Committer to avoid storing a static ContractId for the factory. Instead, dynamically look up the current active factory contract when processing requests, or consider adding consuming choice to update the requestFactoryCid
-
L-23 Low AllocationFactory allocate needs disclosure Unexpected Behavior Acknowledged
Description
Several user controlled entry points are hosted on the factory contract, but the factory is not visible to those users by default. The factory is signed by the instrument admin and observed by the endpoint, while the relevant choices are controlled by the sender. Under Canton privacy rules, the controller must be able to see the contract that hosts the choice, so these flows depend on off ledger disclosure for basic availability. This affects
TransferFactory_Transfer,AllocationFactory_AllocateandOApp_LzSend, all of which are implemented on the same factory contract even though their controllers are user parties.The visibility mismatch is structural. The factory that hosts these choices does not list the expected caller as an observer, but the choices are controlled by that caller.
template OftFactory with instrumentId : InstrumentId; lzEndpoint : Party where signatory instrumentId.admin observer lzEndpoint interface instance TransferFactory for OftFactory where transferFactory_transferImpl _self TransferFactory_Transfer{...} = do ... interface instance AllocationFactory for OftFactory where allocationFactory_allocateImpl _self AllocationFactory_Allocate{...} = do ... interface instance IOApp for OftFactory where oApp_lzSendImpl _ OApp_LzSend{...} = do ...If disclosure delivery is missing, delayed, or scoped incorrectly, these user entry points fail with visibility errors even when the user is the controller. This creates a consistent liveness dependency on disclosure infrastructure rather than on ledger observers and signatories, and it is likely to surface as intermittent production failures.
Recommendation
Make the factory visible to parties who are expected to exercise these user entry points before they need to use them. A robust approach is to introduce a sender visible registry or delegation contract that references the current factory and can exercise these choices on behalf of the sender. If off ledger disclosure remains the intended approach, treat factory disclosure to users as a required provisioning step and add monitoring that detects missing disclosure before user flows are invoked.
-
L-24 Low Missing Time Validations In Allocation Creation Validation Acknowledged
Description
allocationFactory_allocateImplcomparesallocation.settlement.requestedAtonly against the caller-providedrequestedAt, and never checksallocateBefore/settleBeforeagainstgetTime. This allows allocations to be created after their ownallocateBeforedeadline (or with futurerequestedAt), weakening time-bound guarantees.Recommendation
Use
getTimeto validaterequestedAtand requireallocation.settlement.allocateBefore > now(and optionallysettleBefore > now) at allocation creation. -
L-25 Low Missing Validations When Creating Offers Validation Acknowledged
Description
OftTransferOfferis missing the following validations:transfer.inputHoldingCidsuniqueness. If an offer is created with duplicate holdings (possible via direct template creation by issuer+sender), accept/reject/withdraw will attempt to process the same locked holding twice, causing the transaction to fail and leaving the offer pending with locked funds. In normal TransferFactory flow, duplicates are rejected earlier, so this is primarily a footgun for direct creation paths.- Locked
transfer.inputHoldingCids. If an offer is created with unlocked holdings (possible via direct template creation by issuer+sender), accept/reject/withdraw will all fail becauseUnlockMergeSplitTransferandOft_Unlockrequire locked inputs. The offer remains pending with no valid resolution path.
Recommendation
Add a uniqueness check for
transfer.inputHoldingCidsinOftTransferOffer(or in the transfer rule) to fail fast on malformed offers. Additionally, add a lock-state check fortransfer.inputHoldingCidsat offer creation (or in the transfer rule) so malformed offers fail fast. -
I-01 Informational Misleading Comment In createPayoutHoldings Informational Acknowledged
Description
The comment on
createPayoutHoldingsstates “returning any remaining change to the sender as an unlocked holding,” but the function actually returns only the newly created locked payout holdings. Unlocked remaining change is minted later in the flow, not in this function.Recommendation
Update the comment on
createPayoutHoldingsfunction -
I-02 Informational Endpoint Can Exercise Callback Choices Trust Assumptions Acknowledged
Description
OApp_OnLzSendAcceptedCallback/Rejected/Expiredare directly callable bylzEndpointand do not verify that the holding/payouts belong to an activeLzSendRequest.Recommendation
Bind callbacks to a specific
LzSendRequestor validate the holding/payouts against request state before mutating. -
I-03 Informational Duplicate payout receivers not coalesced Unexpected Behavior Acknowledged
Description
The payout receiver list for cross-chain sends is used as-is when creating payout holdings and offers. If the sender supplies the same receiver multiple times, the system creates multiple payout holdings and multiple transfer offers for that receiver instead of a single aggregated payout. A regression test expects duplicate receivers to be coalesced into one offer, but the current behavior generates one offer per list entry.
A realistic scenario is Alice initiating a cross-chain send and specifying a service provider list that contains the same receiver twice, for example Carol with two separate amounts. The system creates two locked payout holdings and two payout offers for Carol. When the endpoint later forces acceptance, Carol receives the total amount across two offers rather than a single consolidated payout. This does not break conservation of value, but it complicates accounting, reconciliation and downstream monitoring that assumes one payout per receiver.
Recommendation
Consider normalizing the receiver list before creating payout holdings and offers. Consider also aggregating amounts by receiver so each receiver appears at most once, drop zero-amount entries and preserve the original total payout amount.
-
I-04 Informational Missing issuer auth on OApp LzSend Validation Acknowledged
Description
OApp
LzSendis controlled solely by the sender, but the request factory interface for creating a cross-chain send requires both sender and issuer authorization. The OApp flow constructs the request with issuer set to the token admin, yet it does not require the admin to be a submitter. As a result, the transaction succeeds even when the issuer is absent from the submitter set, which bypasses the intended issuer co-sign requirement.A realistic scenario is Alice holding OFT tokens for a regulated instrument. The issuer expects to approve every cross-chain transfer by co-signing the request. Alice calls OApp
LzSendon the factory using only her own party and thelzEndpointas submitters. The system locks her holding, splits payouts and creates anLzSendRequestthat records the issuer as the admin, even though the admin never authorized the transaction. Off-chain relayers that watch for requests and trust the issuer field now see a seemingly authorized request and proceed with cross-chain delivery. The issuer loses control over outbound cross-chain transfers and the request queue can be flooded with unauthorized requests, creating operational risk. This goes against any issuer control and any compliance policy that requires issuer review.This mismatch also breaks rollback expectations. A missing issuer authorization should cause the request creation step to fail and roll back all intermediate artifacts, but the system allows the transaction to complete. In practice, any token holder can initiate a cross-chain transfer that off-chain relayers will treat as issuer approved, because the request records the issuer/admin even though they did not sign. It also enables request flooding that the issuer or relayer must triage, creating an operational risk and potentially allowing unauthorized transfers to execute on the destination chain.
Recommendation
Make issuer authorization explicit and enforce it at the entry point. Require the issuer/admin to co-sign OApp
LzSend, or move request creation into a choice that is directly controlled by the issuer so that missing issuer authorization fails before any side effects occur. Keep the interface contract and the implementation consistent so that the stated sender plus issuer policy is actually enforced. Add or keep a regression test that submits OAppLzSendwithout the issuer and verifies it fails with an authorization error and rolls back all artifacts. -
I-05 Informational Cannot burn locked holdings in BurnMint Validation Acknowledged
Description
The
executeBurnMintfunction allows the admin to burn input holdings and mint new holdings for specified outputs. In addition to that, only checks blacklist for output owners, not input holding owners, allowing admin to burn holdings of blacklisted parties. However, it requires all input holdings to be unlocked (mustBeUnlocked oft.lock).This can prevent burning locked holdings, even when the admin has authorization to burn them.
Example Scenario
- Admin creates Oft manually for a party (bypasses factory validation)
- Admin locks the Oft with
lockers = [party](admin is NOT a locker) - Party gets blacklisted
- Admin cannot burn the locked holding because:
mustBeUnlockedcheck fails (holding is locked)- Admin cannot unlock it (admin is not in lockers list)
Recommendation
Consider allowing admin to burn locked holdings for blacklisted parties.
-
I-06 Informational No validation on
SetExpirableAfterBest Practices AcknowledgedDescription
There are no checks in
SetExpirableAfter, allowing arbitrary values to be set, including negative, and excessively large values.-- Admin sets negative expirableAfter (born expired) exerciseCmd requestFactoryCid LzRequestFactory.SetExpirableAfter with newExpirableAfter = seconds (-3600) -- -1 hourSetting Excessively large values:
-- Admin sets expirableAfter to 1000 years exerciseCmd requestFactoryCid LzRequestFactory.SetExpirableAfter with newExpirableAfter = days 365000 -- 1000 yearsRecommendation
Add validation in the
SetExpirableAfterchoice to ensure it is always positive and within specific bounds. -
I-07 Informational No validation on
SetMinLockDurationBest Practices AcknowledgedDescription
There are no checks in
SetMinLockDuration, allowing arbitrary values to be set, including negative ones.-- Admin (maliciously or accidentally) sets a negative minLockDuration exerciseCmd requestFactoryCid LzRequestFactory.SetMinLockDuration with newMinLockDuration = seconds (-3600) -- -1 hourAdditionally, there is no validation preventing excessively large values.
-- Admin sets minLockDuration to 100 years exerciseCmd requestFactoryCid LzRequestFactory.SetMinLockDuration with newMinLockDuration = days 36500 -- 100Recommendation
Ensure that
minLockDurationis always positive and within specific bounds. -
I-08 Informational DoS Window on New Cross-Chain Sends Upgradeability Acknowledged
Description
The
OftFactorystores a reference toLzRequestFactory:template OftFactory with requestFactoryCid : ContractId ILzRequestFactory -- STORED reference ...When users initiate cross-chain sends, this reference is passed to
lzSend:-- OftFactory.daml:205 lzSend instrumentId localDecimals sharedDecimals issuerFeeBps transferRuleCid requestFactoryCid lzEndpoint sender sendParam oftReceivers oftReceiverAmounts feeCid oAppCidWhich then exercises it to create the request:
-- OAppImpl.daml:210 exercise requestFactoryCid ILzRequestFactory.LzSendRequest_Create with sender issuer = instrumentId.admin ...Problem: When admin updates
LzRequestFactoryvia consuming choices:SetMinFeeSetMinLockDurationSetExpirableAfter
The old factory is archived, but
OftFactorycontracts still reference the oldrequestFactoryCid. So any OApp_LzSend request sent by the user will fail cause we are calling archived contract.Recommendation
Ensure that
SetRequestFactoryis called immediately after any of these functions are invoked.:SetMinFeeSetMinLockDurationSetExpirableAfter
-
I-09 Informational Unused Errors Best Practices Acknowledged
Description
Multiple errors are unused in the codebase:
errLzSend_NoOutputerrOft_SenderBlacklistederrOft_ReceiverBlacklistederrOftTransfer_SenderReceiverSameerrOftAllocation_InstrumentMismatcherrOftAllocation_AmountNotPositiveerrLzSendRequest_UnauthorizedRejecterrLzSendRequest_RejectTooEarlyerrLzSendRequest_UnauthorizedExpireerrOftTransfer_InstrumentMismatch
Recommendation
Consider removing the unused errors.
-
I-10 Informational Sender Visibility Inconsistency in Request Event Best Practices Acknowledged
Description
In
lzSendRequest.daml, the emitted event templates explicitly include the sender as an observer, so the user always has visibility.In contrast, the event templates in
Request.daml(RequestAcceptedEvent,RequestRejectedEvent,RequestExpiredEvent) only use the observers list provided at request creation time and do not automatically add the sender as an observer.This results in inconsistent behavior compared to
lzSendRequest.daml.Recommendation
Document this requirement clearly: the request sender must include themselves in the observers list when creating a Request.
-
I-11 Informational Transfer rule upgrade can brick pending requests Upgradeability Acknowledged
Description
OftTransferOfferstores a concretetransferRuleCidand always exercises that rule on accept/reject. If the admin upgrades the transfer rule and archives the old rule contract, any pending offers that reference the old rule become unprocessable (accept/reject/withdraw fail). This is a liveness hazard during upgrades.Recommendation
Keep old rules alive until outstanding offers are resolved, or route offers through a stable registry/key so they always resolve to the current rule. Alternatively, implement an upgrade-safe indirection or migrate outstanding offers.
-
I-12 Informational Developed testing suite Documentation Acknowledged
Description
Testing suite link: a Guardian proof of concept
This entry records the test validation work performed in the daml/test_validation_spec suite. New test modules were implemented to cover additional edge cases, interface bypass attempts, cross-factory isolation, request and fee lifecycle integrity and
lzReceivevalidation and the suite was executed to generate transaction traces and confirm behavioral outcomes. TheTESTS_BY_CONTRACTindex was regenerated from the latest JUnit output and augmented with human readable descriptions derived from source comments to ensure the catalog reflects the current test inventory and pass or fail status. Full suite execution produced transaction logs and summaries that were used to correlate failing tests with expected policy gaps and behavior mismatches. The work provides a traceable record of what was tested, how it was run and which areas are intentionally failing due to missing validations.The full suite was executed with a transactions output path and JUnit output to preserve an auditable trail of test results and traces.
daml test --package-root daml/test_validation_spec --transactions-output daml/test_validation_spec/tx_logs/latest --junit daml/test_validation_spec/daml_test_validation_spec_latest.xmlRecommendation
No recommendation. Informational record only.
No findings match.
More from LayerZero
All 7 reports-
Canton VER Updates
169 findings2 critical · 12 high 169 findings: 2 critical, 12 high, 36 medium, 54 low, 65 informational -
Console EVM Updates
4 findings 4 findings: 1 low, 3 informational -
Solana Console
42 findings 42 findings: 6 low, 36 informational -
Solana OApp
54 findings 54 findings: 1 medium, 9 low, 44 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.