Reach out for an audit or to learn more about Macro
or Message on Telegram

Silicon A-4

Security Audit

Mar 12, 2026

Version 1.0.0

Presented by 0xMacro

Table of Contents

Introduction

This document includes the results of the security audit for Silicon's smart contract code as found in the section titled ‘Source Code’. The security audit was performed by the Macro security team from Mar 5th 2026 to Mar 9th 2026.

The purpose of this audit is to review the source code of certain Silicon Solidity contracts, and provide feedback on the design, architecture, and quality of the source code with an emphasis on validating the correctness and security of the software in its entirety.

Disclaimer: While Macro’s review is comprehensive and has surfaced some changes that should be made to the source code, this audit should not solely be relied upon for security, as no single audit is guaranteed to catch all possible bugs.

Overall Assessment

The following is an aggregation of issues found by the Macro Audit team:

Severity Count Acknowledged Won't Do Addressed
Medium 2 - - 2
Code Quality 5 - - 5

Silicon was quick to respond to these issues.

Specification

Our understanding of the specification was based on the following sources:

Source Code

The following source code was reviewed during the audit:

Specifically, we audited the following contracts within this repository:

Source Code SHA256
contracts/src/FifoEscrow.sol

d8a0d335ab96ecceb21efb7f703742d03339ff630f1ab824c6bf4543737ab77d

Note: This document contains an audit solely of the Solidity contracts listed above. Specifically, the audit pertains only to the contracts themselves, and does not pertain to any other programs or scripts, including deployment scripts.

Issue Descriptions and Recommendations

Click on an issue to jump to it, or scroll down to see them all.

Security Level Reference

We quantify issues in three parts:

  1. The high/medium/low/spec-breaking impact of the issue:
    • How bad things can get (for a vulnerability)
    • The significance of an improvement (for a code quality issue)
    • The amount of gas saved (for a gas optimization)
  2. The high/medium/low likelihood of the issue:
    • How likely is the issue to occur (for a vulnerability)
  3. The overall critical/high/medium/low severity of the issue.

This third part – the severity level – is a summary of how much consideration the client should give to fixing the issue. We assign severity according to the table of guidelines below:

Severity Description
(C-x)
Critical

We recommend the client must fix the issue, no matter what, because not fixing would mean significant funds/assets WILL be lost.

(H-x)
High

We recommend the client must address the issue, no matter what, because not fixing would be very bad, or some funds/assets will be lost, or the code’s behavior is against the provided spec.

(M-x)
Medium

We recommend the client to seriously consider fixing the issue, as the implications of not fixing the issue are severe enough to impact the project significantly, albiet not in an existential manner.

(L-x)
Low

The risk is small, unlikely, or may not relevant to the project in a meaningful way.

Whether or not the project wants to develop a fix is up to the goals and needs of the project.

(Q-x)
Code Quality

The issue identified does not pose any obvious risk, but fixing could improve overall code quality, on-chain composability, developer ergonomics, or even certain aspects of protocol design.

(I-x)
Informational

Warnings and things to keep in mind when operating the protocol. No immediate action required.

(G-x)
Gas Optimizations

The presented optimization suggestion would save an amount of gas significant enough, in our opinion, to be worth the development cost of implementing it.

Issue Details

M-1

FIFO queue selling is disabled once the vault has more than 200 active schedule classes

Topic
DoS
Impact
High
Likelihood
Low

In FifoEscrow, the depositNft() function first calls INftVault(VAULT).depositFixed() to deposit an NFT into the vault and mint SPT, then calls _walkAndRecordFill() which reads the pool NAV via INftVault(VAULT).getPoolNAV() to price the FIFO queue allocations.

Inside NftVaultV1.depositFixed(), after processing the deposit, _processEachDeposit() calls _invalidateNAVCache() which zeros out the cached NAV state (cachedNAV = 0, navCacheStartTime = 0, navCacheComplete = false).

When _walkAndRecordFill() subsequently calls getPoolNAV(), the vault checks whether the number of active schedule equivalence classes exceeds NAV_CHUNK_SIZE (200). If it does, getPoolNAV() requires a fresh completed cache via _isNavFresh(). Since the cache was just invalidated by depositFixed(), this check fails and the call reverts with FreshNAVRequired.

