Current status: Testnet only. No real funds. Not audited. Do not deploy to mainnet without completing the items in this document.
| Component | Status | Notes |
|---|---|---|
SentinelVaultFinal |
⚠ Unaudited | Core vault + deposit/withdraw logic |
MEVShieldCore |
⚠ Unaudited | 4-layer MEV protection engine |
YieldOracle |
⚠ Unaudited | Entitlement-based APY data store |
StrategyRegistry |
⚠ Unaudited | Strategy catalog + live TVL queries |
LiquidStakingStrategy |
⚠ Unaudited | FlowIDTableStaking epoch integration |
YieldFarmingStrategy |
⚠ Unaudited | Multi-protocol yield farming |
ArbitrageStrategy |
⚠ Unaudited | Cross-DEX spread detection |
MultiSigAdmin |
⚠ Unaudited | M-of-N multi-sig admin actions |
SentinelInterfaces |
⚠ Unaudited | Shared interface definitions |
| Frontend API routes | ⚠ Unaudited | Wallet auth, settings, export |
No third-party security audit has been conducted. All contracts require professional Cadence audit before mainnet deployment. An internal adversarial self-review (below) found and fixed 2 critical and 1 high-severity fund-safety bugs — that is evidence a real audit is more necessary, not less; self-review by the same party that wrote the code cannot substitute for independent review, however thorough.
Six testnet accounts exist from iterative fix-and-redeploy cycles. Cadence blocks in-place upgrades that change a stored field's type or remove/add contract-level fields, so several rounds of fixes each required a fresh account rather than an update.
| Account | Status | Notes |
|---|---|---|
0x2090c2fa44d12bd8 (original) |
Abandoned — partially broken | YieldFarmingStrategy updated with the honesty fix; MEVShieldCore/SentinelVaultFinal/MultiSigAdmin permanently stuck on old, pre-fix code (upgrade blocked by Cadence's field-type-change rule). Contract removal is also blocked by Flow itself on normal accounts. |
0xd8ec6ade393c65e9 (v2) |
Abandoned | Deployed before flow.json aliases were updated — cross-contract references bound to the wrong account. |
0x37a1e19ae4431b1f (v3) |
Abandoned | Same alias-ordering issue as v2. |
0xc5923d746cbd11a1 (v4) |
Abandoned — superseded | First fully-correct deployment (identity-spoofing fix, quorum bootstrap, real dispatch wiring). Superseded when a deeper bug was found: MultiSigAdmin.Admin's proposals dictionary lived per-resource-instance instead of contract-wide, so a proposal created by one admin account was invisible to a different admin's own Admin resource — cross-account multisig signing never actually worked. Still holds real FLOW and live contracts; not decommissioned, just not the reference build. |
0xc2cda89ef9d88797 (v5) |
Superseded | All 8 contracts fresh-deployed with proposals moved to contract-level state. Verified live: a proposal created by one admin account was signed by a different admin account's own Admin resource and executed with real effect (0.5 FLOW moved into the yield reserve via FundYieldReserve; a real APYUpdated event via UpdateYieldOracle), proving cross-account quorum genuinely works end-to-end. Raised to a real 3-of-3 with a third independent admin account (0xc9e79a07355df9be), also live-verified. Superseded because UpgradeContract's upgradeAuthorizations field can't be added via update() — required a fresh account. |
0xdfb3ff34ac59f3df (v7) |
Superseded | All 8 contracts, including the UpgradeContract gate (quorum approves an exact SHA3-256 code hash; account key executes via transactions/multisig_upgrade_contract.cdc). Live-verified all three properties on real testnet: approved code executes and the approval is consumed; replaying the same approval is rejected; submitting code with no matching approval is rejected. Superseded because the adversarial review below found two critical fund-safety bugs requiring new contract-level fields (approvedUnstakes), which update() can't add. |
0x3bd446e9bda3f300 (v8) — CURRENT / canonical |
✅ Live-verified | All 8 contracts with the adversarial-review fixes (see below). Live-verified: Admin.approveUnstakeCompletion() records a real UnstakeApproved event and getApprovedUnstake() reads it back correctly. |
flow.json's contract aliases currently point at v8 for all 8 project contracts. The six earlier accounts (original, v2–v5, v7) are dangling test artifacts — do not send further transactions to them; do not treat their contract state as authoritative.
Not a third-party audit — see the note at the top of this document. This was a genuine attempt by the development AI to find real bugs before a real auditor does, using a fresh review pass with no knowledge of its own prior fixes' blind spots. Findings, most severe first:
1. CRITICAL — Fixed. No on-chain path existed to return a vault's staked principal. stakeToProtocol() pools every vault's FLOW into one shared FlowStakingCollection delegation; unstake_tokens.cdc/withdraw_unstaked_tokens.cdc only ever returned unstaked FLOW to the shared account's vault, with zero accounting link back to which Sentinel vault it belonged to. Vault.withdraw() can only pull from that vault's own flowVault, which no longer held the staked amount — so once staked, a user's principal had no contract-level path back to them. Fix: added a per-vault approvedUnstakes ledger (SentinelVaultFinal.approvedUnstakes: {UInt64: UFix64}) that the keeper credits via Admin.approveUnstakeCompletion(vaultId, amount) only after genuinely completing the real 2-epoch Flow unstaking process, and a new Vault.completeUnstake(amount) that the vault owner calls to pull exactly their approved amount back into their own flowVault. Live-verified the approval half on testnet v8; the full unstake round-trip needs a real epoch cycle to test (see Testnet Soak Plan).
2. CRITICAL — Fixed. Reward-per-share accounting let a vault that staked (or added stake) after the global reward rate had already risen inherit yield it never earned, paid out of other vaults' real rewards. stakeToProtocol() increased principalStaked without ever moving yieldPerFlowSnapshot forward to the current rate — so claimYield()'s owed = (currentRate - snapshot) * principalStaked computed against a stale (often zero) snapshot. Concretely: attacker stakes 500 FLOW when the real accumulated rate is already 0.02/FLOW from other users' genuine staking, immediately calls claimYield(), and pulls 10.0 FLOW that rightfully belongs to earlier stakers — draining the pool out from under them. Fix: added settlePendingYield(), which pays out any yield already owed at the OLD principal/snapshot (auto-compounding it into the vault's own balance) and only then advances the snapshot to the current rate, called at the start of both stakeToProtocol() and the new completeUnstake().
3. HIGH — Fixed. MultiSigAdmin.removeAdminInternal() had no floor check — removing an admin could drop totalAdmins below requiredSignatures, permanently bricking governance (no remaining quorum could ever again reach the threshold, including on a ChangeThreshold meant to fix it). Fix: removeAdminInternal now panics if the removal would drop totalAdmins below requiredSignatures; the threshold must be lowered first. Live-verified in tests/adversarial_findings_test.cdc::testAdminRemovalCannotBrickQuorum with two real signing accounts.
4. MEDIUM — Documented, not yet fixed. MEVShieldCore.createCommit()/revealExecution() are access(all) with only a vault-existence check, no relation to the actual transaction signer. An attacker watching the mempool can front-run a legitimate commit with the same hash (griefing the real committer's transaction into a revert), and can spam createCommit+revealExecution with garbage data indefinitely — pendingExecutions is only pruned by a real matching execution, so griefing entries accumulate in storage forever with no cleanup path analogous to cleanupExpiredCommits(). Needs a real design pass (likely binding commits to signer identity the same way MultiSigAdmin now does via self.owner), not a quick patch — tracked as a pre-mainnet item.
5. MEDIUM — Documented, not yet fixed. transactions/auto_execute_vault.cdc (the "automated keeper" transaction) doesn't actually call executeStrategyWithMEV — its own comment admits the auth-entitlement path was never finished. The advertised automated execution does not currently run end-to-end, and the same transaction has no restriction on who can call it, making it another vector into finding #4's storage-growth issue. getVaultsDueForExecution() is also a permanent stub returning [].
6. MEDIUM — Documented, not yet fixed. withdrawalFeeBps/managementFeeBps/performanceFeeBps are declared, settable, and surfaced in getProtocolStats() as a "production revenue model," but no code path in deposit(), withdraw(), or claimYield() ever actually deducts them — totalFeesCollected stays 0.0 forever. Not a fund-safety risk, but a stated-vs-actual mismatch a real auditor will flag; needs a decision on whether fees are wired for real before mainnet or the stats/fields are removed.
7. LOW/INFORMATIONAL. MultiSigAdmin.dispatch()'s FundYieldReserve action contradicts realRewardsPool's own doc comment ("funded EXCLUSIVELY by claimRewards... never manually topped up"). Not exploitable, but the comment needs correcting or the action needs removing.
8. INFORMATIONAL. The UpgradeContract hash-gate (verifyAndConsumeUpgrade) is procedural, not cryptographically absolute — nothing in Cadence stops whoever holds the deployer key from calling contracts.update() directly in a transaction that skips the gate. The gate only binds if deployer-key custody itself enforces going through it (e.g., a hardware-wallet signing policy). This is an inherent Flow account-model limitation, not a code bug — flag it explicitly in the deployer-key custody plan.
9. INFORMATIONAL — Fixed. YieldOracle had a dead, unreachable Admin resource using the exact caller-supplied-Address identity pattern already fixed in MultiSigAdmin. Confirmed unreferenced anywhere (no factory, no init() call) and deleted rather than left as a landmine for a future public factory to accidentally expose.
10. INFORMATIONAL. Collection.vaults is access(all) var — currently safe only because published capabilities are typed to the restrictive {CollectionPublic} interface, not the concrete type. Recommend access(self) as defense-in-depth. Collection.deposit()'s dictionary-swap silently destroys any resource previously at a colliding key; not reachable today (vault IDs are unique and monotonic) but worth an explicit pre condition rather than relying on that invariant holding forever.
1. Yield reserve solvency (Medium)
The yieldReserve vault is funded via the 0.1% protocol fee on deposits and manual top-ups. If the reserve balance falls below the accrued yield, claimYield() will pay out a reduced amount (clamped to available balance) rather than the full accrued amount. The remaining unclaimed yield persists in totalYieldAccrued and can be claimed when the reserve is refunded.
Mitigation: ReserveHealthWidget on the dashboard shows CRITICAL/WARNING/HEALTHY status. Operators should monitor the YieldReserveFunded and ProtocolFeeCollected events.
2. Strategy yield is calculated, not always from real protocol calls (Medium)
LiquidStakingStrategy calls FlowIDTableStaking.getEpochTokenInfo() for real epoch APY data — this one generates genuine yield. YieldFarmingStrategy has no IncrementFi/Flowty/FlowSwap connector integrated yet; it does not move funds and reports yieldAmount: 0.0, usedRealProtocol: false on every execution (previously it computed a projected number from oracle math and mislabeled it usedRealProtocol: true — fixed to stop misrepresenting provenance). Phase 3 IncrementFi integration is pending their public Cadence connector release.
Mitigation: StrategyResult.usedRealProtocol flag is logged in every StrategyExecuted event and is now accurate. Frontend displays protocol source transparently.
3. Oracle admin key security (High)
The YieldOracle.OracleAdminResource is stored at the deployer account. If the deployer private key is compromised, an attacker can set arbitrary APY values. High APY values would cause the yield reserve to be rapidly drained.
Mitigation: Store the oracle admin key in HSM or Vault (HashiCorp). Rotate keys before mainnet. The checkPriceDeviation Layer 3 guard in MEVShieldCore limits APY manipulation to 3% slippage before execution is rejected.
4. Global pause requires deployer key (Low) — Mitigation now live
setGlobalPause() uses access(account) — only the contract deployer account can call it directly. MultiSigAdmin is deployed and its EmergencyPause action type is wired to real dispatch, giving a genuine M-of-N path to pause. Bootstrap starts at 1-of-1 (single deployer) and is raised via real AddAdmin/ChangeThreshold governance actions as trusted co-admins are onboarded — currently 3-of-3 on the v5 deployment, live-verified (2 signatures at threshold 3 are correctly rejected with panic: Not enough signatures; 3 execute a real action).
5. Keeper authorization model (Medium)
The strategy-keeper Netlify function uses a service key to submit transactions. The keeper can trigger strategy execution but cannot access user funds directly (it calls executeStrategyWithMEV which requires a pre-committed hash). However, a compromised keeper key could spam execution transactions, draining gas from the keeper wallet.
Mitigation: Rate-limit keeper execution to once per vault per interval. Monitor keeper wallet balance. Rotate keeper key via environment variable update.
6a. MultiSigAdmin identity spoofing — Fixed
proposeAction/signProposal/executeProposal previously trusted a caller-supplied caller: Address parameter. Since MultiSigAdmin.createAdmin() is a public factory, anyone could construct their own Admin resource and pass a real admin's address as caller to bypass the isAdmin() check entirely. Fixed by deriving identity from Cadence's own self.owner?.address (which account currently custodies the resource — unforgeable), never from an argument.
6b. MultiSigAdmin per-resource proposal state — Fixed
Each Admin resource instance stored its own private proposals dictionary. A proposal created via one admin's resource was invisible to a co-signer's separate resource instance (different account, different Admin object) — cross-account multisig signing panicked with Proposal not found and had never actually worked. Fixed by moving proposals/proposalCounter to contract-level state, shared by every Admin resource instance. Live-verified: proposal created by one account, signed by a different account's own resource, executed with real on-chain effect.
6. Commit-reveal window (Low) The commit-reveal deadline is 200 blocks (~3 minutes). Commits that expire before reveal are automatically marked expired and must be resubmitted. Under heavy network load, the 200-block window may be tight.
Mitigation: Increase getMEVCommitBlocks() return value if testnet shows frequent expiry. Monitor CommitExpired events.
7. Wallet-auth signature replay (Low)
The buildAuthMessage in lib/wallet-auth.ts uses a 5-minute rolling window (Math.floor(Date.now() / 300000)). Signatures are valid for up to ~10 minutes (current window + previous window). A stolen signature could be replayed within that window.
Mitigation: For sensitive operations (account deletion, settings change), require a fresh signature with a one-time nonce stored server-side.
8. Supabase RLS depends on app-level config (Medium)
Row Level Security policies use current_setting('app.wallet_address', true). This setting must be set by the API route before each query. If a route forgets to set it, data could be returned without RLS filtering.
Mitigation: All API routes use the supabaseAdmin client with explicit .eq('wallet_address', address) filters as a secondary guard. Review all new routes before deployment.
9. Private keys in environment variables (High)
ORACLE_ADMIN_PRIVATE_KEY and KEEPER_PRIVATE_KEY are P-256 private keys stored as Netlify environment variables. If the Netlify account is compromised, these keys are exposed.
Mitigation: Use Netlify's secret scanning. Rotate keys periodically. Consider AWS KMS or similar HSM for mainnet key management. Never commit keys to git — confirm .gitignore covers .env.local.
| Control | Status | Details |
|---|---|---|
| SHA3-256 commit-reveal hashing | ✅ Active, live-verified | HashAlgorithm.SHA3_256.hash() over [UInt8], byte-identical to Python's hashlib.sha3_256; tampered-hash reveal genuinely rejected on-chain |
| Price deviation guard (3% slippage) | ✅ Active | Layer 3 MEV protection — fixed OR logic in Phase 1 |
| VRF block-delay jitter (0-5 blocks) | ✅ Active | Layer 2 MEV protection |
| VRF execution queue shuffle | ✅ Active | Layer 4 MEV protection |
| Protocol fee → yield reserve | ✅ Active | 0.1% auto-fee on every deposit |
| Entitlement-based oracle admin | ✅ Active | access(OracleAdmin) resource — Phase 1 fix |
| Clamped claimYield (no panic) | ✅ Active | Pays available balance, not full accrued — Phase 1 fix |
| Global emergency pause | ✅ Active | access(account) setGlobalPause(), plus 2-of-2 MultiSigAdmin path, live-verified |
| Fee-setter access control | ✅ Active | setWithdrawalFeeBps/setManagementFeeBps/setPerformanceFeeBps moved from public contract functions into the storage-private Admin resource — closes an open "anyone can change protocol fees" hole |
| MEVShieldCore mutator access control | ✅ Active | registerVaultMEV/updateVaultSlippageBps/markExecutionProcessed restricted to access(account) — closes an open "anyone can strip a victim vault's MEV protection" hole |
| MultiSigAdmin real dispatch | ✅ Active, live-verified | EmergencyPause, fee Custom actions, AddAdmin/RemoveAdmin/ChangeThreshold, FundYieldReserve (real FLOW moved), UpdateYieldOracle (real APY write) all wired to genuine on-chain effects, not a stub boolean flip. UpgradeContract intentionally left unwired — a real contract update needs an auth(Contracts) &Account from a signed transaction, not obtainable from a stored resource's dispatch call. |
| Per-vault deposit rate limit | ✅ Active | maxDepositPerBlock = 10,000 FLOW default |
| Max vault balance cap | ✅ Active | maxVaultBalanceCap = 100,000 FLOW default |
| Wallet-signed API authentication | ✅ Active | lib/wallet-auth.ts — FCL signature verification |
| Supabase RLS + explicit eq filter | ✅ Active | Double guard on all DB queries |
| Netlify cron secret | ✅ Required | CRON_SECRET env var gates oracle + keeper routes |
This project is on testnet and under active development. If you find a security issue:
- Do not open a public GitHub issue for security vulnerabilities.
- Email the team at:
security@flowsentinel.io(not yet active — use GitHub private advisory for now). - Include: contract name, function name, attack vector, and a minimal proof-of-concept.
- We will acknowledge within 48 hours and provide a fix timeline.
Before deploying to mainnet, the following must be completed:
- Professional Cadence smart contract audit (minimum 1 firm)
- Fuzz testing all
preconditions with edge cases (zero, max UInt64, max UFix64) - Rotate all testnet private keys — never reuse testnet keys on mainnet
- Move all signing keys to HSM (AWS KMS, HashiCorp Vault, or Ledger)
- Deploy MultiSigAdmin with real dispatch and working cross-account quorum — now 3-of-3 on testnet v5, live-verified (2 signatures correctly rejected with
Not enough signatures, 3 signatures executed a real action) - Wire
UpgradeContractto a real, secure path — quorum approves an exact SHA3-256 code hash (ActionType.UpgradeContract), the account key executes viatransactions/multisig_upgrade_contract.cdc, andverifyAndConsumeUpgrade()rejects any code that doesn't match. One approval = one upgrade, non-replayable. Both halves required; neither sufficient alone. Covered bytests/multisig_upgrade_test.cdc. - Write the contract deployment runbook — see DEPLOYMENT.md
- Get the Cadence testing framework actually running — root cause was
flow.json'semulatoralias conflicting withTest.deployContract()'s dynamic account; fixed with propertestingaliases. 13 tests pass for real across 5 files: oracle bootstrap defaults, SHA3-256 commit-reveal round trip, tampered-hash rejection, YieldFarmingStrategy honesty, deposit/withdraw balance movement, min-deposit guard, insufficient-balance guard, global-pause-blocks-deposit-but-never-traps-funds, fee-cap enforcement, and the multisig upgrade gate. Deletedtests/SentinelVaultV2_test.cdc(645 lines, invalid Cadence syntax since written — never ran once). — [ ] still needs strategy-execution and yield-claim path coverage - Implement upgradeability path for contracts (proxy or migration script)
- Penetration test the Netlify API routes (settings, oracle-update, export-data)
- Enable Sentry error monitoring with PII scrubbing
- Set up on-chain event monitoring with alerts for anomalous activity
- Bug bounty program (Immunefi or equivalent) before public launch