Job

f66fcb49shapechainCompletedpaid by0x424f…c755

write a smart contract which can receive funds: ETH, USDT, USDC and IMD. (0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7 on Ethereum mainnet).

Design the smart contract that it should accept maximum of 10K worth of assets (set the ETH price at fixed 2600 and IMD price 9 dollars)

Only this address 0x047f606fd5b2baa5f5c6c4ab8958e45cb6b054b7 should withdraw the funds, but he shall be withdraw it any time any amount, full too.

Upgrades and pausing: owner can upgrade and pause

Published · Contracts

app
CappedAssetVault 0x63d044af53cd2aedb01c31d55a4f68dec36b49e0 · Ethereum mainnet
app
OpenDepositPolicy 0x36816a58afa298ca39f7d5cad4e730801edd36ab · Ethereum mainnet
github
identity-md-launches/launch-799-write-smart-contract-can

Work

  1. posted15 minto the first attempt
  2. built
    #896Build contract projectCodex57 files changed

    Implemented the vault, vendored dependencies, deployment rehearsal, and tests.

    • forge build passed with two documented lint warnings.
    • forge test passed: 52 tests, including stateful invariants.
    • forge fmt --check passed.

    Upgrades replace deposit policy logic; custody stays fixed because the protected checks forbid DELEGATECALL.

    Deployment parameters, forced-transfer limitations, and required live token verification are documented in README.md.

    ran oncodex · gpt-6-astra · 7 turns · 14m 36s · 94.9K in · 28.9K out · 1M cached
    submission634341499be69166245daa9dc1a54e68b6f2ac676647105d7dd1fb0f53665025
    device0bd6e7ef3fc9345a83ff1cbb310e402be66028966ffa655573698a5e2cf34717
    started from0243d7da4a4337ae8b16bcdf15bb4ead736fd68f
    bundleef7aa586fdeb70e67544a53e64473323dcf3e75fd38f334053a0fda6ccca5c64 · 109 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 57 files
    .gitignoreDEPENDENCIES.mdREADME.mdfoundry.tomllib/forge-std/LICENSE-APACHElib/forge-std/LICENSE-MITlib/forge-std/src/Base.sollib/forge-std/src/Script.sollib/forge-std/src/StdAssertions.sollib/forge-std/src/StdChains.sollib/forge-std/src/StdCheats.sollib/forge-std/src/StdConstants.sollib/forge-std/src/StdError.sollib/forge-std/src/StdInvariant.sollib/forge-std/src/StdJson.sollib/forge-std/src/StdMath.sollib/forge-std/src/StdStorage.sollib/forge-std/src/StdStyle.sollib/forge-std/src/StdToml.sollib/forge-std/src/StdUtils.sollib/forge-std/src/Test.sollib/forge-std/src/Vm.sollib/forge-std/src/console.sollib/forge-std/src/console2.sollib/forge-std/src/interfaces/IERC1155.sollib/forge-std/src/interfaces/IERC165.sollib/forge-std/src/interfaces/IERC20.sollib/forge-std/src/interfaces/IERC4626.sollib/forge-std/src/interfaces/IERC6909.sollib/forge-std/src/interfaces/IERC721.sollib/forge-std/src/interfaces/IERC7540.sollib/forge-std/src/interfaces/IERC7575.sollib/forge-std/src/interfaces/IMulticall3.sollib/forge-std/src/safeconsole.sollib/openzeppelin-contracts/LICENSElib/openzeppelin-contracts/contracts/access/Ownable.sollib/openzeppelin-contracts/contracts/access/Ownable2Step.sollib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sollib/openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Permit.sollib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sollib/openzeppelin-contracts/contracts/utils/Address.sollib/openzeppelin-contracts/contracts/utils/Context.sollib/openzeppelin-contracts/contracts/utils/Pausable.sollib/openzeppelin-contracts/contracts/utils/ReentrancyGuard.solremappings.txtscript/Deploy.s.solsrc/CappedAssetVault.solsrc/OpenDepositPolicy.solsrc/interfaces/IDepositPolicy.soltest/Administration.t.soltest/CappedAssetVault.t.soltest/Deployment.t.soltest/VaultBase.soltest/VaultInvariant.t.soltest/mocks/MockToken.soltest/mocks/Policies.soltest/mocks/Receivers.sol
  3. integrated
    #1253ManifestCodex1 file changed
    afterBuild contract project
    writes to
    launch.json

    Created launch.json with both accepted contracts in dependency order and matching constructor arguments.

    Validation passed: schema and ABI checks, forge build, all 52 existing tests, and the protected deployment check. Only launch.json is included in the deliverable.

    ran oncodex · gpt-6-astra · 4 turns · 4m 10s · 44K in · 5.6K out · 400.5K cached
    submissionc5ac6022953a8c3d295221be7d0b402a73a8d7aebdecc9a0cdf87b9dc1ccc2c4
    deviceb29f0968f2df07a002bfba3f8fb88c0e5957e7b1641bb6f355c86332c5a00f9f
    started from9597428982e93d2903b0e7c85c66aa77e020245a
    bundle70ccc6d2bace6216a02eb4fe8c1189ac921d823dba76232c21845e58eaf899e1 · 110 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied onef7aa586fdeb70e67544a53e64473323dcf3e75fd38f334053a0fda6ccca5c64
    changed · 1 file
    launch.json
  4. reviewed
    #153Audit mathClaude3 findings · 2 low
    afterBuild contract project

    The review is complete. Findings and coverage are written to .imd-findings.json, and all three snippets validate against the source tree.

    Outcome: the valuation math holds. I traced every multiplication in totalValueUsd() and the cap check against the Math Precision, Boundary and Numerical Gap guides, and found no reachable rounding, overflow, truncation or scale-mixing defect. Specifically verified, with scratch tests under test/scratch/ that pass on the current code:

    • ETH, USDT, USDC and IMD each value exactly at their smallest unit. There is no division, so no dust can be farmed.
    • The cap is inclusive at exactly $10,000. The largest ETH deposit is the floored wei amount, and one extra wei is rejected.
    • IMD at 0 decimals and 18 decimals both scale correctly. The literal-base exponent evaluates in uint256, so there is no uint8 truncation.
    • Overflow would need more than 1e58 IMD units, and IMD's on-chain supply is about 4.1 million tokens. Any overflow would only block deposits, never withdrawals.
    • The Slither reentrancy and strict-equality leads are benign: all deposit and withdraw paths share one reentrancy guard, and the equality is a zero-amount guard.

    I also read mainnet directly: the pinned IMD address is a LayerZero OFT with 18 decimals, and USDT and USDC report 6, so the README's expected configuration is correct.

    Three findings reported, none above low:

    1. Low. The stablecoin scale is hardcoded to 6 decimals and the constructor never checks decimals() on the configured tokens or IMD. A wrong address or wrong IMD decimal argument silently misvalues holdings in either direction rather than failing closed. Reproduced both ways in scratch tests.
    2. Info. The cap bounds current holdings, not lifetime receipts, so each withdrawal reopens $10,000 of capacity. This matches the README but the brief is ambiguous, so the requester should confirm the intended reading.
    3. Low. Because the cap check includes unsolicited balances, a one-unit USDC donation front-run makes a capacity-exact deposit revert. No funds are lost, only gas and a retry.

    Coverage: all 11 entry points have a row, plus four invariant and static-analysis rows. No proof files were attached because nothing reached high or critical severity. Nothing outside .imd-findings.json and test/scratch/ was changed.

    ran onclaude · claude-fable-5-1 · 24 turns · 7m 32s · 354 in · 24.4K out · 1M cached
    submissionef6e0cdabd446bbe6096134e2c472e7223cca341fda10d2fd7770b8c9473d0d3
    devicec35be49d2f8f8def53d127cb1fdf58d1200d2c513d0ef92d905319810c41e5c6
    started from9597428982e93d2903b0e7c85c66aa77e020245a
    bundlenone
    applied onef7aa586fdeb70e67544a53e64473323dcf3e75fd38f334053a0fda6ccca5c64
    changed · 0 filesnothing
    • lowStablecoin scale is hardcoded to 6 decimals (1e12) and never validated against the configured usdt/usdc contractssrc/CappedAssetVault.sol:115

      totalValueUsd() multiplies both stablecoin balances by the literal 1e12, i.e. it assumes 6 decimals. The constructor (lines 55-58) checks the usdt/usdc addresses for zero, duplicates, IMD and self, but never checks decimals(), and IMD's decimals are a free uint8 parameter checked only for <= 18. A wrong address or wrong decimal argument therefore does not fail closed: it silently misvalues holdings in either direction.

      Over-scaling (an 18-decimal token in a 6-decimal slot) values 1 raw unit at 1e12 WAD so only 1e10 raw units ($0.00000001 nominal) ever fit under the cap and the asset is effectively unusable; under-scaling (a 2-decimal token, or imdDecimals_=18 for a 9-decimal IMD) lets nominal value 1e4..1e9 times the $10,000 cap be accepted.

      Mainnet USDT (0xdAC1...1ec7), USDC (0xA0b8...eB48) and IMD (0xD34a...63B7) were read from a public RPC during this review and report 6, 6 and 18 decimals, so the README's expected configuration is correct; the defect is that the contract cannot detect a deviation from it.

      Minimal fix that preserves the design: in the constructor require IERC20Metadata(usdt).decimals() == 6, IERC20Metadata(usdc).decimals() == 6 and IERC20Metadata(IMD).decimals() == imdDecimals_ (or read IMD decimals instead of taking a parameter). If the author wants deployment rehearsals without token code present, keep the parameter but add the check behind a code.length > 0 condition.

      Over-scaling: deploy new CappedAssetVault(owner, usdt6, dai18, 18, policy) where dai18 is any 18-decimal ERC20.

      Alice approves and calls depositToken(dai18, 1e18) (1 DAI, $1 nominal).

      Expected: accepted, totalValueUsd()==1e18.

      Actual: reverts CapExceeded(1e30) because 1e18*1e12 = 1e30 WAD = $1,000,000,000,000; depositToken(dai18, 1e10) is accepted and totalValueUsd() reads 10_000e18 while the vault holds $0.00000001 nominal.

      Under-scaling: deploy with a 2-decimal token as usdt; depositToken(two, 100_000_000e2) ($100,000,000 nominal) is accepted and totalValueUsd()==10_000e18.

      Same arithmetic applies to imdDecimals_: passing 18 for a 9-decimal token undervalues IMD 1e9 times.

      Both traces were run in test/scratch/MathBoundary.t.sol (test_StablecoinDecimalsNotValidated, test_LowDecimalStableBypassesCap) and pass against the current code.

    • infoThe $10,000 cap bounds current holdings, not lifetime receipts: withdrawals reopen capacitysrc/CappedAssetVault.sol:153

      The cap is evaluated against totalValueUsd(), which is the vault's present balance at fixed prices. Because the beneficiary may withdraw at any time, the total value ever accepted through the deposit entry points is unbounded: after each withdrawal the next $10,000 is accepted again.

      The README documents this interpretation ('Withdrawals restore deposit capacity'), and the brief ('should accept maximum of 10K worth of assets') can be read either way, so this is a design confirmation for the requester rather than a code defect.

      If the intended guarantee is a lifetime ceiling, the fix is a monotonically increasing accumulator of accepted USD value (incremented by the valued receipt in _acceptDeposit and compared to MAX_USD_WAD) instead of, or in addition to, the balance-based check; withdrawals would then not restore capacity.

      Fresh vault with OpenDepositPolicy.

      Alice: depositToken(USDC, 10_000e6) -> accepted, totalValueUsd()==10_000e18.

      WITHDRAWER: withdrawAll(USDC) -> vault balance 0.

      Alice: depositToken(USDC, 10_000e6) -> accepted again.

      Vault plus WITHDRAWER now hold 20_000e6 USDC received through depositToken, i.e. $20,000 accepted against a '$10,000 maximum'.

      Under the lifetime reading the second deposit should revert CapExceeded.

      Run in test/scratch/MathBoundary.t.sol test_LifetimeReceiptsExceedCap (passes against current code, demonstrating the behaviour).

    • lowCap check includes unsolicited balances, so a 1-unit donation front-run makes a capacity-exact deposit revertsrc/CappedAssetVault.sol:154

      Boundary x invariant seam: remainingCapacityUsd() tells a depositor exactly how much fits, but the check in _acceptDeposit is made against the live balance, which anyone can raise by a plain ERC20 transfer or forced ETH without going through the vault.

      A third party who sees a pending deposit of exactly the remaining capacity can push the total 1e12 WAD (one USDC/USDT unit, $0.000001) over the cap, making the victim's transaction revert and waste its gas; repeated indefinitely at negligible cost while the beneficiary does not withdraw. No funds are lost and the deposit can be retried for a smaller amount, which is why this is low.

      The README acknowledges unsolicited balances may exceed the cap but does not describe this griefing path. Mitigations that preserve the design: compute the cap against tracked accepted balances (incremented in _acceptDeposit, decremented in the withdraw paths) so donations cannot consume capacity, or have the frontend recommend leaving a small margin below remainingCapacityUsd().

      Fresh vault, remainingCapacityUsd()==10_000e18.

      Alice prepares depositToken(USDC, 10_000e6) which fits exactly.

      Before it is mined, address 0xBAD executes USDC.transfer(vault, 1).

      Alice's transaction now executes with totalValueUsd()==10_000e18 + 1e12 and reverts CapExceeded(10000000000001000000000000) although her own deposit was within the capacity she read.

      Expected: her deposit is accepted (or capacity reflects only accepted deposits).

      Run in test/scratch/MathBoundary.t.sol test_DonationFrontRunRevertsExactDeposit (passes against current code, demonstrating the revert).

  5. reviewed
    #1376Audit permissionsClaude2 findings · 1 low
    afterBuild contract project

    The review is complete. .imd-findings.json holds two findings and a 15-row coverage record, and no source or configuration file was changed.

    Outcome. The permission model in src/CappedAssetVault.sol holds against the Access Control, Trust Gap and Asymmetry checklists. No permission bypass, escalation path or asymmetric guard was found. Every guarded storage slot has a single writer class, withdrawals never read pause state or the policy, and the beneficiary address is a compile-time constant that no owner call can alter.

    Findings written:

    • Low, line 153. The $10K cap is enforced on current holdings only. After the beneficiary withdraws, the vault accepts another full $10,000, so lifetime receipts are unbounded. The README documents this reading, but the brief's wording can be read as a lifetime ceiling. Reproduced with a scratch test that deposits $10K, withdraws all, and deposits $10K again.
    • Info, line 134. "Owner can upgrade" is implemented as a swappable deposit-admission policy. Custody logic, prices, cap and beneficiary are not upgradeable. This is forced by the platform ban on delegatecall and proxies and is documented, but the requester should confirm it matches their intent.

    Live-chain verification at mainnet block 26133529 confirmed the configuration assumptions: the IMD address is a verified LayerZero OFT token with 18 decimals and plain ERC20 semantics, USDT and USDC have 6 decimals, and the beneficiary is an EOA that also owns the IMD token.

    Static analysis leads were all reproduced as non-issues. The slither reentrancy lead on depositToken is a balance read with no vault state written and every entry point guarded. The two strict-equality leads are zero-amount checks.

    Coverage. All 11 ABI entry points have a row. Four reference finding 1 or 2, seven hold. Nothing was left unreached. The scratch test under test/scratch/ is disposable and passes on current code, since both findings describe behavior rather than a loss of funds.

    ran onclaude · claude-fable-5-1 · 42 turns · 8m 25s · 386 in · 28K out · 1.1M cached
    submission117e225b86fe208a790d0e4673c57262817ee057654ee6d8cac2609749f698b9
    device1559912e747bbcd47e08bea06ad34a8cab66936ffc40556c79cfd0ecc60f74df
    started from9597428982e93d2903b0e7c85c66aa77e020245a
    bundlenone
    applied onef7aa586fdeb70e67544a53e64473323dcf3e75fd38f334053a0fda6ccca5c64
    changed · 0 filesnothing
    • lowThe $10,000 cap bounds current holdings only; beneficiary withdrawals reopen capacity so lifetime acceptance is unboundedsrc/CappedAssetVault.sol:153

      Trust Gap seam access x economics. The brief asks that the contract "accept maximum of 10K worth of assets". The cap check in _acceptDeposit compares only the vault's present balances (totalValueUsd) against MAX_USD_WAD and the contract keeps no record of cumulative receipts.

      Each withdrawal by WITHDRAWER (withdrawETH / withdrawToken / withdrawAll, which carry no pause or policy restriction) therefore frees the same capacity again, and the vault will accept another full $10,000. Across the life of the contract the amount accepted is unbounded; only the instantaneous balance is capped.

      The README documents this as the intended reading ("Withdrawals restore deposit capacity"), but the brief's wording is at least as naturally read as a lifetime ceiling. If the requester meant a lifetime cap, a depositor cannot rely on the contract to refuse the 10,001st dollar once the beneficiary has withdrawn.

      Fix if a lifetime cap is intended: track a monotonically increasing acceptedUsdWad in _acceptDeposit (acceptedUsdWad += valueOfReceipt; revert if it exceeds MAX_USD_WAD) and leave the withdrawal path unchanged. If the holdings-cap reading is confirmed by the requester, this finding needs no code change.

      State: fresh vault, OpenDepositPolicy, Alice holds 20,000 USDC.

      1. Alice approve(vault, 10_000e6) then depositToken(USDC, 10_000e6) -> succeeds, totalValueUsd()==10_000e18.
      2. Alice depositToken(USDC, 1) -> reverts CapExceeded(10_000e18 + 1e12), as expected.
      3. WITHDRAWER (0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7) calls withdrawAll(USDC) -> vault balance 0, remainingCapacityUsd()==10_000e18.
      4. Alice approve + depositToken(USDC, 10_000e6) -> succeeds. Expected under a lifetime-cap reading: step 4 reverts because $20,000 has now been accepted. Actual: step 4 succeeds; usdc.balanceOf(WITHDRAWER)+usdc.balanceOf(vault)==20_000e6. Verified by test/scratch/LifetimeCap.t.sol::test_LifetimeReceiptsExceedTenThousandAfterWithdrawal (passes on current code, i.e. demonstrates the behavior).
    • info"Owner can upgrade" is implemented as a replaceable deposit-admission policy only; custody rules, prices, cap and beneficiary cannot be upgradedsrc/CappedAssetVault.sol:134

      Access-control scope note, not a permission bypass. upgradeTo only swaps the IDepositPolicy address that is consulted by STATICCALL during deposits. No owner call sequence can alter WITHDRAWER, ETH_PRICE_USD, IMD_PRICE_USD, MAX_USD_WAD, the asset set or the withdrawal logic, and there is no proxy.

      This is a deliberate narrowing: the launch policy and the protected runtime check forbid DELEGATECALL/CALLCODE/SELFDESTRUCT and proxies, so a UUPS/transparent upgrade is not deployable here, and the README states the tradeoff. The judge should confirm the requester accepts this reading of "upgrade"; if a fuller upgrade path was expected it requires a scope decision outside this contract (a new reviewed deployment plus funds moved by the beneficiary).

      Trust assumptions in this area, recorded for completeness: the owner can pause deposits indefinitely and can install a policy that rejects all or selected depositors; neither power touches withdrawals (verified: withdrawETH/withdrawToken/withdrawAll carry only nonReentrant + onlyWithdrawer and never read depositPolicy or _paused).

      State: deployed vault, owner O.

      Sequence: O calls pause(); O calls upgradeTo(P2) with any contract whose policyId() returns POLICY_ID; O calls unpause().

      After this, vault.WITHDRAWER()==0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7, ETH_PRICE_USD()==2600, IMD_PRICE_USD()==9, MAX_USD_WAD()==10_000e18 and the withdrawal functions are byte-identical in behavior; only depositPolicy() changed.

      There is no entry point by which the owner can change any of those values (full ABI inventory: acceptOwnership, depositETH, depositToken, pause, receive, transferOwnership, unpause, upgradeTo, withdrawAll, withdrawETH, withdrawToken, renounceOwnership(reverts)).

      Expected by a proxy-upgrade reading of the brief: owner could replace custody code.

      Actual: impossible by design.

  6. reviewed
    #606Audit economicsClaude2 findings · 2 low
    afterBuild contract project

    Review complete. The findings file is at .imd-findings.json and the working tree is otherwise unchanged (build output in out/ and cache/ is gitignored, scratch tests removed).

    Result: no critical or high defects in the Economic Security, Invariant or Flow Gap areas. Two low findings, both reproduced with Foundry tests, plus a 14-row coverage record answering all 11 entry points and three invariants.

    Findings

    • Cap is a holdings ceiling, not an acceptance ceiling (src/CappedAssetVault.sol:154). After the withdrawer empties the vault, it accepts another full $10,000. The embedded proof test deposits 10,000 USDC, withdraws it, deposits 10,000 again, and fails on the current code. The README says this is intentional, so it is flagged as a specification-reading question for the requester: the brief says the contract "should accept maximum of 10K", which reads more naturally as a lifetime limit. A minimal fix is described if that reading is intended.
    • Stablecoin blacklist strands funds (src/CappedAssetVault.sol:173). Every withdrawal pays the hard-coded payee with no recipient parameter and no owner path. If Circle or Tether blacklists the payee or the vault, that stablecoin is permanently stuck, contradicting "withdraw any time any amount". Verified with a blacklisting mock on both withdrawal functions. Kept at low because the trigger is an issuer action. A recipient argument gated by the same withdrawer check would preserve the brief.

    What held after tracing: cap arithmetic is exact with no division (ETH boundary at 3846153846153846153 wei verified), the balance-delta receipt handles fee, no-return, false-return and over-receipt tokens, all entry points carry the reentrancy guard, the policy is invoked by STATICCALL and cannot move funds or alter the cap, withdrawals are never blocked by pause, policy or another asset's failure, and conservation holds because the vault keeps no internal accounting that could diverge from balances.

    Live checks at mainnet block 26133522: the pinned IMD address is the Identity.md LayerZero OFT with 18 decimals and standard ERC20 selectors (no fee or blacklist), USDT and USDC report 6 decimals, and the withdrawer is an EOA. Two observations outside my area for the judge: IMD trades near $12.68 against the fixed $9, so the vault undervalues it (per brief, not a defect), and the IMD token contract's owner is the withdrawer address itself.

    Static analysis leads: the Slither reentrancy-balance line is guarded by the reentrancy modifier and the over-receipt check; the two incorrect-equality lines are > comparisons, not strict equality. Neither reproduced.

    Not reached: no fork test ran against the real IMD bytecode, and the suite's reliance on a mock etched at the IMD address means that path is untested locally.

    ran onclaude · claude-fable-5-1 · 38 turns · 9m 10s · 450 in · 34.7K out · 1.5M cached
    submission34c6245657082065299c7ff58a418ad68c0d86db55383bf7c1fd8b3577dbd833
    devicee6de8d4c6cf9755159db0f1186e45380731977af9ac6ac182deeacfba39d97ee
    started from9597428982e93d2903b0e7c85c66aa77e020245a
    bundlenone
    applied onef7aa586fdeb70e67544a53e64473323dcf3e75fd38f334053a0fda6ccca5c64
    changed · 0 filesnothing
    • lowThe $10,000 cap bounds current holdings only; the vault accepts unbounded cumulative value once the beneficiary withdrawssrc/CappedAssetVault.sol:154

      The brief asks for a contract that "should accept maximum of 10K worth of assets". _acceptDeposit() enforces the cap only against totalValueUsd(), i.e. the balances the vault holds at that moment (src/CappedAssetVault.sol:153-154). Nothing records how much value the deposit entry points have already accepted, so every withdrawal by WITHDRAWER re-opens capacity and the same vault accepts another full $10,000 through depositETH()/depositToken()/receive().

      Over its lifetime the vault therefore accepts an unbounded amount: N withdraw-and-refill cycles accept N x $10,000.

      README.md states this is intentional ("Withdrawals restore deposit capacity"), so this is a specification-interpretation finding rather than an implementation slip: if the requester meant a ceiling on what the vault holds at once, no change is needed; if they meant a ceiling on what it will ever accept (the plainer reading of "accept maximum of 10K"), the current code violates it.

      Economic consequence under the lifetime reading: there is no limit to the funds the fixed beneficiary can collect from depositors through this contract.

      Minimal design-preserving fix if the lifetime reading is intended: add uint256 public acceptedUsdWad;, increment it in _acceptDeposit() by the USD value of the amount actually received (wei2600, raw1e12, imdRaw*_imdUsdPerUnit) and revert when acceptedUsdWad + delta > MAX_USD_WAD; keep the existing holdings check if unsolicited balances should still count.

      Untested edge: no existing test deposits again after a full withdrawal and asserts a limit; test_WithdrawalReopensCapacity asserts the opposite.

      State: fresh vault, OpenDepositPolicy, Alice holds 20,000 USDC (6 decimals).

      1. Alice approves and calls depositToken(USDC, 10_000e6): succeeds, totalValueUsd()==10_000e18.
      2. Alice calls depositToken(USDC, 1): reverts CapExceeded, as expected.
      3. WITHDRAWER (0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7) calls withdrawAll(USDC): receives 10_000e6, totalValueUsd()==0.
      4. Alice approves and calls depositToken(USDC, 10_000e6) again. Actual: step 4 succeeds; WITHDRAWER balance + vault balance == 20_000e6, i.e. the contract has accepted $20,000 of assets. Expected under the brief's lifetime reading: step 4 reverts (CapExceeded) because $10,000 has already been accepted. The scratch test in proof fails on the current code with "vault accepted $20,000 of assets over its lifetime".
      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {CappedAssetVault} from "src/CappedAssetVault.sol";
      import {OpenDepositPolicy} from "src/OpenDepositPolicy.sol";
      import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
      
      /// @dev Minimal 6-decimal ERC20 standing in for USDC.
      contract Stable is IERC20 {
          uint256 public totalSupply;
          mapping(address => uint256) public balanceOf;
          mapping(address => mapping(address => uint256)) public allowance;
      
          function mint(address to, uint256 amount) external {
              balanceOf[to] += amount;
              totalSupply += amount;
              emit Transfer(address(0), to, amount);
          }
      
          function approve(address spender, uint256 amount) external returns (bool) {
              allowance[msg.sender][spender] = amount;
              emit Approval(msg.sender, spender, amount);
              return true;
          }
      
          function transfer(address to, uint256 amount) external returns (bool) {
              balanceOf[msg.sender] -= amount;
              balanceOf[to] += amount;
              emit Transfer(msg.sender, to, amount);
              return true;
          }
      
          function transferFrom(address from, address to, uint256 amount) external returns (bool) {
              allowance[from][msg.sender] -= amount;
              balanceOf[from] -= amount;
              balanceOf[to] += amount;
              emit Transfer(from, to, amount);
              return true;
          }
      }
      
      /// @notice The vault's "$10,000 maximum" is a ceiling on current holdings, not on what it accepts.
      ///         After the beneficiary withdraws, the same vault accepts another full $10,000, so the
      ///         cumulative value it accepts through its deposit entry points is unbounded.
      contract CapRecycleTest is Test {
          address constant OWNER = address(0xA11CE);
          address constant ALICE = address(0xB0B);
          address constant PAYEE = 0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7;
          address constant IMD_ADDRESS = 0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7;
      
          CappedAssetVault vault;
          Stable usdt;
          Stable usdc;
      
          function setUp() public {
              usdt = new Stable();
              usdc = new Stable();
              Stable template = new Stable();
              vm.etch(IMD_ADDRESS, address(template).code);
              OpenDepositPolicy policy = new OpenDepositPolicy();
              vault = new CappedAssetVault(OWNER, address(usdt), address(usdc), 18, address(policy));
              usdc.mint(ALICE, 30_000e6);
          }
      
          function _deposit(uint256 amount) internal returns (bool ok) {
              vm.startPrank(ALICE);
              usdc.approve(address(vault), amount);
              (ok,) = address(vault).call(abi.encodeCall(vault.depositToken, (address(usdc), amount)));
              vm.stopPrank();
          }
      
          function test_VaultAcceptsMoreThanTenThousandDollarsOverItsLifetime() public {
              // First $10,000 fills the cap exactly.
              assertTrue(_deposit(10_000e6), "first deposit should fill the cap");
              assertEq(vault.totalValueUsd(), 10_000e18);
              assertFalse(_deposit(1), "one more unit must be rejected at the cap");
      
              // Beneficiary withdraws everything, as the brief allows at any time.
              vm.prank(PAYEE);
              vault.withdrawAll(address(usdc));
              assertEq(usdc.balanceOf(PAYEE), 10_000e6);
              assertEq(vault.totalValueUsd(), 0);
      
              // Second $10,000 is accepted through the same deposit entry point.
              bool secondAccepted = _deposit(10_000e6);
      
              // Cumulative value accepted by the vault is now $20,000 even though the brief
              // asks for a maximum of $10,000 worth of assets to be accepted.
              uint256 acceptedLifetime = usdc.balanceOf(PAYEE) + usdc.balanceOf(address(vault));
              assertFalse(
                  secondAccepted && acceptedLifetime == 20_000e6,
                  "vault accepted $20,000 of assets over its lifetime; expected acceptance to stop at $10,000"
              );
          }
      }
    • lowStablecoin issuer blacklist of the fixed payee (or of the vault) permanently strands USDC/USDT; no recipient override or owner path existssrc/CappedAssetVault.sol:173

      Flow-gap seam (periphery x first principles). Every token withdrawal pays the hard-coded constant WITHDRAWER (src/CappedAssetVault.sol:173; ETH at line 164) and there is no recipient parameter, no owner sweep and no migration. Ethereum USDC (Circle FiatTokenV2) and USDT (TetherToken) both revert any transfer whose from or to is on the issuer blacklist.

      The brief's guarantee is that the withdrawer "shall be withdraw it any time any amount, full too".

      If Circle or Tether blacklists 0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7 (an EOA with about 2,700 sent transactions at mainnet block 26133522, so it has a live compliance footprint) or blacklists the vault address itself, every withdrawToken()/withdrawAll() call for that stablecoin reverts inside safeTransfer, forever: WITHDRAWER cannot redirect to a clean address and the owner has no path at all (UnauthorizedWithdrawer).

      Deposits of that stablecoin keep succeeding meanwhile, so the stranded amount can grow to the full cap. The same single-recipient design also means loss of the payee's key freezes everything, which README.md already documents. Precondition is an action by the token issuer, so severity is kept at low; impact when it occurs is permanent loss of the vault's entire holding of that stablecoin (up to $10,000 at fixed prices).

      Minimal fix that preserves the brief (only that address may withdraw): let the withdrawer name the recipient, e.g. withdrawToken(address asset, uint256 amount, address to) / withdrawAll(address asset, address to) with to != address(0) and still onlyWithdrawer; if paying only WITHDRAWER is a deliberate hardening, record the blacklist-stranding risk as an accepted trust assumption.

      Untested edge: the suite has no blacklisting token mock; MockToken only models revert-all, false-return, fee and no-movement.

      State: vault configured with a USDC-like token that reverts transfers to/from blacklisted accounts (behaviour of mainnet USDC/USDT).

      Alice deposits 5_000e6 USDC via depositToken; vault holds 5_000e6.

      Then the issuer blacklists WITHDRAWER.

      WITHDRAWER calls withdrawToken(USDC, 1e6): reverts with the token's blacklist reason; withdrawAll(USDC): same revert.

      OWNER calls withdrawAll(USDC): reverts UnauthorizedWithdrawer(OWNER).

      Actual: vault USDC balance stays 5_000e6 and no transaction can move it.

      Expected per brief: the withdrawer can withdraw any amount at any time, which a recipient parameter would restore.

      Variant: blacklist the vault address instead of the payee; same outcome.

      Verified with a scratch Foundry test (BlacklistableStable mock with require(!blacklisted[from] && !blacklisted[to])) exercising both withdrawToken and withdrawAll.

  7. reviewed
    #440Audit flowClaude4 findings · 1 medium
    afterBuild contract project

    Review complete. The findings file is written and validated: all four quoted snippets match the tree exactly, and the proof test fails on the current code as required.

    Outcome

    The vault is solid in my area. Every entry point's checks, state changes and failure paths traced as intended, including a mainnet fork simulation of real IMD, USDT and USDC deposits and withdrawals to the fixed payee. Both static-analysis leads were rejected: the reentrancy lead is blocked by the guard on every entry point and the vault grants no approvals, and the strict-equality lead is the amount == 0 guard.

    Findings written to .imd-findings.json

    1. Medium, with proof. The $10,000 cap is a current-balance ceiling, not a lifetime acceptance cap. Deposit $10,000, let the beneficiary withdraw it, and the vault accepts another $10,000, indefinitely. The README documents this reading, so the author must confirm with the requester which reading of "accept maximum of 10K" was meant. The proof test in test/scratch/LifetimeCap.t.sol fails today and passes with a cumulative counter.
    2. Low. Payouts are hard-wired to the withdrawer address, not just gated on it. If USDC or USDT blacklists that address, that asset is stranded with no owner recovery path. Letting the withdrawer name a recipient keeps the brief's rule and removes the trap.
    3. Info. "Owner can upgrade" is implemented as deposit-policy replacement only because the launch recipe forbids proxies. Prices, cap, token addresses, decimals and beneficiary are immutable, so a constructor mistake needs redeployment. Recorded as a scope decision.
    4. Info. Three untested edges, each verified to behave correctly today: withdrawing a zero or codeless asset address, ETH sends under the 2,300 gas stipend, and the only route to cancel a pending ownership transfer.

    On-chain facts checked at block 26133523

    • IMD is a non-proxy LayerZero OFT with 18 decimals and plain ERC20 transfers.
    • The withdrawer is an active externally owned account, so ETH payouts via call succeed.
    • USDT and USDC at the README addresses report 6 decimals.

    Coverage has a row for all eleven entry points plus three invariant and external-dependency rows. No source files were changed. The only scratch file left is the proof test.

    ran onclaude · claude-fable-5-1 · 46 turns · 9m 31s · 450 in · 37.1K out · 1.5M cached
    submission2218c98e698f39239287e45ac34ff849eb11b869f4dd7254ff664d0cd9a695db
    device6ef494db85781eec11af6ed42b4e455faba3a2395fa3fe3ca47b4b5fc8708369
    started from9597428982e93d2903b0e7c85c66aa77e020245a
    bundlenone
    applied onef7aa586fdeb70e67544a53e64473323dcf3e75fd38f334053a0fda6ccca5c64
    changed · 0 filesnothing
    • mediumCap is a current-balance ceiling, so lifetime accepted deposits are unbounded once the beneficiary withdrawssrc/CappedAssetVault.sol:154

      The brief says the contract 'should accept maximum of 10K worth of assets'. _acceptDeposit() only compares totalValueUsd() (the vault's current holdings at fixed prices) with MAX_USD_WAD. Nothing records how much the vault has already accepted, so every withdrawal by WITHDRAWER reopens the full $10,000 of capacity and the vault will accept $10,000 again, indefinitely.

      If the requester meant a cumulative acceptance cap (the natural reading of 'accept maximum of 10K', e.g. a raise limit), that guarantee is not enforced at all: the vault accepted $30,000 in the reproduction below. README.md documents the balance-ceiling reading ('Withdrawals restore deposit capacity'), so this is a specification interpretation the author must confirm with the requester; if the balance-ceiling reading is confirmed, no change is needed.

      Minimal fix that preserves everything else: add uint256 public totalAcceptedUsdWad; and, in _acceptDeposit, compute the USD value of received for asset (same multipliers as totalValueUsd), require totalAcceptedUsdWad + depositUsd <= MAX_USD_WAD, then increment it; keep the existing balance check if both limits are wanted.

      State: fresh vault, ALICE holds 1,000,000 USDC and approved the vault.

      1. ALICE depositToken(USDC, 10_000e6) -> accepted, totalValueUsd()==10_000e18.
      2. WITHDRAWER withdrawAll(USDC) -> vault holds $0.
      3. ALICE depositToken(USDC, 10_000e6) -> accepted again (expected under a lifetime cap: revert CapExceeded).
      4. WITHDRAWER withdrawAll(USDC);
      5. ALICE depositToken(USDC, 10_000e6) -> accepted. Result: WITHDRAWER received 20,000 USDC and the vault holds 10,000 USDC, i.e. $30,000 accepted through the deposit entry points. test/scratch/LifetimeCap.t.sol fails with 'next call did not revert as expected' on step 3.
      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {CappedAssetVault} from "src/CappedAssetVault.sol";
      import {OpenDepositPolicy} from "src/OpenDepositPolicy.sol";
      import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
      
      /// @dev Minimal 6-decimal stablecoin stand-in with standard ERC20 semantics.
      contract StableMock is IERC20 {
          mapping(address => uint256) public balanceOf;
          mapping(address => mapping(address => uint256)) public allowance;
          uint256 public totalSupply;
      
          function mint(address to, uint256 amount) external {
              balanceOf[to] += amount;
              totalSupply += amount;
          }
      
          function approve(address spender, uint256 amount) external returns (bool) {
              allowance[msg.sender][spender] = amount;
              return true;
          }
      
          function transfer(address to, uint256 amount) external returns (bool) {
              balanceOf[msg.sender] -= amount;
              balanceOf[to] += amount;
              return true;
          }
      
          function transferFrom(address from, address to, uint256 amount) external returns (bool) {
              allowance[from][msg.sender] -= amount;
              balanceOf[from] -= amount;
              balanceOf[to] += amount;
              return true;
          }
      }
      
      /// @notice Fails on the current code: the vault accepts more than $10,000 of assets over its lifetime
      /// because the cap is checked against the current balance only. Passes once the vault also enforces
      /// a cumulative acceptance cap (or the requester confirms the balance-ceiling reading and the test is dropped).
      contract LifetimeCapTest is Test {
          address constant IMD = 0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7;
          address constant PAYEE = 0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7;
          address constant OWNER = address(0xA11CE);
          address constant ALICE = address(0xB0B);
      
          CappedAssetVault vault;
          StableMock usdt;
          StableMock usdc;
      
          function setUp() public {
              usdt = new StableMock();
              usdc = new StableMock();
              StableMock template = new StableMock();
              vm.etch(IMD, address(template).code);
              vault = new CappedAssetVault(OWNER, address(usdt), address(usdc), 18, address(new OpenDepositPolicy()));
              usdc.mint(ALICE, 1_000_000e6);
              vm.prank(ALICE);
              usdc.approve(address(vault), type(uint256).max);
          }
      
          function test_VaultAcceptsAtMostTenThousandDollarsOverItsLifetime() public {
              // Accept exactly $10,000 of USDC: allowed by both readings of the cap.
              vm.prank(ALICE);
              vault.depositToken(address(usdc), 10_000e6);
              assertEq(vault.totalValueUsd(), 10_000e18);
      
              // The beneficiary withdraws everything; the vault now holds $0.
              vm.prank(PAYEE);
              vault.withdrawAll(address(usdc));
              assertEq(vault.totalValueUsd(), 0);
      
              // A further deposit would push lifetime acceptance to $10,000.000001 > $10,000.
              // Expected: the vault refuses it. Actual on current code: it is accepted.
              vm.prank(ALICE);
              vm.expectRevert();
              vault.depositToken(address(usdc), 1);
      
              assertEq(usdc.balanceOf(PAYEE) + usdc.balanceOf(address(vault)), 10_000e6, "lifetime acceptance exceeded $10,000");
          }
      }
    • lowPayout address is hard-wired to WITHDRAWER, so an issuer blacklist of that address permanently strands USDC/USDT with no recovery pathsrc/CappedAssetVault.sol:173

      The brief only requires that 0x047f...54b7 be the sole caller allowed to withdraw; the implementation additionally forces every payout to that same address (line 164 for ETH, line 173 for tokens). USDC and USDT can blacklist any address, and both revert transfers to a blacklisted recipient.

      If the beneficiary is ever blacklisted, every withdrawToken/withdrawAll for that stablecoin reverts forever; the owner has no sweep, no recipient parameter and no way to change WITHDRAWER, so the balance (up to the full $10,000 cap in that asset) is stuck permanently. The same structure makes the vault unrecoverable if the beneficiary's key is lost, which README.md acknowledges.

      WITHDRAWER was verified on mainnet (block 26133523) to be an active EOA (nonce 2681), so ETH payouts via call{value} work today. Minimal fix that keeps the brief's rule: keep onlyWithdrawer but let the withdrawer pass a to address (e.g. withdrawETH(uint256 amount, address to)), or add withdrawTo(asset, amount, to) alongside the existing functions.

      State: vault holds 5,000 USDC deposited by ALICE; the USDC issuer blacklists WITHDRAWER (mock: transfers to WITHDRAWER revert with 'Blacklistable: account is blacklisted').

      Calls: WITHDRAWER withdrawAll(USDC) -> reverts 'Blacklistable: account is blacklisted'; WITHDRAWER withdrawToken(USDC, 1) -> same revert; OWNER withdrawAll(USDC) -> reverts UnauthorizedWithdrawer(OWNER).

      Expected: some authorised path can move the 5,000 USDC.

      Actual: usdc.balanceOf(vault) stays 5,000e6 with no function able to release it.

    • info'Owner can upgrade' is implemented as deposit-policy replacement only; custody rules, prices, cap, token addresses and beneficiary cannot be changed after deploymentsrc/CappedAssetVault.sol:134

      The brief asks that the owner 'can upgrade'. Because the launch recipe forbids proxies, DELEGATECALL and SELFDESTRUCT, the author implemented upgradeTo() as a swap of the IDepositPolicy contract that is consulted by STATICCALL during deposits.

      This is documented in README.md and is a reasonable reading, but the requester should be aware of what it does not cover: ETH_PRICE_USD, IMD_PRICE_USD, MAX_USD_WAD, USDT, USDC, imdDecimals and WITHDRAWER are constants/immutables with no setter. A deployment mistake in a constructor argument (e.g. imdDecimals_ != 18, wrong stablecoin address) or any later change of requirements needs a new deployment and a manual move of funds by the beneficiary.

      On mainnet (block 26133523) IMD at 0xD34a...63B7 is a non-proxy LayerZero OFT with 18 decimals and plain OpenZeppelin ERC20 transfers, and USDT/USDC at the README addresses report 6 decimals, so the expected constructor values are correct today. No code change is proposed; this records the scope decision for the requester.

      State: vault deployed with imdDecimals_ = 6 by mistake (constructor accepts any value 0..18). Then 1 IMD (1e18 raw) is valued at 1e18 * 9 * 10**12 = $9e12 instead of $9, so depositToken(IMD, 1e18) reverts CapExceeded and no owner function (pause/unpause/upgradeTo/transferOwnership) can correct it; only redeployment can.

    • infoUntested edges: withdrawToken with address(0)/EOA asset, receive() under the 2300-gas stipend, and the only route to cancel a pending ownership transfertest/CappedAssetVault.t.sol:294

      The suite is thorough on cap boundaries, token misbehaviour, pause and policy upgrades, but three paths I traced have no test. (1) withdrawToken(address(0), x) and withdrawToken(, x): both revert because balanceOf on a codeless address fails ABI decoding, which is the intended behaviour but is only implied by the address(0)-means-ETH branch of withdrawAll.

      (2) receive() invoked with the 2,300-gas stipend (Solidity transfer/send) reverts because totalValueUsd() makes three external balanceOf calls plus the policy STATICCALL; README documents it but no test pins it, so a future change that makes receive() cheaper or more expensive would go unnoticed.

      (3) Because the override rejects transferOwnership(address(0)), the only way to cancel a pending two-step transfer is transferOwnership(currentOwner); this works (pending becomes the owner itself, the old candidate can no longer accept) but is untested and undocumented. All three were verified locally with scratch tests; each behaves as intended today.

      Inputs: (1) prank WITHDRAWER, withdrawToken(address(0), 1) -> revert (empty returndata decode); withdrawToken(0xdead, 1) -> revert.

      (2) After depositETH 1 ether, prank ALICE, address(vault).call{value: 1, gas: 2300}("") -> ok == false.

      (3) prank OWNER: transferOwnership(ALICE); transferOwnership(OWNER); prank ALICE acceptOwnership() -> revert OwnableUnauthorizedAccount(ALICE); pendingOwner() == OWNER.

  8. tested
    #1405Write foundry testsCodex6 files changed
    afterBuild contract project
    writes to
    testtest/**

    Added failure-path, fuzz, and multi-actor invariant tests. All changes are under test/.

    • forge build passed.
    • forge test: 63 passed, 0 failed.
    • 6,000 fuzz cases and 40,960 random invariant calls completed.

    No reproducible implementation defects found. Live-mainnet token integration remains unverified.

    ran oncodex · gpt-6-astra · 6 turns · 9m 38s · 100.5K in · 16.3K out · 1.1M cached
    submission225a99e29fdda46e79f381006d1ca16c6f153d432bed12284b86511c389768d3
    device4d218e2d11a36af490bc84c52a014d863ce2158907cc01932ea5437d30161d8f
    started from9597428982e93d2903b0e7c85c66aa77e020245a
    bundle58756a51fd28561c48db9e036c7c8f1bcbb61f27876e9c8f7b0c30372b7ca1e8 · 120 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied onef7aa586fdeb70e67544a53e64473323dcf3e75fd38f334053a0fda6ccca5c64
    changed · 6 files
    test/Administration.t.soltest/AdversarialVault.t.soltest/CappedAssetVault.t.soltest/README.mdtest/VaultInvariant.t.soltest/VaultStateMachine.t.sol
  9. reviewed
    #1473Audit judgeClaude6 findings · 4 low
    afterBuild contract project, Write foundry tests, Manifest, Audit math, Audit permissions, Audit economics, Audit flow

    The review is complete. The findings file is written at the repository root, the scratch tests are removed, and no source or config file was changed.

    Outcome. The vault is sound on every entry point I traced. No specialist reported a fund-loss or permission-bypass defect, and my own pass found none. All kept findings are low or informational and were reproduced in Foundry against the current code before being recorded.

    What I kept, after merging duplicates across the four specialists:

    • Holdings cap, not lifetime cap (low). Four specialists reported this at four different severities. I merged them into one finding and set it to low because the README, the manifest notes, and an existing test all document the holdings-ceiling reading as deliberate. Both attached specialist proofs fail on the current code as claimed. I did not attach a proof, since a proof would bind the fix to the lifetime reading before the requester has confirmed it. This is the one item the requester must settle.
    • Payout hard-wired to the beneficiary (low). Two specialists reported this. A blacklist of that address by Circle or Tether strands the stablecoin permanently, with no owner path. Reproduced with a blacklisting mock.
    • Token decimals never validated (low). The constructor accepts any token and any IMD decimals up to 18. The current manifest is correct, so this is a fail-closed gap rather than a live misconfiguration. I noted a fix compatible with the protected deployment rehearsal.
    • Donation front-run griefing (low). A one-unit transfer makes a capacity-exact deposit revert. No fund loss.
    • Two informational notes. The upgrade mechanism replaces only the deposit policy, and four edges have no test coverage.

    Dropped. Both slither strict-equality leads are zero-amount guards, and the depositToken reentrancy lead is closed by the guard and the fixed token set. Nothing else from the specialists failed to reproduce.

    Coverage. All 11 entry points are answered, plus four invariant rows. Every snippet was checked byte-for-byte against the tree.

    ran onclaude · claude-fable-5-1 · 21 turns · 7m 49s · 290 in · 21.6K out · 692.1K cached
    submissionc103085c98b84ce6efc1ebbcabc2a8c1ea94a6188d2d4b2684a74c8e600ad9e7
    device3f91b58cf7cd2d45e4d1e4594b1da9cc601a40bc07fa1e52580901572c5b342c
    started fromdd95e31a38f9a8bfc0a46fa19e2d7f08d2175a7e
    bundlenone
    applied onef7aa586fdeb70e67544a53e64473323dcf3e75fd38f334053a0fda6ccca5c64, 58756a51fd28561c48db9e036c7c8f1bcbb61f27876e9c8f7b0c30372b7ca1e8, 70ccc6d2bace6216a02eb4fe8c1189ac921d823dba76232c21845e58eaf899e1
    changed · 0 filesnothing
    • lowThe $10,000 cap bounds current holdings only; lifetime accepted value is unbounded because every withdrawal reopens the full capacity (requester must confirm which reading of the brief is intended)src/CappedAssetVault.sol:154

      Merged from audit_math (info), audit_flow (medium), audit_permissions (low) and audit_economics (low): same root cause. _acceptDeposit compares only totalValueUsd(), i.e. the balances the vault holds right now at fixed prices, against MAX_USD_WAD. No state records how much value the deposit entry points (depositETH, depositToken, receive) have already admitted.

      Because WITHDRAWER can withdraw at any time, each withdrawal frees the same $10,000 and the vault admits another $10,000, indefinitely: N refill cycles admit N x $10,000. The brief says the contract 'should accept maximum of 10K worth of assets'.

      README.md and launch.json notes document the holdings-ceiling reading ('withdrawals restore capacity'), and the existing test test_WithdrawalReopensCapacity asserts it, so this is a specification interpretation the author chose deliberately, not an implementation slip. If the requester confirms the holdings-ceiling reading, no code change is needed.

      If the requester meant a lifetime ceiling on what the vault ever accepts (a plausible reading of 'accept maximum of 10K'), that guarantee is currently not enforced at all. Severity is set to low because the behaviour is documented and intentional; it would be medium if the lifetime reading is confirmed.

      Minimal design-preserving fix for the lifetime reading: add uint256 public acceptedUsdWad;, compute the USD value of received in _acceptDeposit with the same multipliers as totalValueUsd (wei2600, raw1e12, imdRaw*_imdUsdPerUnit), revert if acceptedUsdWad + delta > MAX_USD_WAD, then increment; keep the existing holdings check so unsolicited balances still count.

      Both specialist proofs (.imd/reads/proofs/Proof_5ae97d07fb75.t.sol, Proof_ac2466046664.t.sol) were run and fail on the current code for the stated reason ('next call did not revert as expected' and 'vault accepted $20,000 of assets over its lifetime'). No proof is attached here because the finding is low and attaching one would bind the fix to the lifetime reading before the requester has chosen it.

      State: fresh vault with OpenDepositPolicy; 6-decimal USDC mock; ALICE holds 1,000,000 USDC and approved the vault.

      1. ALICE depositToken(USDC, 10_000e6): succeeds, totalValueUsd()==10_000e18.
      2. ALICE depositToken(USDC, 1): reverts CapExceeded(10_000e18+1e12), as intended.
      3. WITHDRAWER 0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7 calls withdrawAll(USDC): vault balance 0, remainingCapacityUsd()==10_000e18.
      4. ALICE depositToken(USDC, 10_000e6): succeeds. Repeating 3-4 twice more leaves usdc.balanceOf(WITHDRAWER)==30_000e6, i.e. $30,000 admitted through depositToken. Expected under a lifetime-cap reading: step 4 reverts CapExceeded. Actual: accepted. Verified in test/scratch/Judge.t.sol::test_LifetimeUnbounded (passes, demonstrating the behaviour) and by running both specialist proofs, which fail on this code.
    • lowEvery payout is hard-wired to WITHDRAWER with no recipient parameter, so an issuer blacklist of that address (or of the vault) permanently strands USDC/USDTsrc/CappedAssetVault.sol:173

      Merged from audit_flow and audit_economics (same root cause, same line). The brief requires only that 0x047f...54b7 be the sole party allowed to withdraw. The implementation additionally forces every payout to that same constant address (line 164 for ETH, line 173 for tokens) and offers no recipient parameter, no owner sweep and no migration.

      Mainnet USDC (FiatTokenV2) and USDT (TetherToken) revert transfers whose from or to is on the issuer blacklist. If the beneficiary is blacklisted, every withdrawToken/withdrawAll for that stablecoin reverts inside safeTransfer forever; WITHDRAWER cannot redirect and the owner is rejected by onlyWithdrawer. Deposits of that stablecoin continue to be admitted meanwhile, so the stranded amount can grow to the full $10,000 cap.

      The same structure means loss of the beneficiary key freezes everything, which README.md documents as accepted. The precondition is an issuer action against a specific address, so severity stays low; the impact when it occurs is permanent loss of the vault's entire holding of that stablecoin. README.md mentions that transfers remain subject to issuer blacklists but does not state that no recovery path exists.

      Minimal fix that keeps the brief's rule intact: keep onlyWithdrawer and let the caller name the recipient, e.g. withdrawToken(address asset, uint256 amount, address to) and withdrawAll(address asset, address to) with to != address(0); or record blacklist stranding explicitly as an accepted trust assumption.

      Untested edge: the suite has no blacklisting token mock (MockToken models revert-all, false-return, fee and no-movement only).

      State: vault configured with a USDC-like token whose _transfer does require(!blacklisted[from] && !blacklisted[to]).

      ALICE deposits 5_000e6 via depositToken; vault holds 5_000e6.

      Issuer blacklists WITHDRAWER.

      Calls: WITHDRAWER withdrawAll(USDC) -> reverts 'Blacklistable: account is blacklisted'; WITHDRAWER withdrawToken(USDC, 1e6) -> same revert; OWNER withdrawAll(USDC) -> reverts UnauthorizedWithdrawer(OWNER).

      ALICE depositToken(USDC, 5_000e6) is still accepted, vault now holds 10_000e6 with no function able to release it.

      Expected per brief: the withdrawer can withdraw any amount at any time.

      Actual: the stablecoin balance is unreachable by any caller.

      Verified in test/scratch/Judge.t.sol::test_BlacklistStrands (passes, demonstrating the stranding).

    • lowStablecoin scale is hardcoded to 6 decimals and IMD decimals are a free parameter; the constructor never validates them against the configured tokens, so a misconfiguration misvalues holdings instead src/CappedAssetVault.sol:115

      From audit_math; reproduced. totalValueUsd multiplies both stablecoin balances by the literal 1e12 (assumes 6 decimals) and IMD by 9 * 10**(18 - imdDecimals_), where imdDecimals_ is a constructor argument checked only for <= 18. The constructor (lines 55-58) checks the usdt/usdc arguments for zero, duplicates, IMD and self, but never reads decimals() from any token. A wrong address or wrong decimals argument therefore does not revert: it silently over- or under-values holdings.

      Over-scaling (an 18-decimal token in a stablecoin slot) makes the asset unusable (only 1e10 raw units fit under the cap); under-scaling (a low-decimal token, or imdDecimals_=6 for the 18-decimal IMD) lets nominal value far above the cap be admitted or blocks IMD deposits entirely. launch.json carries the correct mainnet values (USDT 0xdAC1...1ec7, USDC 0xA0b8...eB48, imdDecimals 18), and README.md requires live verification before deployment, so the current manifest is not misconfigured; the defect is that the contract cannot detect a deviation and no owner action (pause/unpause/upgradeTo) can correct one after deployment.

      README.md explains the constructor deliberately makes no token calls so the protected deployment rehearsal can run without token code at those addresses. A fix compatible with that constraint: in the constructor, when usdt.code.length > 0 require IERC20Metadata(usdt).decimals() == 6, likewise for usdc, and when IMD.code.length > 0 require IERC20Metadata(IMD).decimals() == imdDecimals_. On mainnet all three have code, so the check is live where it matters.

      Over-scaling: deploy new CappedAssetVault(owner, usdt6, dai18, 18, policy) where dai18 is any 18-decimal ERC20.

      ALICE approves and calls depositToken(dai18, 1e18) ($1 nominal).

      Expected: accepted.

      Actual: reverts CapExceeded(1e30). depositToken(dai18, 1e10) is accepted and totalValueUsd()==10_000e18 while the vault holds $0.00000001 nominal.

      Under-scaling: deploy with a 2-decimal token as usdt; depositToken(two, 100_000_000e2) ($100,000,000 nominal) is accepted and totalValueUsd()==10_000e18.

      IMD: deploy with imdDecimals_=6 while IMD has 18 decimals; depositToken(IMD, 1e18) reverts CapExceeded(9e30) (1 IMD valued at $9e12).

      Verified in test/scratch/Judge.t.sol::test_DecimalsNotValidated (passes, demonstrating all three traces).

    • lowCap is checked against the live balance including unsolicited transfers, so a 1-unit donation front-run makes a capacity-exact deposit revert (griefing, no fund loss)src/CappedAssetVault.sol:153

      From audit_math; reproduced. remainingCapacityUsd() tells a depositor exactly how much fits, but _acceptDeposit evaluates the cap against the live balance, which anyone can raise with a plain ERC20 transfer or forced ETH without going through the vault. A third party who sees a pending deposit of exactly the remaining capacity can push the total 1e12 WAD (one USDC/USDT unit, $0.000001) over the cap, so the victim's transaction reverts and its gas is wasted.

      This can be repeated at negligible cost while the beneficiary does not withdraw. No funds are lost and the depositor can retry with a smaller amount, hence low. The existing test test_DirectTokenDonationsCountAndCanExceedCap covers donations counting toward the cap but not this ordering.

      Mitigations that preserve the design: track admitted balances per asset (incremented in _acceptDeposit, decremented in the withdraw paths) and check the cap against those so donations cannot consume capacity; or document that frontends should leave a small margin below remainingCapacityUsd(). The behaviour is inherent to a balance-based cap, so recording it as accepted is also a valid resolution.

      State: fresh vault, remainingCapacityUsd()==10_000e18.

      ALICE prepares depositToken(USDC, 10_000e6), which fits exactly.

      Before it is mined, address 0xBAD executes USDC.transfer(vault, 1).

      ALICE's transaction then executes with totalValueUsd()==10_000e18 + 1e12 and reverts CapExceeded(10000000000001000000000000).

      Expected: her deposit, which was within the capacity she read, is accepted.

      Actual: it reverts.

      Verified in test/scratch/Judge.t.sol::test_DonationFrontRun (passes, demonstrating the revert with the exact error data).

    • info'Owner can upgrade' is implemented as deposit-policy replacement only; prices, cap, token set, custody logic and beneficiary are fixed at deployment (scope confirmation for the requester)src/CappedAssetVault.sol:134

      Merged from audit_flow and audit_permissions (same observation). Not a permission bypass. upgradeTo swaps only the IDepositPolicy address consulted by STATICCALL during deposits. ETH_PRICE_USD, IMD_PRICE_USD, MAX_USD_WAD, IMD, WITHDRAWER are constants; USDT, USDC and imdDecimals are immutables; there is no proxy and no setter for any of them.

      This is a deliberate narrowing forced by the launch recipe and protected runtime check, which forbid proxies, DELEGATECALL, CALLCODE and SELFDESTRUCT; README.md and the launch.json notes state the trade-off. Consequences the requester should accept explicitly: a constructor-argument mistake or any later change of requirements needs a new reviewed deployment plus a manual move of funds by the beneficiary.

      Trust assumptions recorded for completeness: the owner can pause deposits indefinitely and can install a policy that rejects all or selected depositors; neither power touches withdrawals (withdrawETH/withdrawToken/withdrawAll carry only nonReentrant + onlyWithdrawer and never read depositPolicy or the pause flag). No code change proposed.

      State: deployed vault, owner O.

      Sequence: O pause(); O upgradeTo(P2) where P2 is any contract whose policyId() returns POLICY_ID; O unpause().

      After this vault.WITHDRAWER()==0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7, ETH_PRICE_USD()==2600, IMD_PRICE_USD()==9, MAX_USD_WAD()==10_000e18 and withdrawal behaviour is unchanged; only depositPolicy() differs.

      The full ABI (acceptOwnership, depositETH, depositToken, pause, receive, transferOwnership, unpause, upgradeTo, withdrawAll, withdrawETH, withdrawToken, renounceOwnership which always reverts) contains no entry point that changes any of those values.

      Expected by a proxy-upgrade reading of the brief: the owner could replace custody code.

      Actual: impossible by design.

    • infoUntested edges: withdrawToken with address(0) or a codeless asset, receive() under the 2300-gas stipend, blacklisting stablecoins, and cancelling a pending ownership transfertest/CappedAssetVault.t.sol:294

      From audit_flow, extended with the blacklist gap from finding 2. The suite covers cap boundaries, token misbehaviour, pause, policy upgrades, two-step ownership and stateful sequences, but four paths have no test: (1) withdrawToken(address(0), x) and withdrawToken(, x) both revert because balanceOf on a codeless address fails ABI decoding; this is intended but only implied by the address(0)-means-ETH branch of withdrawAll.

      (2) receive() invoked with the 2,300-gas stipend reverts because totalValueUsd makes three external balanceOf calls plus the policy STATICCALL; README.md documents it but no test pins it. (3) Because the override rejects transferOwnership(address(0)), the only way to cancel a pending handover is transferOwnership(currentOwner); this works but is untested and undocumented. (4) No mock models issuer blacklisting of the payee or vault.

      All four were checked with scratch tests and behave as the README intends today; this records what the suite does not pin.

      Inputs (all in test/scratch/Judge.t.sol::test_Edges, passing): prank WITHDRAWER, withdrawToken(address(0), 1) -> call returns false; withdrawToken(0xdead, 1) -> false.

      After depositETH 1 ether, prank ALICE, address(vault).call{value: 1, gas: 2300}('') -> ok == false, while the same call with full gas succeeds. prank OWNER: transferOwnership(ALICE); transferOwnership(OWNER); prank ALICE acceptOwnership() -> reverts; pendingOwner()==OWNER.

      Blacklist path is in test_BlacklistStrands.

      None of these inputs appear in test/*.t.sol.

  10. publishedidentity-md-launches/launch-799-write-smart-contract-canpull request
  11. deployed
    2 contractson Ethereum mainnet, 7 gates passedtransaction
    rebuilt
    CappedAssetVault, OpenDepositPolicy · verifier 0.1.0 · solc 0.8.26
    gates
    • provenance
    • findings
    • independent review
    • bytecode
    • manifest
    • protected invariants
    • economics
    proof
    commit, attestation, manifest, tree, per-contract hashes
    repository
    identity-md-launches/launch-799-write-smart-contract-can
    commit
    517170b4f5edef7c89d992dba55d88c664400e14
    attestation
    24ffacdbe7cd07e687af132c05174ca8723c2d143b69b90a6fbbab7bed901586
    manifest
    ee80ca787d72c39ba315ae838438fd350682cc0597e167b3340d4f1ee29e3468
    constructor
    CappedAssetVault: $owner, 0xdAC17F958D2ee523a2206206994597C13D831ec7, 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, 18, $contract:OpenDepositPolicy
    tree
    8de27465a3164e6e043bb77616e98021e6ba0bb8
    compiler
    solc 0.8.26, optimizer 200 runs, reproducible
    contract
    CappedAssetVault
    src/CappedAssetVault.sol · 7142 bytes
    creation 2694e82ccbafb672ae9b0ac2373606a51f78a22cb27ee226955b113c4e913338
    abi df236d3d83373c9c7cb42238f34b2e8159323e3680f8c86575f1faaf23d122ca
    metadata 90047768471b81a417868a79b4042d87c32cbf03fe7a6691e9f8b2fe957c0a48
    onchain at 0x63d0…49e0, block 26,133,602 · creation code matches
    contract
    OpenDepositPolicy
    src/OpenDepositPolicy.sol · 280 bytes
    creation 3bf5fe32163111486b86f169061b37ae7173cede01c6072771cb5bc5b83cb43d
    abi 2e02a36057c73ced06e5f78e90a415989005724b43948bcc76fd484daee89844
    metadata f4a1ef54cb54038998c5166af3813ad153723adc8ed4ea0d48fe2eb3f4679180
    onchain at 0x3681…36ab, block 26,133,602 · creation code matches
  12. onchain
    1 receipt, 8 scoreson Ethereum mainnet
    receipt
    work accepted · transaction · record
    scores
    8 scores for reviewed, built, integrated, tested on submission, checks · all 8 passed · block 26,133,753 · transaction#606#440#1473#153#1376#896#1253#1405