Each depositFixed() call at a different block.timestamp creates a new schedule equivalence class because _computeScheduleHash() includes block.timestamp in the hash. This means the number of active schedule classes grows approximately linearly with the number of deposit transactions (net of redemptions). Once 201 unique schedules are active, any depositNft() call that attempts to sell SPT to the FIFO queue reverts, disabling the escrow's core swap functionality. Users can still deposit NFTs by setting retentionBps = 10_000 to retain 100% of their SPT, but this bypasses the FIFO queue entirely and defeats the purpose of the escrow contract.

Remediations to Consider

Read both INftVault(VAULT).getPoolNAV() and SPT.totalSupply() before calling depositFixed(), and pass those values into _walkAndRecordFill(). The price ratio totalSupply / NAV is preserved across a depositFixed() call (the vault mints proportional tokens), so pre-deposit values yield the same SPT pricing as post-deposit values. This avoids the stale-cache revert entirely with no extra gas cost.

M-2

Mismatched dust thresholds cause depositNft() to randomly revert

Topic
DoS
Status
Impact
Medium
Likelihood
Medium

In _walkAndRecordFill(), during a partial fill, pUsdc is calculated as remaining * nav / totalSupply. If pUsdc == 0, the loop breaks early without allocating the remaining SPT. After the loop, if anyAllocated && remaining > 1_000, the transaction reverts.

These two thresholds are significantly mismatched. The pUsdc == 0 condition is satisfied when remaining < totalSupply / nav. For example, if SPT is worth $1 with USDC having 6 decimals and SPT having 18 decimals, 1 wei USDC would equal approximately 1e12 SPT wei. This means pUsdc == 0 is reached when remaining < ~1e12, but the revert occurs when remaining > 1_000.

Any remaining value in the range (1_000, ~1e12) triggers both conditions: the pUsdc == 0 break AND the remaining > 1_000 revert. This is a wide window spanning roughly 1 billion possible values. The leftover remaining after full fills is an arithmetic residue that depends on the exact queue composition and current NAV — effectively arbitrary. NFT depositors cannot predict or control this value and have no workaround when their transaction lands in this range.

Remediations to Consider

Forward dust SPT to the last allocated recipient when pUsdc == 0, matching the existing dust-forwarding logic that is currently unreachable:

 if (pUsdc == 0) {
+    if (lastAllocated != type(uint256).max) {
+        sptOwed[lastAllocated] += remaining;
+        remaining = 0;
+    }
     break;
 }

Reference: src/FifoEscrow.sol#L336

Q-1

SENTINEL constant uses uint128 type requiring unnecessary casting

Topic
Code Quality
Status
Quality Impact
Low

The SENTINEL constant is defined as uint128 but is consistently cast to uint256 throughout the codebase whenever it is used (e.g., uint256(SENTINEL) in comparisons with listHead, listTail, _nexts, and _prevs). Since all linked list indices and related state variables are uint256, the uint128 type adds unnecessary casting operations and reduces code clarity without providing any benefit. Consider defining SENTINEL directly as uint256:

- uint128 internal constant SENTINEL = type(uint128).max;
+ uint256 internal constant SENTINEL = type(uint128).max;

Reference: src/FifoEscrow.sol#L62

Q-2

Redundant check for _sptRetained > 0 when sptForSale is zero

Topic
Code Quality
Status
Quality Impact
Low

In depositNft(), when sptForSale == 0, there is a check if (_sptRetained > 0) before transferring SPT. This condition is always true and therefore redundant. The function already reverts if sptMinted == 0, and since sptMinted = sptForSale + _sptRetained, when sptForSale == 0, it follows that _sptRetained == sptMinted > 0 is guaranteed.

Consider removing the redundant condition:

  if (sptForSale == 0) {
-     if (_sptRetained > 0) { 
-         SPT.safeTransfer(msg.sender, _sptRetained);
-     }
+     SPT.safeTransfer(msg.sender, _sptRetained);
      return (0, _sptRetained);
  }

Reference: src/FifoEscrow.sol#L260-L264

Q-3

Unreachable condition pUsdc > amt in partial fill logic

Topic
Code Quality
Status
Quality Impact
Low

