Guardian's review of Nucleus Boring Vault for Paxos Labs, published July 2026. The report records 11 findings, including 2 medium and 4 low.
- Published
- Review window
- June 22 to 24, 2026
- Language
- Solidity
- Chains
- Ethereum, Base, Hyperliquid
- Sector
- Yield and vaults
- 0 Critical
- 0 High
- 2 Medium
- 4 Low
- 5 Informational
Findings 11
-
M-01 Medium Sub-dust order queues with zero amountDue Validation Acknowledged
Description
_pushOrderderives the want owed asamountDue = _toTokenDecimals(offerAmountNormalized18AfterFees, wantAsset.decimals())._toTokenDecimalsfloors toward zero, so when the offer asset has more decimals than the want and the collected net is small,amountDuetruncates to zero (TransitStation.sol:643) and the order is still queued — no revert.This contradicts the project's design documentation, which states that sub-dust orders revert. No such guard exists: there is no
ZeroAmountDueerror and no zero-check in_pushOrderor_verifyAndCollect. The only size guard, the fee-vs-offer partition (TransitStation.sol:580), ensures a net of just one offer-token unit — far below the want-truncation threshold.The net was already pulled to
offerReceiver, so the submitter paid while the queued order owes zero want. It is born "fully filled":executePendingOrdersrejects any positive fill withAmountExceedsDue(due is 0), and a zero fill removes it delivering nothing. Worse, a dead order co-batched with healthy ones leaves the vault's approval partly unspent, tripping the per-batchResidualApprovalcheck and reverting the whole batch.Recommendation
Enforce a minimum order size on the source chain, in
_verifyAndCollect, so sub-dust orders are rejected before any funds move. This is the only safe fix for the bridged path: a destination-side revert there would strand a LayerZero message whose source funds were already collected. As a backstop for same-chain submissions, after derivingamountDuein_pushOrder, revert with a dedicatedZeroAmountDueerror when it is zero — matching the behavior the design documentation already specifies. -
M-02 Medium Refunded in-flight order can execute Logical Error Resolved
Description
Transit’s admin removal/refund model relies on manual/off-chain refunds, but has no on-chain tombstone for cross-chain orders that were submitted on the source chain and have not yet been queued on the destination. A cross-chain submit pulls the user’s offer asset to offerReceiver, then sends abi.encode(OrderTerms) through LayerZero. The destination only records the order once _lzReceive() succeeds and calls _pushOrder(). If delivery is delayed or reverts before _pushOrder(), the order is in-flight but absent from pendingOrderIds. In that state, forceRemovePendingOrder(uuid) cannot help because it requires the UUID to already be pending. If ops manually refunds the user while the LayerZero packet remains retryable, no Transit state marks the UUID as refunded. Later, after the delivery issue is fixed, the same authenticated packet can be retried. _pushOrder() only checks pendingOrderIds.contains(terms.uuid), so it accepts the refunded UUID and queues it. This is not limited to a one-off stuck packet. If LayerZero delivery or destination execution is degraded for a route, many source-chain submissions can accumulate as in-flight orders that are absent from destination pendingOrderIds. A user who observes the outage can submit multiple orders and later request refunds for all of them. If ops refunds from source-side records while those packets remain retryable, each packet can later be delivered and executed, multiplying the double-payout impact. The executor can then fill the orders normally, causing users to keep the manual refund and also receive the want asset. The issue is that a refunded economic obligation is not made terminal on-chain. LayerZero has packet-level recovery tools such as clear, nilify, and burn. The OApp or configured delegate can call these EndpointV2 recovery functions, and burn makes a packet permanently unexecutable when its preconditions are met. However, Transit does not enforce use of any packet-level recovery before refunds and does not maintain an application-level refunded/cancelled UUID set. Safety therefore depends entirely on off-chain runbook discipline.
Recommendation
Add terminal state for refunded/cancelled UUIDs, checked in _pushOrder() before adding to pendingOrderIds. Add an admin function to mark in-flight UUIDs as cancelled/refunded before any off-chain refund. If forceRemovePendingOrder() is part of a refund flow, it should tombstone the UUID too. Operationally, require manual refunds to either tombstone the UUID in Transit or prove the exact LayerZero packet has been terminally cleared, burned, skipped, or otherwise made unexecutable first. nilify can be used as a temporary quarantine, but refunds should not rely on it as the final terminal state unless the packet cannot later be re-verified and executed.
-
L-03 Low Version-skewed payload corrupts order Logical Error Acknowledged
Description
Transit sends cross-chain orders as raw abi.encode(OrderTerms) bytes. The receiver authenticates only the local LayerZero endpoint and configured peer, then decodes the bytes directly as the current OrderTerms type. There is no Transit-level message type, schema version, type hash, or exact payload-length check. The LayerZero/OApp version exposed by oAppVersion() is not a Transit payload schema version and is not checked before decoding application message bytes. This is unsafe during migrations of normal non-upgradeable deployments. Transit versions are deployed as new contracts, and OrderTerms is the cross-chain wire format. If a source and destination station are ever configured across different OrderTerms schemas, Solidity ABI decoding will not fail closed by itself. Solidity ABI encoding is not self-describing and requires the decoder to know the expected schema. For example, a payload encoded as (bytes32,address,address,uint32,address,uint256) is accepted when decoded as the current (bytes32,address,address,address,uint256). The fields shift silently: current offerAsset becomes the old sourceEID word interpreted as an address, and current offerAmountNormalized18AfterFees becomes the old offerAsset address interpreted as a uint256. The trailing amount word is ignored. This can happen during staggered redeploys or peer rotation if a new receiver is configured to trust an old-schema source, or if old-schema messages are not drained before changing the trusted source/destination pairing. Instead of failing closed, the destination can queue a corrupted order that requires manual cleanup. If executor automation fills from pending order data without reconciling against source events or backend records, the corrupted amount may also cause an incorrect payout.
Recommendation
Reject old-schema payloads during receive and make migrations drain-first. For the current static OrderTerms layout, require payload.length == 160 before decoding so old six-word payloads fail closed instead of shifting fields. During redeploys or peer rotations, drain, retry, or clear in-flight packets from the old schema before configuring the new station to trust that peer. If Transit later expects multiple live message formats, add an explicit schema version at that point.
-
L-04 Low Stale executePendingOrders decoder Compatibility Resolved
Description
NucleusDecoderAndSanitizerdeclaresexecutePendingOrders(bytes32[],uint256[])(NucleusDecoderAndSanitizer.sol:194), selector0xb4fb0493, butTransitStation.executePendingOrdersnow takesFillBatch[], selector0xd9fe0258. The decoder was never realigned when the fill input changed to per-asset batches.Production fulfilment drives the vault through
ManagerWithMerkleVerification, which staticcalls the decoder keyed bybytes4(targetData). AFillBatch[]call matches no decoder function, falls through to theBaseDecoderAndSanitizerfallback, and revertsFunctionNotImplemented, reverting the whole manage call. ThesubmitOrderdecoder is aligned, so manager-driven deposits work while every manager-driven fill is bricked.This is the live fulfilment path, not a hypothetical: fills currently run through the manager against a wired root. It is masked only because the deployed stations are pre-redesign bytecode whose
executePendingOrdersis still0xb4fb0493, matching the stale decoder. Deploying the auditedFillBatch[]contract behind the unchanged decoder halts all merkle-gated fulfilment, stranding queued orders whose deposit was already collected.Recommendation
Realign the decoder to the current signature — declare
executePendingOrders((address,bytes32[],uint256[])[])inNucleusDecoderAndSanitizerso its selector matchesFillBatch[]and the merkle leaf sanitizes each batch'swantAsset. Add a selector-parity check to the build so a futureTransitStationsignature change cannot silently desync the decoder again. -
L-05 Low Depeg controls don't gate queued fills Validation Acknowledged
Description
Transit settles every approved route at a fixed 1:1 with no on-chain peg, oracle, or freshness check (
TransitStation.sol:326). The 1:1 assumption is an accepted owner trust — only like-valued pegs are approved. The issue is not the depeg risk itself, but that the controls documented to contain a depeg fail for in-flight and queued cross-chain orders.Route de-approval via
setRouteApprovalsis enforced only at submit on the source (TransitStation.sol:569); neither_lzReceivenorexecutePendingOrdersre-checks the route, so de-approving a depegged route stops new submissions but leaves already-queued orders fully fillable. Halting the backend signer only blocks new quotes — quotes already signed are bearer instruments and, once queued, never expire (thedeadlineis an off-chain policy with no on-chain cap).The only remaining on-chain control is
pause(), which is global: stopping one depegged route stops every route and token, and there is no per-route execute freeze. Until an operator pauses globally, an arbitrageur's queued order on the depegged route fills 1:1 andwantAssetSourceabsorbs the full depeg spread on each.Recommendation
Add an execution-side guard so a depegged route cannot be filled: re-check
approvedRoutesinsideexecutePendingOrders, and/or gate fills on a per-route freshness window or a peg sanity bound. Make route de-approval effective against already-queued orders — for example a per-route execute-side freeze — so incident response does not depend on a globalpausethat also halts healthy routes.Cap the
Quotedeadlineon-chain and/or add per-order expiry so pre-signed quotes cannot outlive a depeg response. Document that cross-chain depeg response requires pausing every peer station, since source-side de-approval does not protect orders already in flight. -
L-06 Low Quote signer can redirect receiver Unexpected Behavior Acknowledged
Description
Transit’s docs model quoteSigner as a hot lower-trust key and state that a compromised signer is bounded by derived amountDue, approved routes, and the 10.5% combined fee cap. However, the signed Quote also contains receiver, and Transit only checks that it is nonzero. submitOrder pulls the offer asset from msg.sender, then stores quote.receiver in OrderTerms. Later, executePendingOrders pays the want asset from wantAssetSource to that stored receiver. A compromised signer/backend can therefore return a valid quote with an attacker-controlled receiver. If a user submits it, their offer asset is collected to offerReceiver, while the eventual fill pays the want asset to the attacker. This is not bounded by MAX_PROTOCOL_FEE_BPS or MAX_INTEGRATOR_FEE_BPS, because the loss is not encoded as a fee. The entire payout address is controlled by the same backend signature. The bearer-quote design makes stolen quotes self-griefing only when the signed receiver is honest; it does not protect users from a malicious signed receiver unless the frontend independently verifies quote.receiver against the user’s intended recipient.
Recommendation
Bind the payout receiver to an independently trusted user intent. For direct user submits, require quote.receiver == msg.sender. If relayers or gas sponsors are required, add an explicit user-signed intent or trusted-forwarder scheme that binds the intended receiver separately from the backend quote. At minimum, update the compromised-quoteSigner risk model to state that signer compromise can redirect the full payout, not only skim capped fees.
-
I-02 Informational quoteSend fee wrong for unset gas limit Documentation Acknowledged
Description
quoteSendbuilds the LayerZero fee estimate frommessageGasLimit[destEID]with no zero-check and returns it (TransitStation.sol:470); its@devstates the estimate "matches the real send cost." But_sendOrderrevertsGasLimitNotSetwhen the limit is zero. For an unconfigured destination EID,quoteSendsilently returns a fee computed for a zero-gas option while the real cross-chainsubmitOrderreverts, so the documented equivalence is false exactly when the destination is not yet configured.No value is at risk — the real send reverts safely — but an integrator relying on the quote for an unconfigured EID receives a misleading non-reverting fee.
Recommendation
Make
quoteSendrevertGasLimitNotSetwhenmessageGasLimit[destEID] == 0, mirroring_sendOrder, so the preview and the real send agree. -
I-03 Informational Stale receive-route validation docs Documentation Partially resolved
Description
Some Transit documentation still states that the destination station’s _lzReceive validates the route before recording a bridged order. Current code does not do this: _lzReceive decodes OrderTerms and calls _pushOrder, while route approval is enforced only during source-side submission. Newer sections of the same documentation also say the receive-side route check was intentionally removed, so the docs are internally inconsistent.
Recommendation
Update the stale documentation to consistently state that route approval is checked only on the source station.
-
I-04 Informational Disabled peer can backlog packets Warning Acknowledged
Description
Transit’s committed development notes describe setPeer(eid, 0) as a per-chain kill-switch after PeerChain was removed. In practice, this only blocks app-level delivery into Transit. It does not prevent LayerZero V2 from verifying and storing packets for an already-initialized path. OAppAuthReceiver.allowInitializePath() returns true only when peers[srcEid] == origin.sender, and lzReceive() reverts when the configured peer is zero. However, after a path has delivered once, LayerZero endpoint verification no longer depends on allowInitializePath(): _initializable() returns true when lazyInboundNonce > 0. Therefore, packets from the old peer can still be verified and stored in inboundPayloadHash while peers[srcEid] == 0. This is not an authentication bypass and does not directly move funds. The disabled peer still prevents lzReceive() from queuing the order while the peer is zero. The operational risk is that if operators later restore the same peer without clearing the endpoint backlog, packets verified during the disabled window can be delivered and queue orders. Impact: Operators may misunderstand setPeer(eid, 0) as a full per-chain quarantine. For initialized LayerZero paths, it is only an application-level receive block; retryable verified packets can still accumulate at the endpoint and become executable after the peer is restored.
Recommendation
Document this caveat and include a restore runbook. Before re-enabling the same peer, inspect endpoint backlog for (receiver, srcEid, oldPeer) and clear, nilify, skip, or burn any packets that should not execute. For stronger protection, add an application-level peer epoch or high-water mark and reject packets from stale epochs/nonces even if LayerZero later delivers them.
-
I-06 Informational Same-chain value natspec contradicts code Documentation Resolved
Description
The
submitOrderNatSpec states that the function ispayableto fund the LayerZero native fee and that this value is "unused or refundable for same-chain" (TransitStation.sol:237). The code neither ignores nor refunds value on a same-chain order — it reverts._submitOrderrejects any nonzeromsg.valueon the same-chain branch (destEID == thisChainEID) withSameChainOrdersRequireNoValue(TransitStation.sol:547).A caller relying on the documented "refundable" behavior has the transaction revert instead of succeeding with a refund. For example, an integrator that passes a single fee estimate uniformly to both same-chain and cross-chain submits hits this revert. No funds are at risk; the defect is the mismatch between the documented contract and the actual behavior.
Recommendation
Correct the
submitOrderNatSpec to state that a same-chain order must be submitted withmsg.value == 0, and that any nonzero value reverts withSameChainOrdersRequireNoValue. If a uniform calling convention across both paths is preferred instead, ignore rather than rejectmsg.valueon the same-chain branch; reverting is the safer default, so updating the documentation is the recommended fix. -
I-09 Informational One bad fill DoSes the whole batch DoS Acknowledged
Description
executePendingOrders(TransitStation.sol:326) settles a batch in a single all-or-nothing loop, callingsafeTransferFromper order with no per-filltry/catch. If any one fill reverts, the whole batch reverts — blocking every unrelated, healthy order grouped into it.Two reachable triggers make a fill revert. The production want asset is a USDG BoringVault whose
transferFromruns a compliance freeze hook that reverts for a frozen recipient. A freeze of one queued order's receiver then reverts the whole batch — something the backend cannot predict.Also, an order with
receiver == address(0)transfers to the zero address, which most ERC20s reject._verifyAndCollectguardsreceiver != 0on the source, but_pushOrderdoes not re-check it, so a forged bridged order (past LayerZero peer auth) can plant a zero-receiver order that bricks its batch.The atomic revert is intentional — all-or-nothing avoids silent per-fill failures — and funds are not lost: the backend rebuilds the batch without the offending order, or an admin calls
forceRemovePendingOrder. The residual gap is a transient liveness block of healthy orders co-batched with the bad one, not permanent loss.Recommendation
Keep the all-or-nothing revert — it is a deliberate choice to avoid silent, hard-to-detect fill failures. Fix the avoidable triggers instead of swallowing reverts.
Before assembling a batch, have the backend screen each order's
receiveragainst the want asset's compliance freeze list and exclude any frozen recipient. Keep batches small to bound the blast radius of an unexpected revert. Add areceiver != address(0)re-check in_pushOrderso a forged zero-receiver order can never queue. When a batch still reverts, the executor rebuilds it without the offending order or callsforceRemovePendingOrderto clear it.
No findings match.
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.
