Yellow engaged Guardian to review the security of its Nitewatch Contracts. From March 2nd through March 6th, a team of 2 auditors reviewed the source code in scope.
- Published
- Language
- Solidity
- Chains
- Ethereum
- Sector
- Infrastructure
- 0 Critical
- 0 High
- 0 Medium
- 3 Low
- 9 Informational
Scope
Overview
Yellow engaged Guardian to review the security of its Nitewatch Contracts. From March 2nd through March 6th, a team of 2 auditors reviewed the source code in scope.
Findings 12
-
L-01 Low RemoveSingners Can Fail Due To Old Threshold Logical Error Acknowledged
Description
The removeSigners function removes the signers first and then sets the new threshold.
_removeSigners(signersToRemove.toAddressBytesArray());_setThreshold(newThreshold);However, the internal
MultiSignerERC7913._removeSignersfunction always performs the_validateReachableThresholdcheck. Since the threshold has not yet been updated at this stage, the validation is executed against the previous threshold value.As a result, any attempt to remove enough signers that the old threshold becomes unattainable fails even if the caller passes a smaller
newThresholdRecommendation
Set the new threshold first and then remove the signers.
Resolution
Yellow Team: Acknowledged.
-
L-02 Low Signer Front-Runs Removal To Finalize Withdrawal Frontrunning Acknowledged
Description
A signer about to be removed can observe the pending
removeSignerstransaction in the mempool and front-run it by submitting afinalizeWithdrawcall with higher gas. Because the removal has not yet been executed, the signer remains in the active set and passes theonlySignermodifier.When a pending withdrawal is one approval short of the threshold, this front-run pushes the approval count over, triggering
_executeWithdrawaland transferring the funds. TheremoveSignerstransaction confirms afterward — the signer is removed, but the funds are already gone.The remaining signers have no way to prevent this. Even if they no longer want the withdrawal to proceed — whether due to changed circumstances, a revised signer configuration, or simply reconsidering the request —
rejectWithdrawis only callable afterOPERATION_EXPIRY(1 hour). There is no on-chain cancellation mechanism during the active window.Recommendation
Consider acknowledging this behavior so that signers are aware of it.
Resolution
Yellow Team: Acknowledged.
-
L-03 Low Re-added Signer's Stale Approval Is Resurrected Validation Acknowledged
Description
In
ThresholdCustody.sol, the_countValidApprovalsfunction iterates all current signers and checkswithdrawalApprovals[withdrawalId][s]. When a signer is removed viaremoveSigners, their entry inwithdrawalApprovalsmapping is NOT cleared, it remainstrue.If that same address is later re-added via
addSignerswithin the OPERATION_EXPIRY window of a pending withdrawal, the old approval is counted again without the re-added signer explicitly callingfinalizeWithdraw.Recommendation
Consider storing and validating the timestamp when the signer was added and validating it against the request creation date in
_countValidApprovals, or acknowledging this behavior if it is intended.Resolution
Yellow Team: Acknowledged.
-
I-01 Informational Phantom Withdrawals Corrupt Rate Limits (OOS) Informational Acknowledged
Description
InvalidThresholderror is defined inThresholdCustodycontract but never used.Recommendation
Remove unused error.
Resolution
Yellow Team: Acknowledged.
-
I-02 Informational Unused Error In ThresholdCustody Logical Error Acknowledged
Description
In service.go, the
processWithdrawalfunction treats a successfulfinalizeWithdrawtransaction as a completed withdrawal. However, in the ThresholdCustody logic, a successfulfinalizeWithdrawcall does not necessarily mean the withdrawal was executed. The function records the signer’s approval, but executes the withdrawal only whenvalidApprovals >= request.requiredThreshold. If the threshold is not yet reached, the transaction succeeds, records an additional approval and the withdrawal remains pending. Despite this, the worker records the withdrawal in the database as approved and finalized.This creates a mismatch between the off-chain accounting state and the actual contract state. In particular,
receipt.Status == 1only confirms that the transaction did not revert - it does not guarantee that funds were transferred or that the withdrawal was finalized. Because the database is later used to calculate hourly and daily withdrawal totals, pending withdrawals may be counted as completed withdrawals even though no assets have actually left custody. As a result, the rate-limiting mechanism can be manipulated or poisoned, causing legitimate withdrawals to be blocked based on phantom usage.Recommendation
Update the worker so that it records a withdrawal only after the contract has actually finalized it on-chain.
processWithdrawalshould stop usingreceipt.Status == 1as the completion condition and instead verify that a matchingWithdrawFinalizedevent (withsuccess == true) was emitted. IffinalizeWithdrawsucceeds but no such event is observed, the request should remain pending and must not be included in the withdrawal totals used for rate limiting.Resolution
Yellow Team: Acknowledged.
-
I-03 Informational Re-added Signer's Stale Approval Is Resurrected Best Practices Acknowledged
Description
The
startWithdrawfunction creates a withdrawal request but does not count the calling signer as an approver. The signer who initiates the withdrawal must callfinalizeWithdrawseparately to register their own approval.The caller already passed the
onlySignercheck, so the contract knows they are a valid signer, but their approval is not recorded. For a threshold of 1 this means two transactions are needed where one should be enough. For higher thresholds it adds an extra round-trip for the initiator.Recommendation
Record the caller's approval inside
startWithdrawso the initiating signer does not need a second transaction.Resolution
Yellow Team: Acknowledged.
-
I-04 Informational startWithdraw Does Not Auto-approve For Caller Logical Error Acknowledged
Description
In
processWithdrawal, the worker attempts to callrejectWithdrawimmediately when a withdrawal fails the off-chain policy checks. However, the contract allows rejection only after the request has expired:require(block.timestamp > request.createdAt + OPERATION_EXPIRY, WithdrawalNotExpired());
As a result, when the worker receives a fresh
WithdrawStartedevent and immediately tries to reject it, the call will typically revert because the expiry window has not yet passed. This means the worker cannot actually reject policy-violating withdrawals at the moment they are detected. Once the rejection attempt fails, the service records the event as an error and treats it as processed, which prevents the same request from being retried later when it does become eligible for rejection. Consequently, this leaves policy-violating withdrawals unresolved in a pending state and may allow them to be finalized by other signers before expiry.Recommendation
If immediate off-chain policy veto is required, add a dedicated on-chain path. Introduce a separate function that is callable only by a dedicated Nitewatch role operated by the off-chain worker, not by arbitrary signers. If ThresholdCustody is intended to remain committee-driven, do not use the current worker with ThresholdCustody.
Resolution
Yellow Team: Acknowledged.
-
I-05 Informational Contract Bypasses Off-Chain Rate Limits (OOS) Informational Acknowledged
Description
newThresholdis defined asuint256in all hardcoded type hashes (e.g.,SET_THRESHOLD_TYPEHASH), while the corresponding function parameters useuint64newThreshold. Although this does not affect the resulting hash, aligning the types would reduce the risk of confusion or incorrect signing by off-chain tooling.Recommendation
Ensure the
newThresholdtype is consistent between the function parameters and the hardcoded type hashes.Resolution
Yellow Team: Acknowledged.
-
I-06 Informational Type Inconsistency In ThresholdCustody Logical Error Acknowledged
Description
The withdrawal rate-limiting logic exists only in the off-chain worker and is not enforced by ThresholdCustody itself. This is especially problematic when the threshold is set to 1, because
startWithdrawimmediately executes the withdrawal in that case. As a result, funds can leave custody before the worker has any opportunity to apply the configured policy checks, allowing the off-chain rate limits to be bypassed.Recommendation
If withdrawal rate limiting is intended security control, it should be enforced on-chain before a withdrawal can be executed. The contract should validate the limits during the withdrawal flow, rather than relying exclusively on the off-chain worker. If it is intended to keep policy evaluation off-chain, the contract should be adjusted so that execution cannot occur without an explicit approval from the off-chain component. As a short-term mitigation, avoid configurations with
threshold = 1, since that setup allows withdrawals to be completed before any off-chain checks can run.Resolution
Yellow Team: Acknowledged.
-
I-07 Informational Informational Note For Users Informational Acknowledged
Description
The protocol does not maintain on-chain accounting. All accounting is performed off-chain based on on-chain events. Users should be aware that they cannot track or view their balances directly on-chain and must use the protocol UI.
Recommendation
This is an informational issue intended to highlight the protocol design for readers and users. No changes are required.
Resolution
Yellow Team: Acknowledged.
-
I-08 Informational Misleading README In Nitewatch Documentation Acknowledged
Description
The README in the Nitewatch repository states: " The **Event Daemon** waits for the outcome (
WithdrawFinalizedorWithdrawRejected), fires an internal event, and NeoDAX debits the balance upon successful confirmation."However, the custody contract only emits the
WithdrawFinalizedevent with abool successparameter. It emitstruefor successful withdrawals andfalsefor rejected ones, and does not emit a WithdrawRejected event.Recommendation
Consider updating the README to reflect the actual contract behavior. Also, ensure that off-chain listeners do not rely on a
WithdrawRejectedevent and instead correctly differentiateWithdrawFinalizedevents based on the booleansuccessvalue.Resolution
Yellow Team: Acknowledged.
-
I-09 Informational CEI Pattern Not Followed Best Practices Acknowledged
Description
The
_executeWithdrawalfunction clears storage variables after performing an unsafe external call. While all important functions use thenonReentrantmodifier and therequest.finalizedflag is set to true before the external call, preventing possible reentrancy, it is recommended to update all state variables before making an external call to an arbitrary address.Recommendation
Update state variables before making external calls as a best practice.
Resolution
Yellow Team: Acknowledged.
No findings match.
More from Yellow Network
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.
