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
| Piece | What it does |
|---|---|
| TBillToken | ERC-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. |
| RwaNavFeed | NAV 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. |
| Subgraph | The 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 + CCIP | Chainlink Automation keeps NAV fresh; CCIP enables a cross-chain deposit path. Both live on Sepolia with cited evidence in the repo. |
| dApp | Vite + 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
castcommands 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.mdcarries the threat model and the complete Slither triage.