Case study · Flagship

RWA Yield Protocol — an ERC-7540 vault that survived its own audit

A tokenized T-Bill protocol on Sepolia: async vault priced by a NAV oracle, upgraded live (UUPS V1→V2) with real deposits inside, indexed by The Graph, automated with Chainlink. This is the piece where the interesting part isn't the feature list — it's what the adversarial self-audit found, and what happened to each finding.

Solidity 0.8.24ERC-7540UUPSChainlink AutomationCCIPThe GraphFoundrywagmi/viem

The problem

Real-world assets don't settle on-chain. A vault holding tokenized T-Bills can't price shares from its own balance the way a standard ERC-4626 does — the asset's value lives in an external NAV, deposits and redemptions need an operator in the loop (buy/sell the underlying off-chain), and users need a request → fulfill → claim flow instead of instant atomic swaps. That's exactly what ERC-7540 (the standard used by Centrifuge and Maple) models, and building it end-to-end — contracts, oracle, indexing, automation, UI — is the difference between "I read the EIP" and "I shipped it".

What I built

PieceWhat it does
TBillTokenERC-20 for the synthetic RWA (6 decimals). Mint/burn gated by ASSET_MANAGER_ROLE. No logic of its own — the value lives in the feed, not the token.
RwaNavFeedNAV oracle implementing the full Chainlink AggregatorV3Interface. Updates bounded by a ±5% deviation band and a 1-update/hour rate limit — mitigates fat-finger errors and key compromise.
RwaVault (V1→V2)The ERC-7540 async vault: requestDeposit/Redeem → operator fulfill (price fixed there) → user claim. UUPS upgradeable, 4 separated roles, partial pause. V2 adds a 100 bps annual management fee accrued in shares — and it's the code running behind the proxy today, via a real upgrade, not a redeploy.
SubgraphThe Graph Studio indexes every request/fulfill/NAV event; the dApp's Activity and Analytics pages read on-chain history via GraphQL — no backend of my own.
Automation + CCIPChainlink Automation keeps NAV fresh; CCIP enables a cross-chain deposit path. Both live on Sepolia with cited evidence in the repo.
dAppVite + React + wagmi/viem: full request→pending→claimable→claim flow, NAV panel, activity history, analytics, admin panel.

The live upgrade — why it matters

On 2026-07-06, with real deposits already settled in the proxy, I executed upgradeToAndCall(V2, initializeV2(feeBps=100, feeRecipient)). The proxy address never changed; the implementation slot moved from V1 to V2; and the state — shares, totalAssets(), all four operational roles — survived intact. The README ships five copy-paste cast commands so anyone can verify this against the public RPC instead of trusting the prose: read the EIP-1967 slot, read the upgrade receipt's logs (Upgraded, Initialized(2), ManagementFeeInitialized), read the surviving state.

Why this is the senior signal: anyone can deploy a contract. Upgrading one in place, with storage-layout diffs committed (forge inspect storage-layout V1 vs V2), a reinitializer(2) guard, and user funds inside — that's the operation production protocols actually fear, executed and documented so it can be independently verified.

The self-audit: 3 findings, 3 different fates

An audit that "finds nothing" usually means it didn't look hard enough. This one found three things — and the honest part is that each one warranted a different response:

(a) The assets-in-transit window OPS RULE + QUANTIFIED

Between investInTBill pulling USDC out and the corresponding tBillToken.mint landing in a separate tx, totalAssets() is momentarily understated. A fulfillDeposit priced inside that window overpays the new depositor at existing depositors' expense. Decision: not fixed in code. The actor is a trusted role that already has stronger powers; the fix is the operational rule "fulfill only with transit == 0" — but the rule is proven, not stated: a test quantifies the exact damage (Bob receives ~1.67× fair shares; Alice diluted to under $1 of her $1,000 in the limit case) and another proves that closing the window first restores fair pricing to ±0.01%.

(b) ReentrancyGuard through a proxy ASSUMPTION → PROOF

The vault relies on OpenZeppelin's non-upgradeable ReentrancyGuard working correctly with an uninitialized (zero) slot behind a proxy. Correct on paper — but "correct on paper" isn't a standard for the thing guarding every redeem. Tests read the raw slot with vm.load (confirming it was never written), then attack with a malicious ERC-20 whose transfer/transferFrom re-enter mid-call — and confirm ReentrancyGuardReentrantCall. A design assumption became a demonstrated guarantee.

(c) The fulfillRedeem liquidity gap FIXED ON-CHAIN

The one nobody put on the list. The multi-actor invariant campaign found it on its first run: invariant_Solvency failed when the fuzzer reached a state where the vault had promised claimants more liquid USDC than it held — fulfillRedeem recorded NAV-priced claims without checking liquidity, so a user's later redeem() reverted on a promise the contract couldn't keep. Unlike (a), the victim here is a user, not the shared pool — higher standard, so this one got a code fix: fulfillRedeem now caps by _freeAssetBuffer() and reverts with InsufficientLiquidity(requested, available). The original bug-demonstration test was inverted to lock in the new behavior. Commit 77a01b5.

The lesson in one sentence: a table of known attack vectors is necessary but insufficient — the finding that became a real code fix was the one the fuzzer found, not the ones a human thought to list.

Verify it yourself

Every claim above is checkable without trusting me:

  • 140 Foundry tests run offline (unit + fuzz + invariants + attack vectors); 8 more fork tests consume the live Chainlink ETH/USD feed on Sepolia.
  • The README's 5 cast commands verify the upgrade against the public RPC: implementation slot, receipt logs, surviving state, roles, and the live V2 fee.
  • DESIGN.md tells the full 3-findings story; SECURITY.md carries the threat model and the complete Slither triage.