← audit notes

Twyne

Credit delegation / re-lending. 16 in-scope contracts, ~3,250 lines, three leads opened, three closed, zero submissions. Everything below is a non-finding: code that looked wrong and isn't.

Why this target

Small enough to read completely in one pass, actively maintained (last commit three days before the review), and built on a primitive that isn't a fork of anything — delegated credit that gets re-lent. New primitives are where logic bugs survive, because automated tooling has no pattern for them and prior auditors have no muscle memory.

Lead 1 — unbounded conversion in _convertBaseToCollateral

Hypothesis: the function converts a base-asset amount into collateral units with no ceiling, so a user could be credited more collateral than they actually hold.

Why it looked real: a comment in the code itself warns that the result must be clamped against the user's collateral — a warning like that usually means somebody was worried, and worries are not always acted on.

Closed: both implementations do clamp. The Euler and the Aave V3 collateral vaults each take the min against the user's actual balance before use. The comment documents a rule that is, in fact, followed in every call site.

Lead 2 — underflow in skim()

Hypothesis: skim() computes balance - totalAssetsDepositedOrReserved. If the accounted total ever exceeds the real balance, that subtraction reverts — or, in an unchecked block, wraps.

Closed: the onlyBorrowerAndNotExtLiquidated modifier is the invariant. It is exactly the condition under which balance ≥ totalAssetsDepositedOrReserved is guaranteed to hold; the one state where the accounted total can exceed the balance — after an external liquidation — is the state the modifier excludes.

Lead 3 — stale accounting after an external liquidation

Hypothesis: this was the good one. After an external liquidation, totalAssetsDepositedOrReserved is deliberately not updated — the protocol needs the stale value to detect that the liquidation happened at all. So the stale, inflated figure feeds _maxRelease in the three-way collateral split. Inflated input to a distribution function is a real bug shape: over-release to one party at another's expense.

Closed: _maxRelease never reaches the transfer unclamped. splitCollateralAfterExtLiq binds the release to the live balance:

Math.min(_collateralBalance - userCollateral, _maxRelease)
// EulerCollateralVault.sol:251

However inflated _maxRelease gets, the payout is capped by collateral that actually exists.

How it was verified, not just read: a real fork test against mainnet block 23,600,528, no mocking. The protocol's two existing fuzz tests (1,000 runs each) preserve full equality in the split for every value of _maxRelease, and the end-to-end external-liquidation scenario (test_e_handleExternalLiquidation_case00) passes.

Result

Zero findings. Zero submissions. Twyne's own repository guidelines ask reviewers to be transparent about non-issues rather than file them, and that is the correct instruction — a bounty queue full of speculative reports is a tax on the team and buys the reporter nothing. The value of this page is the opposite one: these three paths are now documented as checked, with citations, so neither I nor anyone reading this needs to spend the hours again.

Reviewed 2026-08-24 against 0xTwyne/twyne-contracts-v1. All testing on a local fork. Not affiliated with Twyne.