Security review and resolutions
We put our own deployed contract through an adversarial review. It found two critical flaws. This page publishes all of them, the change that closes each one, and the test that fails if it ever comes back.
Why this page exists
A credit bureau's only real asset is being trustworthy about its own numbers. A team that quietly ships an unenforced limit and calls it enforcement is not one you want underwriting anything. So when a review of our first deployment found that the limit was not actually binding, the choice was to publish it rather than patch quietly.
Nothing was ever at risk. No policy had been installed and no capital sat behind the contract — which is precisely why we deployed it out of service first.
The finding that mattered
Version one recorded a draw amount against the credit limit, then executed a call whose calldata the borrower supplied. The contract never hashed or decoded that calldata. An account could book one dollar and move ten thousand. The number was bookkeeping, not a constraint.
Worse, because the available-headroom figure clamped at zero, a draw of zero always passed the check — so even an agent with a zero limit held an unrestricted execute trampoline.
Version two removes the borrower's calldata entirely. The manager constructs the ERC-20
transfer itself from (asset, recipient), reads
balanceOf(account) before and after the callback, and reverts unless exactly the
booked amount moved.
Every finding
| ID | Finding | Fix in v2 | Regression test |
|---|---|---|---|
| C1 Critical | draw booked amount but executed caller-supplied calldata, so an account could book $1 and move $10,000 | draw no longer accepts calldata. It takes (asset, recipient), builds the transfer itself, and measures balanceOf(account) either side of the callback. A mismatch reverts SpendMismatch. | test_C1_AccountCannotMoveMoreThanBooked |
| C2 Critical | amount == 0 always passed the limit check, making a live policy an unrestricted execute trampoline | Explicit if (amount == 0) revert ZeroAmount(). A zero limit now grants no authority at all. | test_C2_ZeroLimitCannotDrawAnything |
| H1 High | repay was pure storage math callable by the borrower, outside the reentrancy guard — free debt erasure | Split in two. repayWithAsset pulls tokens via transferFrom to a settlement sink and only then reduces the book. settle is onlyBureau and logged. | test_H1_AccountCannotSettleItsOwnDebt |
| H2 High | A non-zero keystore commitment outranked signature-checked terms, authorising draws with no bureau signature | The keystore path is removed entirely. The only route to a commitment is an EIP-712-checked install. | test_H2_NoKeystoreSurfaceRemains |
| M1 Medium | _accrue advanced the clock even when truncation booked zero, so poking every few seconds stopped interest forever | The clock only advances when something was actually booked. Zero-amount settlement is rejected. | test_M1_ClockDoesNotAdvanceWhenNothingAccrues |
| M2 Medium | No nonce: a revoked signature could be replayed to restore an old limit, and the bureau could not push a tighter one | nonce added to the signed struct, incremented on install, revoke and default. installPolicyFor and revokeFromBureau let the bureau re-underwrite without the borrower. | test_M2_RevokedSignatureCannotBeReplayed |
| M3 Medium | Two hot keys with instant, unbounded power; markDefault needed no debt | Signer rotation behind a 2-day timelock. Ownership is two-step. markDefault requires outstanding obligation. | test_M3_BureauSignerRotationIsTimelocked |
| L1 Low | Repaid reported the requested amount, overstating settlement on overpay | _reduce returns the applied figure; Settled emits that. | test_L1_SettledEventReportsAppliedNotRequested |
| L2 Low | accruedAt as uint64 truncates in 2106 and can brick a policy | Stored as uint256. | covered by every accrual test |
| L3 Low | Reentrancy guard covered only draw | nonReentrant on every state-changing entry point; the account callback is treated as hostile. | test_L3_ReentrantDrawDuringCallbackBlocked |
| I1 Info | Interest truncates toward the borrower | Unchanged and intentional. Documented. | test_InterestMatchesHandCalculation |
| I2 Info | Epoch bump leaves commitments and debt in place | Unchanged and intentional: a bump freezes borrowing without forgiving or stranding debt. | test_EpochBumpDoesNotEraseDebt |
Properties now enforced rather than assumed
- Booked equals moved. The manager builds the transfer and verifies the balance delta.
- Still non-custodial.
balanceOf(manager) == 0after a draw, and repayments route to a sink that can never be the manager itself. - The asset allowlist binds. A draw against an unlisted token reverts — tested on a mainnet fork against real USDC and real WETH.
- Default is atomic. Interest is captured and authority destroyed in one transaction, with no window to draw through.
Verification
Suite 73 tests passing (69 unit + 4 Base Sepolia fork)
Coverage 98.95% lines · 97.67% statements · 86.96% branches · 96.97% functions
Size runtime 9,947 B (40% of the EIP-170 ceiling)
Deploy ~2.96M gas (~$0.10 on Base)
The fork tests run against live Base state and the real Circle USDC contract, through an ERC-4337-shaped account that performs its own transfers.
Signature acceptance, proven against mainnet
Offline signature recovery only proves we signed something. To prove the deployed contract
accepts our terms and rejects tampering, we simulate the real installPolicy call
against live mainnet state using terms fetched from the live bureau endpoint:
BadSignature()BadSignature()The signature is bound to both the value and the account, on the contract as deployed.
Still open before real money moves
- External audit by a named firm. This resolution set answers an AI-assisted review, which is a pre-audit.
- Bureau signer to an HSM. It is currently a hot key, and it holds no funds by design — it signs, it never transacts.
- Owner to a multisig. Ownership currently sits with a dedicated cold account that is deliberately kept off every internet-facing host; a multisig is still the right end state.
- Liquidation remains off-chain.
markDefaultrecords the event; recovery is a business process.
Addresses
Network Base mainnet (chainId 8453)
Manager v2 0x4c2d505f9c76aF780573b6451c2D20eEe76bbc78 in force
Verified Sourcify exact match on creation and runtime bytecode
Superseded 0x6668f85DEB0f49d048ceC07D9aa059dD23BF9A36 epoch bumped, all its terms void
View v2 on Basescan · Verified source on Sourcify · Scoring methodology
Review conducted 2026-08-24. Resolutions deployed the same day. Bumping the superseded contract's epoch permanently voids every set of terms ever issued for it.