Saffron Fixed Income Vaults is a fixed income protocol that enables yield exchange through vaults where fixed-side participants deposit assets into a productive instrument (like Uniswap V3 LP positions) to receive upfront fixed payments, while variable-side participants pay those fixed amounts to earn all future yield generated over the vault's duration.
Scope
On what chains are the smart contracts going to be deployed?
Ethereum mainnet
Arbitrum
Optimism
BNB chain
Base
Polygon
Avalanche
Berachain
Hyperliquid EVM
If you are integrating tokens, are you allowing only whitelisted tokens to work with the codebase or any complying with the standard? Are they assumed to have certain properties, e.g. be non-reentrant? Are there any types of weird tokens you want to integrate?
Token Standards Used
The contracts integrate ERC20 tokens only. Specifically:
Standard ERC20 tokens for the variable asset (premium payments)
Token pairs (token0/token1) from Uniswap V3 pools, which are also ERC20 tokens
The protocol uses OpenZeppelin's SafeERC20 library for safe token transfers
Whitelisting Mechanism
No explicit token whitelisting is implemented. The contracts allow:
Any ERC20 token to be used as the variableAsset (specified during vault initialization)
Any valid Uniswap V3 pool tokens (token0/token1) through the adapter
The only validation is that pool addresses must be genuine Uniswap V3 pools (verified against the Uniswap V3 Factory in UniV3LimitedRangeAdapter.sol:124-130).
Token Properties and Assumptions
Required Properties:
Non-deflationary tokens only - The contracts explicitly check for this:
Standard ERC20 compliance - Tokens must properly implement:
Reentrancy protection - While the contracts use ReentrancyGuard modifier, tokens themselves are not explicitly required to be non-reentrant
Weird Token Traits Not Supported
The contracts will NOT work with:
❌ Deflationary/fee-on-transfer tokens - Explicitly blocked by balance checks (error code "NDT")
❌ Rebasing tokens - Would break accounting as balances change unexpectedly
❌ Tokens with transfer hooks that could cause reentrancy (though ReentrancyGuard provides some protection)
❌ Tokens with blacklisting - Could brick the vault if addresses get blacklisted
❌ Tokens with pause functionality - Could freeze vault operations
❌ Non-standard decimals - While 6-18 decimals should work, extreme values might cause issues
In summary, we allow any standard ERC20 tokens that comply with the ERC20 standard, with the explicit requirement that they must be non-deflationary. Tokens are not whitelisted but must pass our deflationary check (balance validation) during deposits. We do NOT support weird tokens with fee-on-transfer, rebasing, pausable, or blacklisting mechanisms. Standard tokens with 6-18 decimals are supported.
Examples of supported tokens: USDC, USDT, DAI, WETH
Examples of unsupported tokens: Any deflationary tokens, AMPL (rebasing), or tokens with transfer fees
Are there any limitations on values set by admins (or other roles) in the codebase, including restrictions on array lengths?
Access Control and Admin Limitations
Identified Roles
Owner (from OpenZeppelin's Ownable)
FeeReceiver
Admin-Controlled Functions and Their Limitations
VaultFactory Owner Functions:
setFeeBps(uint256 _feeBps)
setFeeReceiver(address _feeReceiver)
setDefaultDepositTolerance(uint256 _defaultDepositTolerance)
addVaultType(bytes calldata bytecode)
addAdapterType(bytes calldata bytecode)
revokeVaultType(uint256 id)
revokeAdapterType(uint256 id)
Array Length Restrictions
No explicit array length restrictions. The contract uses:
Fixed-size arrays created in memory (new uint256 and new uint256)
Bytes arrays for bytecode with no upper limit enforced
No loops over unbounded arrays
Hardcoded Values
10_000 - Maximum basis points (100%)
type(uint128).max - Maximum liquidity capacity check
1e18 - Scaling factor for calculations
100 - Default deposit tolerance (1%) in VaultFactory
Trust Assumptions
Owner is FULLY TRUSTED
Can deploy arbitrary bytecode as vaults or adapters
Can change fees up to 99.99%
Can change fee receiver to any address
Cannot renounce ownership (protection against accidental loss)
In RestrictedVaultFactory: Owner has exclusive rights to create vaults/adapters
FeeReceiver is TRUSTED
Only collects accumulated fees
Cannot modify protocol parameters
Address can be changed by Owner at any time
In summary, the Owner role is fully trusted with the following input validations:
feeBps: Limited to < 10,000 (less than 100%)
feeReceiver: Cannot be zero address
defaultDepositTolerance: Must be between 1 and 10,000 basis points
Bytecode parameters: Must be non-empty, but no upper size limit
No array length restrictions are implemented as the contract doesn't process unbounded arrays
Ownership cannot be renounced (safeguard against accidental loss)
FeeReceiver is trusted only to collect fees correctly and has no administrative powers.
No plans to change hardcoded values - the constants (10_000 for basis points, type(uint128).max for capacity checks, 1e18 for scaling) are standard values that should remain fixed.
Are there any limitations on values set by admins (or other roles) in protocols you integrate with, including restrictions on array lengths?
No
Is the codebase expected to comply with any specific EIPs?
The codebase is expected to comply with the following EIPs:
EIP-20 (ERC-20 Token Standard)
Implementation:
VaultBearerToken.sol extends OpenZeppelin's ERC20
All bearer tokens (fixed, variable, claim) are ERC-20 compliant
Integration with any ERC-20 tokens for the variableAsset
Intention:
Provides fungible representation of vault positions, allowing users to transfer, trade, or integrate their vault shares with DeFi protocols
Ensures compatibility with the broader Ethereum ecosystem (wallets, DEXes, etc.)
Enables fractional ownership and liquidity for fixed income positions
EIP-721 (ERC-721 Non-Fungible Token Standard)
Implementation:
UniV3LimitedRangeAdapter.sol implements onERC721Received
Receives and manages Uniswap V3 position NFTs
Intention:
Required for integration with Uniswap V3's Position Manager
Uniswap V3 represents liquidity positions as NFTs (ERC-721)
Adapter must be able to receive and hold these position NFTs to manage liquidity
EIP-173 (Contract Ownership Standard)
Implementation:
VaultFactory.sol extends OpenZeppelin's Ownable
Implements owner-only functions with onlyOwner modifier
Overrides renounceOwnership() to prevent accidental loss
Intention:
Provides administrative control over factory settings (fees, vault types, adapter types)
Ensures upgradability and parameter adjustments
Protection against ownership loss maintains protocol governability
Implicit Standards via OpenZeppelin:
EIP-165 (Standard Interface Detection) - Implicitly via OpenZeppelin's ERC20 implementation
SafeERC20 practices - Using OpenZeppelin's SafeERC20 library for safe token transfers
In summary, the codebase complies with:
EIP-20 (ERC-20): For all bearer tokens (VaultBearerToken) to ensure fungibility and DeFi composability
EIP-721 (ERC-721): For receiving Uniswap V3 position NFTs (required for liquidity management)
EIP-173 (Ownable): For administrative control of the factory contract
These standards are essential for:
Allowing users to trade/transfer their vault positions
Integrating with Uniswap V3's NFT-based liquidity system
Maintaining protocol governance and upgradability
Ensuring compatibility with wallets, explorers, and DeFi infrastructure
Are there any off-chain mechanisms involved in the protocol (e.g., keeper bots, arbitrage bots, etc.)? We assume these mechanisms will not misbehave, delay, or go offline unless otherwise specified.
The protocol has NO off-chain mechanisms or keeper bots. All functions are user-triggered:
What properties/invariants do you want to hold even if breaking them has a low/unknown impact?
Critical Protocol Invariants
Bearer Token Supply Invariants
Invariant: The sum of all bearer tokens must exactly equal the deposited amounts
fixedBearerToken.totalSupply() + claimToken.totalSupply() == fixedSideDeposits
variableBearerToken.totalSupply() == variableSideDeposits + protocolFees
Why Critical: Even 1 wei discrepancy indicates accounting error that could compound over time
Vault State Transitions
Invariant: Vault states must follow strict progression
NOT_INITIALIZED → INITIALIZED → STARTED → ENDED
isStarted can only transition from false → true (never back)
earningsSettled can only transition from false → true (never back)
endTime must be immutable once set
Why Critical: State regression could allow double-claiming of funds
Single Position per Vault
Invariant: Each vault must have exactly 0 or 1 Uniswap V3 position
tokenId == 0 (before deployment) XOR tokenId > 0 (after deployment)
Why Critical: Multiple positions would break fee distribution logic
Capacity Constraints
Invariant: Deposits must never exceed defined capacities
variableBearerToken.totalSupply() <= variableSideCapacity
claimToken.totalSupply() <= 1 (for fixed side in UniV3Vault)
Why Critical: Over-deposits could break vault economics and premium calculations
Token Conservation
Invariant: No tokens should be created or destroyed except through mint/burn
sum(userBalances) + vaultBalance == totalMinted - totalBurned
Why Critical: Lost tokens indicate a vulnerability or accounting error
Fixed Side Claim Token Exchange
Invariant: Claim tokens must be fully exchangeable for bearer tokens 1:1
claimToken.burn(amount) => fixedBearerToken.mint(amount)
Why Critical: Broken exchange would lock fixed depositor funds
Earnings Distribution
Invariant: Total earnings distributed must equal collected fees
earnings0 + earnings1 == totalFeesCollectedFromUniswap
distributedEarnings <= earnings0/earnings1
Why Critical: Fee leakage or creation violates user expectations
Liquidity Bounds
Invariant: Actual liquidity must remain within tolerance of target
targetLiquidity * (10000 - tolerance) / 10000 < actualLiquidity <= targetLiquidity * (10000 + tolerance) / 10000
Why Critical: Exceeding bounds could enable economic attacks
Time Monotonicity
Invariant: Time-based checks must respect blockchain time
block.timestamp > endTime required for normal withdrawals
endTime == startTime + duration (once set)
Why Critical: Time manipulation could enable premature withdrawals
Fee Basis Points
Invariant: Protocol fee must remain within valid range
0 <= feeBps < 10000
feeDivisor == 10000 - feeBps
Why Critical: Invalid fees could brick withdrawals or steal funds
Adapter-Vault Binding
Invariant: Each adapter must be bound to exactly one vault
adapter.vaultAddress != address(0) after setVault()
adapter.vaultAddress immutable after setting
Why Critical: Adapter reuse could mix funds between vaults
NFT Ownership
Invariant: Adapter must own the Uniswap V3 NFT position
positionManager.ownerOf(tokenId) == address(adapter)
Why Critical: Lost NFT means lost liquidity
These invariants should hold with ZERO tolerance, even if the immediate impact seems minimal because:
Compound Effects: Small discrepancies can accumulate over time
Trust Erosion: Users expect mathematical precision in DeFi
Attack Indicators: Invariant violations often reveal attack vectors
Integration Risk: Other protocols may depend on these invariants
Special Attention:
Bearer token accounting (even 1 wei matters)
State machine integrity (no backward transitions)
Time-based guarantees (no early withdrawals)
Capacity limits (no over-deposits)
Please discuss any design choices you made.
Design Choices and Trade-offs
Deflationary Token Rejection
Choice: Explicitly block deflationary/fee-on-transfer tokens with balance checks in UniV3Vault.sol:62-66
// Transfer (restricted to non-deflationary tokens)
uint256 oldBalance = IERC20(variableAsset).balanceOf(address(this));
IERC20(variableAsset).safeTransferFrom(msg.sender, address(this), amount);
uint256 newBalance = IERC20(variableAsset).balanceOf(address(this));
require(amount == newBalance - oldBalance, "NDT");
Rationale: This simplifies accounting and prevents exploitation through token mechanics. Supporting deflationary tokens would require complex adjustments throughout the
protocol.
Risk: None - this is a protective measure.
Single Position per Vault
Choice: Each vault manages exactly one Uniswap V3 position (tokenId)
Rationale: Simplifies liquidity management and earnings distribution. Multiple positions would complicate fee collection and proportional distributions.
Trade-off: Less flexibility in liquidity management strategies but much simpler to audit and reason about.
Auto-Start Mechanism
Choice: Vaults automatically start when both sides reach capacity (no keeper needed)
if (claimToken.totalSupply() == 1 && variableBearerToken.totalSupply() == variableSideCapacity) {
start();
}
Rationale: Eliminates need for external triggers or admin intervention
Risk: Potential for griefing if someone deposits 99.9% of variable capacity, preventing vault start
Fee Collection Timing
Choice: Protocol fees are minted as bearer tokens to the vault itself, claimed later by feeReceiver
// Mint bearer tokens for protocol fee to the vault itself to avoid feeReceiver address drift
variableBearerToken.mint(address(this), fee);
Rationale: Prevents issues if feeReceiver address changes between vault start and end
Trade-off: Requires extra step for fee collection but ensures fees go to current fee receiver
Liquidity Tolerance Bands
Choice: Allow ±1% deviation in liquidity amounts (configurable via depositTolerance)
require(uint256(l) * invDepositTolerance / 10000 < _liquidity, "L");
require(_liquidity <= uint256(l) * (10000 + upperDepositTolerance) / 10000, "Excessive liquidity");
Rationale: Uniswap V3 price movements between calculation and execution can cause slight differences
Risk: Could allow sandwich attacks within tolerance bands - auditors should verify this is acceptable
No Partial Withdrawals
Choice: Users must withdraw their entire position at once
Rationale: Simplifies accounting and prevents gaming of the fee distribution mechanism
Trade-off: Less flexibility for users but ensures fair distribution of earnings
Claim Token Design
Choice: Fixed side gets claim tokens that must be exchanged for bearer tokens after vault starts
Rationale: Ensures fixed depositors receive their premium payment (variable deposits) before getting liquidity tokens
Risk: Additional transaction required, but ensures atomic premium payment
No Emergency Withdrawal
Choice: No admin functions to pause or emergency withdraw
Rationale: Fully decentralized operation, no admin risk
Trade-off: Cannot recover from extreme scenarios (e.g., Uniswap V3 exploit), but eliminates centralization risk
Fixed Capacity Limits
Choice: Vault capacities are immutable once initialized
Rationale: Provides certainty to depositors about vault economics
Trade-off: Cannot adjust for market conditions after deployment
Ownership Cannot Be Renounced
Choice: Override renounceOwnership() to prevent accidental loss
function renounceOwnership() public override {}
Rationale: Prevents permanent loss of admin capabilities
Risk: None - purely protective
No Slippage Protection in Vault
Choice: Slippage parameters passed through to Uniswap but not validated by vault
Rationale: Users are responsible for their own slippage tolerance
Risk: Users could submit transactions with poor slippage settings, but this is their choice
Rounding Ignored in Fee Calculations
Choice: Use simple multiplication/division for fees without rounding considerations
uint256 fee = FullMath.mulDiv(variableBearerToken.totalSupply(), feeBps, feeDivisor);
Rationale: Dust amounts are negligible for fee calculations
Risk: Minimal - maximum loss is 1 wei per calculation
Critical Assumptions
Uniswap V3 Pools are Genuine: Pool validation only checks against factory, assumes factory is legitimate
Users Will Claim: No automatic distribution mechanism - relies on economic incentive
Price Movements Are Reasonable: Deposit tolerance assumes normal market conditions
No Token Upgrades: Doesn't handle upgradeable tokens that might change behavior
Please provide links to previous audits (if any) and all the known issues or acceptable risks.
Please list any relevant protocol resources.
Whitepaper: https://github.com/saffron-finance/papers/blob/main/SaffronFixedIncomeVault.pdf
Layman article: https://medium.com/saffron-finance/saffron-fixed-income-vault-calculator-83218596dfa3
Generated Docs: https://gist.github.com/psykeeper/442f17e1c6d686b63e5d56bc2d9e80f2
Error Codes: https://gist.github.com/psykeeper/fe7cf420303e3442f3c1fc06da12c61b
Additional audit information.
Pashov Audit Fixes PR https://github.com/saffron-finance/fixed-income/pull/89
Previous Audit Fixes PR https://github.com/saffron-finance/fixed-income/pull/71
Total Rewards
Contest Pool
Lead Senior Watson
Judging Pool
Lead Judge
13,000 USDC
7,000 USDC
800 USDC
1,200 USDC
Status
Scope
Start Time
End Time
Judging Rules
Reserved Auditors