In _walkAndRecordFill(), the partial fill branch includes a check if (pUsdc > amt) that caps pUsdc to amt. This condition can never be true. The partial fill branch is only entered when remaining < sptNeeded, where sptNeeded = ⌊amt × totalSupply / nav⌋. Since pUsdc = ⌊remaining × nav / totalSupply⌋, mathematical derivation shows pUsdc < amt is always guaranteed. Both floor operations compound in the same direction, making pUsdc strictly less than amt. The guard is dead code.

Consider removing the unreachable condition:

  pUsdc = Math.mulDiv(remaining, nav, totalSupply);
- if (pUsdc > amt) {
-     pUsdc = amt;
- }

Reference: contracts/src/FifoEscrow.sol#L333

Q-4

Partial fill amounts update is unnecessarily deferred outside the loop

Topic
Code Quality
Status
Quality Impact
Low

In _walkAndRecordFill(), when a partial fill occurs, the loop sets pIdx, pUsdc, sptOwed[cursor] += remaining, and remaining = 0 which exits the loop. However, the corresponding amounts[pIdx] -= pUsdc update is performed in a separate block after the loop. Since the partial fill branch sets remaining = 0, the loop always terminates immediately after. Consolidating the amounts update into the partial fill branch would improve code readability and make the partial fill logic self-contained, matching how full fills handle all their state updates inline.

Consider moving amounts[cursor] -= pUsdc block into the partial fill branch, alongside the other state updates for that code path.

Q-5

Unreachable amounts[pIdx] == 0 branch after partial fill subtraction

Topic
Code Quality
Status
Quality Impact
Low

In _walkAndRecordFill(), after a partial fill, amounts[pIdx] -= pUsdc is executed, followed by a check if (amounts[pIdx] == 0). This condition can never be true. The partial fill branch is only entered when remaining < sptNeeded, where sptNeeded = ⌊amt * totalSupply / nav⌋. Since pUsdc = ⌊remaining * nav / totalSupply⌋, mathematical derivation shows pUsdc < amt strictly. Therefore amounts[pIdx] - pUsdc >= 1 always, making the entire amounts[pIdx] == 0 block dead code.

Consider removing the unreachable branch:

  amounts[pIdx] -= pUsdc;
- if (amounts[pIdx] == 0) {
-     filledCount++;
-     _splice(pIdx);
-     _nexts[pIdx] = 0;
-     _prevs[pIdx] = 0;
-     pIdx = type(uint256).max;
-     pUsdc = 0;
-     pSpt = 0;
- }

Reference: contracts/src/FifoEscrow.sol#L352-L363

Disclaimer

Macro makes no warranties, either express, implied, statutory, or otherwise, with respect to the services or deliverables provided in this report, and Macro specifically disclaims all implied warranties of merchantability, fitness for a particular purpose, noninfringement and those arising from a course of dealing, usage or trade with respect thereto, and all such warranties are hereby excluded to the fullest extent permitted by law.

Macro will not be liable for any lost profits, business, contracts, revenue, goodwill, production, anticipated savings, loss of data, or costs of procurement of substitute goods or services or for any claim or demand by any other party. In no event will Macro be liable for consequential, incidental, special, indirect, or exemplary damages arising out of this agreement or any work statement, however caused and (to the fullest extent permitted by law) under any theory of liability (including negligence), even if Macro has been advised of the possibility of such damages.

The scope of this report and review is limited to a review of only the code presented by the Silicon team and only the source code Macro notes as being within the scope of Macro’s review within this report. This report does not include an audit of the deployment scripts used to deploy the Solidity contracts in the repository corresponding to this audit. Specifically, for the avoidance of doubt, this report does not constitute investment advice, is not intended to be relied upon as investment advice, is not an endorsement of this project or team, and it is not a guarantee as to the absolute security of the project. In this report you may through hypertext or other computer links, gain access to websites operated by persons other than Macro. Such hyperlinks are provided for your reference and convenience only, and are the exclusive responsibility of such websites’ owners. You agree that Macro is not responsible for the content or operation of such websites, and that Macro shall have no liability to your or any other person or entity for the use of third party websites. Macro assumes no responsibility for the use of third party software and shall have no liability whatsoever to any person or entity for the accuracy or completeness of any outcome generated by such software.