This document outlines the security assumptions, trust model, known limitations, and emergency procedures for the Plether protocol.
All Plether contracts are non-upgradeable. Once deployed, the bytecode cannot be changed. This provides strong guarantees:
- No proxy patterns: No UUPS, Transparent, or Beacon proxies are used
- Immutable logic: Contract behavior is fixed at deployment
- Immutable parameters: CAP, oracle addresses, and token addresses cannot be changed
The only mutable state is governed by timelocks:
- Adapter address (7-day timelock)
- Treasury/staking addresses (7-day timelock)
- BasketOracle Curve pool (7-day timelock after initial setup)
These properties must always hold. Violation indicates a critical bug.
| Invariant | Description |
|---|---|
| Collateral Backing | totalAssets >= totalLiabilities where totalLiabilities = tokenSupply × CAP |
| No Value Leak | totalAssets <= totalLiabilities + accumulatedYield + dust (no funds stuck) |
| Fair Pricing | Users always pay at least the ceiling-rounded USDC cost (no rounding exploits) |
| Invariant | Description |
|---|---|
| Supply Parity | TOKEN_A.totalSupply == TOKEN_B.totalSupply - emergencyRedeemed |
| No Orphaned Tokens | If tokenSupply > 0, then totalAssets > 0 |
| Mint/Burn Symmetry | currentSupply == totalMinted - totalBurned - totalEmergencyRedeemed |
| Invariant | Description |
|---|---|
| Liquidation Irreversibility | Once isLiquidated == true, it can never become false |
| Router Statelessness | Routers hold zero tokens after any operation completes |
| Self-Reference Prevention | Treasury and staking addresses are never the Splitter itself |
| Invariant | Description |
|---|---|
| LP Tracking | trackedLpBalance <= _lpBalance() — donated LP inflates actual balance, never tracked balance |
| Gauge Consistency | gaugeStakedLp == gauge.balanceOf(address(this)) when gauge is healthy (no bricking) |
| Harvest Isolation | Only VP growth on trackedLpBalance produces harvestable yield; donated LP cannot inflate harvest |
| Dual Pricing | Deposits use optimistic NAV (max of EMA, oracle); withdrawals/harvest use pessimistic NAV (min of EMA, oracle) |
| Emergency Exit | lpWithdraw() is always callable (no whenNotPaused), providing guaranteed balanced exit |
- Assumption: Chainlink price feeds provide accurate, timely data for EUR/USD, JPY/USD, GBP/USD, CAD/USD, and CHF/USD
- Mitigation: Staleness timeouts reject stale data (24 hours for all consumers: SyntheticSplitter, RewardDistributor, BullLeverageRouter, MorphoOracle); sequencer uptime check on L2s
- Risk: If Chainlink is compromised or all 5 feeds fail simultaneously, minting is blocked but existing positions can still be redeemed. Note: the basket also depends on SEK/USD via Pyth (see below), so a Pyth failure also blocks minting.
- Assumption: Pyth Network provides accurate SEK/USD price data via PythAdapter
- Architecture: PythAdapter wraps Pyth's pull-based oracle to match Chainlink's AggregatorV3Interface
- Mitigation: 72-hour staleness timeout in PythAdapter; price validated as positive; price inversion validated (USD/SEK → SEK/USD)
- Risk (Weekend Staleness): Pyth forex feeds stop publishing during weekend market closures (~48h gap). The 72-hour tolerance covers weekends plus holiday Mondays. Friday's close price is the correct price through Sunday since forex markets are closed.
- Self-Attestation Model: PythAdapter reports
block.timestampasupdatedAtafter validating freshness internally. This mirrors Chainlink's semantics (attestation time, not data origin time) and prevents downstream consumers from rejecting valid weekend prices with tighter timeouts. - Risk (Staleness Tradeoff): Up to 72 hours of stale price data is accepted. This is acceptable because forex markets are closed during that window, but a Pyth feed failure during market hours would not be detected for up to 72 hours.
- Risk (Single Feed): Unlike Chainlink's decentralized network, Pyth SEK/USD has different trust assumptions. A Pyth compromise affects only SEK (4.2% basket weight).
- Mitigation (Update): RewardDistributor provides
distributeRewardsWithPriceUpdate()that accepts Pyth update data, allowing atomic price refresh + distribution.
- Assumption: Curve pool for USDC/plDXY-BEAR operates correctly and provides fair exchange rates
- Mitigation:
price_oracle()deviation check (2% max) prevents price manipulation attacks - Risk: Curve pool manipulation could affect ZapRouter and LeverageRouter swap outcomes; user-provided slippage protects against this
- Assumption: Curve twocrypto-ng pool for USDC/plDXY-BEAR correctly implements
add_liquidity,remove_liquidity,remove_liquidity_one_coin,get_virtual_price, andlp_price - LP Pricing:
lp_price()returns the EMA-smoothed LP price (flash-loan resistant);get_virtual_price()returns monotonically increasing virtual price used for fee yield isolation - Oracle-Derived LP Price:
2 * virtualPrice * sqrt(bearPrice)mirrors the twocrypto-ng formula; used alongsidelp_price()for dual pricing (pessimistic = min, optimistic = max) - Risk (Pool Manipulation): Spot-vs-EMA deviation guard (0.5%) blocks
deployToCurve,replenishBuffer, andlpDepositduring pool manipulation.withdrawuses EMA-based slippage floor via try/catch onlp_price(). - Risk (Pool Bricking): If
remove_liquidityreverts permanently,lpWithdrawandemergencyWithdrawFromCurveare blocked. Users must wait forsetEmergencyMode()+forceRemoveGauge()to write off stuck LP, then exit with remaining local USDC and BEAR. - Risk (Virtual Price Regression): If
get_virtual_price()decreases (Curve bug or exploit),_harvestproduces no yield (safe), butcurveLpCostVpretains the old higher value, suppressing future harvests until VP recovers.
- Assumption: Curve gauge correctly stakes/unstakes LP tokens and distributes CRV + extra rewards via
claim_rewards() - Mitigation: Gauge must be in the
approvedGaugesallowlist and pass_validateGaugeAddress(has code,lp_token()matches). Gauge changes require 7-day timelock. - Internal Tracking:
gaugeStakedLptracks staked LP internally rather than callinggauge.balanceOf(), preventing a bricked gauge from DOSing_lpBalance()→totalAssets()→ all operations. - Risk (Bricked Gauge): If
gauge.withdraw()permanently reverts, staked LP is inaccessible. Recovery:setEmergencyMode()→forceRemoveGauge()writes off stuck LP proportionally fromtrackedLpBalanceandcurveLpCostVp. Remaining local LP is still redeemable vialpWithdraw. - Risk (Reward Token Theft): CRV and other reward tokens land in the InvarCoin contract after
claimGaugeRewards(). Protected reward tokens can only be swept to the timelockedgaugeRewardsReceiver; unprotected tokens can be rescued viarescueToken().
- Assumption: Curve Minter correctly mints CRV to the gauge when
mint(gauge)is called - Note: On L2, CRV is distributed via
gauge.claim_rewards()instead;CRV_MINTERisaddress(0)
- Assumption: Chainlink sequencer uptime feed accurately reports L2 sequencer status
- Mitigation: 1-hour grace period after sequencer restart before oracle reads are trusted;
address(0)on L1 (check skipped) - Affected Operations:
deposit,lpDeposit,harvest,deployToCurve,replenishBuffer,redeployToCurve,donateUsdc— all revert during sequencer downtime or grace period - Unaffected Operations:
withdraw,lpWithdraw— use_harvestSafe()with best-effort oracle reads that skip on oracle or sequencer failures, preserving withdrawal liveness
- Assumption: Morpho Blue lending protocol correctly handles collateral, borrows, liquidations, and flash loans
- Mitigation: Router contracts validate authorization before operations; flash loan callbacks validate caller and initiator
- Risk (Bugs): Morpho protocol bugs could affect leveraged positions; users must monitor positions independently
- Note: Morpho Blue flash loans are fee-free, reducing leverage costs compared to other providers
- Assumption: Morpho Vault vault correctly manages USDC deposits, yield accrual, and withdrawals
- Risk (Share Price): Morpho Vault share price could be manipulated via donation attacks, inflating or deflating the adapter's reported
totalAssets() - Risk (Liquidity): If Morpho Vault vault liquidity is constrained (all supplied USDC is lent out), adapter withdrawals revert. Burns exceeding the local buffer will fail until vault liquidity returns. The
ejectLiquidity()emergency function is also affected. Users may be temporarily unable to redeem even if the protocol is solvent. - Mitigation (Liquidity): Use
withdrawFromAdapter(amount)for gradual liquidity extraction when the protocol is paused. This allows repeated partial withdrawals as vault liquidity becomes available. - Risk (Rounding): Nested ERC4626 (VaultAdapter wrapping Morpho Vault) introduces double rounding on deposits and withdrawals, potentially losing a few wei per operation
- Assumption: USDC maintains its $1 peg and operates as a standard ERC-20 token
- Risk (Depeg): If USDC depegs significantly, the protocol's collateral value diverges from its nominal value. Users holding plDXY tokens would receive fewer real dollars than expected on redemption.
- Risk (Blacklisting): Circle can blacklist addresses, freezing their USDC balances. If the SyntheticSplitter, VaultAdapter, or Morpho Vault vault contracts are blacklisted, the protocol cannot process redemptions or yield withdrawals.
- Risk (Upgradeability): USDC is an upgradeable proxy contract. Circle could modify transfer logic, add fees, or change behavior in ways that break protocol assumptions.
- Risk (Regulatory): Circle operates under US regulatory oversight. Regulatory action could affect USDC availability or require compliance changes that impact the protocol.
- Mitigation: None. These are fundamental risks of using USDC as collateral. Users should understand that the protocol inherits all USDC counterparty risks.
- Note: The protocol does not implement USDC depeg detection. If USDC depegs, the protocol continues operating at nominal values.
- Assumption: OpenZeppelin's audited implementations of ERC20, ERC4626, Ownable, Pausable, ReentrancyGuard, and ERC3156 are secure and behave as documented
- Mitigation: Using pinned versions; OpenZeppelin is the most widely audited Solidity library with extensive formal verification
- Risk: A vulnerability in OpenZeppelin base contracts could affect all inheriting contracts
The protocol owner can:
- Pause/unpause the SyntheticSplitter (blocks minting, not redemption)
- Pause/unpause router contracts
- Propose adapter migrations (7-day timelock)
- Propose treasury/staking address changes (7-day timelock)
- Propose BasketOracle Curve pool changes (7-day timelock)
- Rescue stuck tokens (non-core assets only)
- Transfer ownership (via Ownable2Step two-step pattern)
InvarCoin owner can additionally:
- Propose/finalize sINVAR staking contract (one-time, 7-day timelock)
- Propose/finalize Curve gauge changes (7-day timelock, must be in approved allowlist)
- Manage gauge allowlist (
setGaugeApproval) - Propose/finalize gauge rewards receiver (7-day timelock)
- Protect reward tokens (irreversible) and sweep them to the receiver
- Activate emergency mode, force-remove bricked gauges, emergency-withdraw from Curve, redeploy to Curve
- Stake/unstake LP to gauge, claim gauge rewards
The owner cannot:
- Freeze user funds permanently (burn works even when paused, provided the protocol remains solvent; if insolvent and paused, burns are blocked to prevent a race-to-exit)
- Modify the CAP after deployment
- Change oracle addresses after deployment
- Mint or burn tokens directly
- Change the sINVAR staking contract once set (one-time setter)
- Bypass timelocks on gauge, staking, or rewards receiver changes
- Rescue protected reward tokens via
rescueToken()(must usesweepGaugeRewards) - Un-protect a reward token once marked
Anyone can call:
triggerLiquidation()- settles protocol if oracle price >= CAPharvestYield()- collects and distributes yield from adapterdeployToAdapter()- pushes excess USDC to yield adapter (10% buffer retained)distributeRewards()- converts RewardDistributor's USDC into staker rewards (1-hour cooldown)- InvarCoin
deposit()/withdraw()/lpWithdraw()/lpDeposit()- user vault operations - InvarCoin
harvest()- mints INVAR from Curve fee yield and donates to sINVAR (reverts if no yield) - InvarCoin
deployToCurve()- deploys excess USDC buffer to Curve ($1000 minimum) - InvarCoin
replenishBuffer()- burns Curve LP to restore USDC buffer - InvarCoin
donateUsdc()- accepts USDC donations, mints proportional INVAR to sINVAR
Critical operations require a 7-day timelock:
- Adapter migration (yield strategy change)
- Treasury address change
- Staking address change
- BasketOracle Curve pool change (initial setup is immediate)
- InvarCoin sINVAR staking contract (one-time propose/finalize)
- InvarCoin Curve gauge changes (propose/finalize)
- InvarCoin gauge rewards receiver (propose/finalize)
This provides users time to exit if they disagree with proposed changes.
After unpausing the protocol, a 7-day cooldown period must elapse before governance operations (adapter migration, fee receiver changes) can be finalized. This prevents rapid pause/unpause cycles that could bypass timelock protections.
The cooldown is enforced by _checkLiveness():
- Reverts if protocol is paused
- Reverts if
block.timestamp < lastUnpauseTime + 7 days
Adapter migrations include two safety mechanisms to prevent fund loss:
| Mechanism | Implementation | Purpose |
|---|---|---|
| Atomic swap | yieldAdapter pointer is never set to null |
Prevents view functions from returning incorrect values mid-migration |
| Loss check | assetsAfter >= assetsBefore * 99.999% |
Reverts if total assets decrease by more than 0.1 bps (0.001%) |
The loss check protects against:
- Malicious adapters that steal funds on deposit
- Adapters with excessive entry/exit fees
- Rounding errors that compound during migration
If migration fails the loss check, it reverts with Splitter__MigrationLostFunds. The admin must investigate and propose a different adapter.
- Behavior: OracleLib reverts with
OracleLib__InvalidPriceon zero or negative prices - Impact: All state-changing operations (mint, burn, liquidation) halt when oracle reports invalid data
- Rationale: Operating with broken oracle data could enable arbitrage exploits between oracle price and market price; halting operations is the safer default
- Note: The
getSystemStatus()view function gracefully returns 0 for UI diagnostics without reverting
- Behavior:
previewMint()andpreviewBurn()show expected values at current price - Impact: Actual execution may differ if price changes between preview and execution
- Rationale: This is inherent to any DeFi protocol; users should set appropriate slippage
- Mechanism: BasketOracle compares the theoretical plDXY-BEAR price (derived from Chainlink feeds) against Curve's internal EMA oracle (
price_oracle()) - Threshold: Maximum 2% deviation (configurable via
MAX_DEVIATION_BPSat deployment) - Behavior: If deviation exceeds threshold,
latestRoundData()reverts withBasketOracle__PriceDeviation(theoretical, spot) - Affected Operations: Minting, liquidation trigger, leverage operations, and Morpho liquidations (via MorphoOracle → StakedOracle). Strict price-dependent operations revert while the deviation persists.
- Unaffected Operations:
withdrawandlpWithdrawremain live because_harvestSafe()treats BasketOracle / sequencer failures as best-effort and skips harvest instead of reverting. - Rationale: Detects oracle manipulation (Chainlink compromise) or market manipulation (Curve pool attack). Also catches stale oracle data if Chainlink stops updating but Curve continues trading.
- User Impact: Minting and leverage operations temporarily halt until prices converge. This is a protective circuit breaker.
- Morpho Liquidation Impact (Acknowledged): Because MorphoOracle and StakedOracle depend on BasketOracle's
latestRoundData(), a deviation revert also freezes Morpho liquidations. This is intentional: large divergence between Chainlink and Curve EMA signals either a compromised feed or a manipulated pool, and freezing all price-dependent operations (including liquidations) is safer than acting on potentially bad data. Undercollateralized Morpho positions cannot be liquidated during this window, but the freeze is temporary—prices typically converge within minutes as arbitrageurs trade the discrepancy. - Recovery: Prices typically converge within minutes as arbitrageurs trade the discrepancy. No admin intervention required.
- Note: Uses Curve's
price_oracle()(time-weighted EMA) rather thanget_dy()(instantaneous spot) to resist flash loan manipulation
- Behavior: Users can burn tokens to redeem USDC even after liquidation triggers
- Impact: This is intentional - users should always be able to exit
- Rationale: Only minting is blocked during liquidation to prevent new positions
- Behavior: When price >= CAP, the entire protocol enters SETTLED state
- Impact: All positions are affected equally
- Rationale: The CAP represents the theoretical maximum plDXY value; exceeding it means the inverse token has zero value
| Operation | Minimum | Maximum | Notes |
|---|---|---|---|
| Mint | 1 wei | Unlimited* | *Subject to available USDC liquidity |
| Burn | 1 wei | Token balance | Must burn equal BEAR + BULL |
| Leverage Principal | ~$1 | Unlimited* | *Subject to Morpho liquidity |
| Flash Mint | 1 wei | maxFlashLoan() |
Per ERC-3156 spec |
- Mint: Rounds UP (favors protocol) - users pay slightly more USDC
- Burn: Rounds DOWN (favors protocol) - users receive slightly less USDC
- Rationale: Prevents economic exploits from rounding arbitrage
- Local Buffer: 10% of deposited USDC kept in Splitter for immediate redemptions
- Adapter Deployment: 90% deployed to yield adapter
- Risk: Burns exceeding the local buffer require adapter withdrawal. If Morpho Vault vault liquidity is constrained, burns revert with
Splitter__AdapterWithdrawFailed - Mitigation: 10% buffer absorbs normal withdrawal patterns; Morpho Vault vault liquidity is typically deep
- Note: Buffer ratio is enforced at mint time only; no automatic rebalancing occurs
The protocol has zero fees for user operations. The only fee is a performance fee on yield.
| Operation | Fee | Notes |
|---|---|---|
| Mint | 0% | No fee to mint BEAR+BULL pairs |
| Burn | 0% | No fee to redeem USDC |
| Flash Mint (plDXY-BEAR/BULL) | 0% | ERC-3156 compliant, zero fee |
| Flash Loan (Morpho) | 0% | Morpho Blue provides fee-free flash loans |
| Curve Swaps | ~0.04% | Paid to Curve LPs, not Plether |
When harvestYield() is called, surplus yield is distributed:
| Recipient | Share | Purpose |
|---|---|---|
| Caller | 0.1% | Incentive to call harvest |
| Treasury | 20% | Protocol/developer performance fee |
| Staking | 79.9% | Distributed to stakers |
- Performance fee only applies to yield generated, not principal
- If staking address is not set, treasury receives 100% of non-caller share
- Fee percentages are hardcoded and cannot be changed
Morpho may distribute token rewards (e.g., MORPHO) to vault depositors via their Universal Rewards Distributor (URD). Rewards are claimed at two levels:
| Level | Mechanism | Details |
|---|---|---|
| Morpho Vault → Morpho markets | Vault curator claims | Curator claims on behalf of the vault; rewards accrue to vault share price, increasing VaultAdapter's totalAssets() |
| VaultAdapter → external distributors | claimRewards(target, data) |
Owner calls arbitrary reward distributor contracts (Merkl, URD, etc.) to claim tokens sent directly to the adapter |
claimRewards() security model:
- Owner-only (
onlyOwnermodifier) - Target address is validated: cannot be the underlying asset (USDC), the Morpho Vault, or the adapter itself
- Claimed reward tokens land in the adapter; use
rescueToken()to extract them - Cannot affect deposited USDC or vault shares (forbidden targets)
Reward timing depends on the vault curator's claim schedule and external distributor epochs.
SyntheticToken (plDXY-BEAR and plDXY-BULL) supports ERC-3156 flash mints:
- Fee: Zero (no flash mint fee)
- Max Amount: Unlimited (tokens are minted on demand)
- Use Cases:
- ZapRouter: Flash mints plDXY-BEAR for atomic BULL acquisition
- BullLeverageRouter: Flash mints plDXY-BEAR for closing leveraged positions (single flash loan pattern)
- Risk: Flash-minted tokens could be used in complex attack vectors (e.g., oracle manipulation, governance attacks)
- Mitigation: Tokens must be returned in same transaction; protocol operations validate prices independently
Critical decimal conversions throughout the protocol:
| Asset/Oracle | Decimals | Notes |
|---|---|---|
| USDC | 6 | Collateral token |
| plDXY-BEAR / plDXY-BULL | 18 | Synthetic tokens |
| Chainlink Price Feeds | 8 | EUR/USD, JPY/USD, etc. |
| BasketOracle Output | 8 | Aggregated plDXY price |
| Morpho Oracle | 24 | Morpho scale: 36 + loanDec(6) - colDec(18) |
| StakedToken Offset | 3 | 1000x inflation attack protection |
Conversion formula in Splitter:
USDC_MULTIPLIER = 10^(18 - 6 + 8) = 10^20usdcNeeded = (tokenAmount * CAP) / USDC_MULTIPLIER
The RewardDistributor allocates yield based on price discrepancy between Chainlink (theoretical) and Curve EMA (spot). Potential manipulation vectors have been analyzed:
Attack concept: Manipulate Curve EMA to diverge from Chainlink, causing rewards to favor one side.
Why it's not economically viable:
| Factor | Constraint |
|---|---|
| BasketOracle deviation check | Reverts if Chainlink/Curve diverge >2%, limiting manipulation range |
| Curve EMA resistance | Moving EMA requires sustained trading against arbitrageurs |
| Attack cost | Moving price 2% on a deep pool costs $50k-200k+ in fees/slippage |
| Max profit | Extra allocation × attacker's stake share × reward pool size |
| Cooldown | 1-hour minimum between distributions limits frequency |
Example: To gain an extra $5k (from 50/50 → 100/0 on a $100k reward pool with 10% stake), attacker spends $50k+ manipulating the pool. Net loss.
Attack concept: During rapid price moves, Chainlink updates faster than Curve EMA, creating temporary discrepancy.
Why it's not exploitable: The same 2% BasketOracle deviation check that prevents manipulation also prevents stale-EMA exploitation. Large moves (>2% divergence) cause the oracle to revert entirely, blocking distribution until prices converge.
The system assumes that if the gap is >2%, the market is "disordered." By reverting, we protect the protocol from distributing based on noise. The 10% liquid buffer in the Splitter ensures that even if rewards are paused, users can still redeem their principal without friction.
StakedToken (splDXY-BEAR, splDXY-BULL) is an ERC-4626 vault used as Morpho collateral:
- Inflation Attack Protection: Uses
_decimalsOffset() = 3(1000x multiplier) - Streaming Rewards:
donateYield()streams rewards linearly over 1 hour instead of instant distribution - Griefing Protection: Proportional stream extension prevents zero-amount timer resets
Streaming prevents reward sniping attacks:
| Attack Vector | Mitigation |
|---|---|
| Deposit → claim → withdraw | Rewards stream over 1 hour; early exit captures only pro-rata portion |
Front-run donateYield() |
Must hold for full stream duration to capture full rewards |
How it works:
donateYield(amount)starts a 1-hour linear stream viarewardRateandstreamEndTimetotalAssets()excludes unvested rewards:balance - _unvestedRewards()- Share price increases gradually as rewards vest (not instantly)
Precision: Streaming math uses 1e18 scaling. Truncation dust (~1000 wei per 100 ETH donation) vests immediately but is negligible and favors existing stakers.
donateYield() is permissionless but resistant to timer-reset griefing. The stream duration extends proportionally to the donation size relative to the unvested total:
newDuration = remainingTime + (STREAM_DURATION × amount) / (remaining + amount)
| Scenario | Effect |
|---|---|
donateYield(0) |
No-op: duration unchanged, rewards keep vesting |
donateYield(1 wei) against large unvested balance |
Negligible extension (~0 seconds) |
| Fresh donation (no active stream) | Full STREAM_DURATION (1 hour) |
Duration capped at STREAM_DURATION |
Cannot extend beyond 1 hour |
This makes griefing economically useless: an attacker calling donateYield(0) has zero effect on the vesting schedule.
InvarCoin is a vault token backed by Curve USDC/plDXY-BEAR LP tokens. Users deposit USDC, which is single-sided deployed to Curve. Fee yield (virtual price growth) is harvested and donated to sINVAR stakers.
InvarCoin uses two oracle modes for different contexts:
| Mode | Function | Behavior on Stale Oracle | Used By |
|---|---|---|---|
| Permissive | totalAssets() |
Returns best-effort value (no revert) | getBufferMetrics(), view functions |
| Strict | totalAssetsValidated() |
Reverts with OracleLib__StalePrice |
N/A (external callers only) |
| Validated | _validatedOraclePrice() |
Reverts | deposit, lpDeposit, harvest, deployToCurve, replenishBuffer, donateUsdc |
| Safe | _harvestSafe() |
Silently skips harvest | withdraw, lpWithdraw |
Staleness timeout is 24 hours (ORACLE_TIMEOUT). L2 sequencer uptime is checked with a 1-hour grace period.
LP tokens are valued using both Curve's lp_price() (EMA) and an oracle-derived price (2 * vp * sqrt(bearPrice)):
| Context | Pricing | Rationale |
|---|---|---|
totalAssets(), harvest |
Pessimistic (min of EMA, oracle) | Conservative NAV prevents overpaying yield |
deposit(), lpDeposit() NAV |
Optimistic (max of EMA, oracle) | Prevents new depositors from diluting existing holders |
lpDeposit() LP valuation |
Pessimistic | Prevents depositors extracting value from stale-high EMA |
withdraw(), lpWithdraw() |
Pro-rata (no pricing needed) | Fair share distribution regardless of price |
| Parameter | Value | Purpose |
|---|---|---|
BUFFER_TARGET_BPS |
200 (2%) | Target USDC held locally for gas-efficient withdrawals |
DEPLOY_THRESHOLD |
$1,000 | Minimum excess before deployToCurve activates |
MAX_SPOT_DEVIATION_BPS |
50 (0.5%) | Circuit breaker for pool manipulation |
The spot deviation guard compares spot execution against EMA-derived fair value. It blocks deployToCurve, replenishBuffer, lpDeposit, and redeployToCurve when the pool is being manipulated in either direction beyond the configured tolerance.
For permissionless maintenance actions (deployToCurve and replenishBuffer), Curve min-out parameters are derived from the EMA fair-value bound rather than the current spot quote. This prevents callers from using a manipulated spot quote as the vault's slippage floor.
LP tokens are auto-staked to the Curve gauge on deployment for CRV reward accrual. Key design choices:
| Design Choice | Rationale |
|---|---|
Internal gaugeStakedLp tracking |
Prevents bricked gauge from DOSing _lpBalance() → totalAssets() |
Gauge allowlist (approvedGauges) |
Prevents setting arbitrary contracts as gauges |
_validateGaugeAddress checks code + lp_token() |
Validates gauge is a real Curve gauge for the correct LP |
No try/catch on gauge.deposit() |
Bricked gauge reverts entire deploy — prevents LP going untracked |
Per-operation approve (not type(uint256).max) |
Limits gauge's spending authority to the exact deposit amount |
Gauge reward tokens (CRV, etc.) use a separate flow from rescueToken():
- Owner calls
protectRewardToken(token)— irreversible, blocksrescueTokenfor that token. Core vault assets (USDC,BEAR, Curve LP) cannot be protected - Owner proposes gauge rewards receiver (7-day timelock)
- After timelock,
sweepGaugeRewards(token)sends balance to the receiver
This prevents the owner from instantly draining reward tokens via rescueToken() while still allowing legitimate reward collection. sweepGaugeRewards() cannot be used as an alternate path to transfer core vault assets.
Limitation: protectRewardToken is irreversible. A mistakenly protected token can only be recovered via sweepGaugeRewards (requires receiver to be set). Unprotected reward tokens remain rescuable via rescueToken().
| Donation Type | Effect | Safety |
|---|---|---|
| USDC sent directly | Inflates totalAssets(), benefits existing holders |
Harmless — new depositors get fewer shares (optimistic pricing) |
| BEAR sent directly | Inflates totalAssets(), benefits existing holders |
Harmless — same as USDC |
| LP tokens sent directly | Inflates _lpBalance() but NOT trackedLpBalance |
Safe — harvest only measures VP growth on tracked LP |
| First-depositor inflation | Virtual shares (1e18 INVAR / 1e6 USDC offset) | 1e12 virtual ratio makes attack economically infeasible |
withdraw()ignores loose BEAR: Single-sided withdrawal only returns USDC (buffer + Curve LP burn). If the contract holds material BEAR (e.g., after emergency recovery), users should uselpWithdraw()or setminUsdcOutto enforce fair value.lpWithdraw()during emergency with bricked Curve: If both the gauge and Curve pool are bricked,lpWithdrawreverts onremove_liquidity. Users must wait forforceRemoveGauge()to write off gauge LP, thenlpWithdrawsucceeds with remaining local USDC + BEAR only.- No keeper incentive:
harvest(),deployToCurve(), andreplenishBuffer()have no on-chain caller reward. Keepers must be incentivized externally (Gelato, Keep3r, etc.). This is intentional — paying rewards from principal in fungible-share vaults enables small-loop MEV extraction. - Harvest frequency: If
harvest()is not called regularly,curveLpCostVpbecomes stale and the next harvest mints a larger-than-normal INVAR batch. This is not exploitable — the yield is real VP growth — but creates lumpy reward distribution to sINVAR stakers.
Router contracts assume specific Curve pool structure:
| Index | Asset | Constant |
|---|---|---|
| 0 | USDC | USDC_INDEX |
| 1 | plDXY-BEAR | PLDXY_BEAR_INDEX |
- Risk: If Curve pool is deployed with different indices, all router swaps fail
- Mitigation: Indices are verified during deployment; pool address is immutable
- Deviation Check: BasketOracle validates Chainlink price against Curve
price_oracle()(max 2% deviation) - Pool Updates: BasketOracle Curve pool can be updated via 7-day timelock (
proposeCurvePool→finalizeCurvePool). Initial setup viasetCurvePoolis immediate.
LeverageRouter and BullLeverageRouter share a common base contract (LeverageRouterBase) for consistent behavior:
- Shared Validation: Authorization checks, deadline validation, slippage limits (max 1%)
- Custom Errors: All routers use custom errors for gas-efficient reverts and clear error identification
- Flash Loan Pattern: Both routers use single-level flash loans (Morpho for USDC, ERC-3156 for tokens)
- Callback Security: Flash loan callbacks validate
msg.sender(lender) andinitiator(self)
| Router | Flash Loan Source | Collateral Token |
|---|---|---|
| LeverageRouter | Morpho (USDC) | splDXY-BEAR |
| BullLeverageRouter | Morpho (USDC) for open, ERC-3156 (plDXY-BEAR) for close | splDXY-BULL |
| ZapRouter | ERC-3156 (plDXY-BEAR) | N/A |
All routers implement multiple layers of MEV protection:
| Protection | Implementation | Purpose |
|---|---|---|
| Max Slippage Cap | MAX_SLIPPAGE_BPS = 100 (1%) |
Prevents users from setting dangerously high slippage |
| Deadline | if (block.timestamp > deadline) revert |
Prevents stale transactions from executing |
| Min Output | minAmountOut / minUsdcOut parameters |
User-specified minimum acceptable output |
| Curve Enforcement | min_dy passed to exchange() |
On-chain slippage check in Curve swap |
| Safety Buffer | SAFETY_BUFFER_BPS = 50 (0.5%) in ZapRouter |
Accounts for rounding in flash calculations |
| Real-time Pricing | get_dy() before swaps |
Uses current pool state, not stale oracle prices |
Limitations:
- Transactions are visible in the public mempool before inclusion; users can mitigate this by using private mempools (e.g., Flashbots Protect)
- The 1% cap is a maximum; users can specify lower values for tighter protection, but large positions may still experience significant slippage in dollar terms
Router contracts use type(uint256).max approvals in their constructors for all protocol-to-protocol interactions (Splitter, Curve, Morpho, StakedTokens). This is safe because routers are stateless: they never hold user funds between transactions. All tokens flow in and out within a single atomic call, so an infinite approval from an empty contract to a known immutable address has no exploitable surface.
| Approval Type | Pattern | Example |
|---|---|---|
| Router → Protocol (constructor) | type(uint256).max to immutable addresses |
ZapRouter approves Splitter to spend USDC |
| Router → Flash Lender (runtime) | Exact repayment amount | ZapRouter approves repayAmount for flash mint callback |
Users never grant token approvals to the routers. Instead:
- LeverageRouter / BullLeverageRouter: Users authorize the router in Morpho via
morpho.setAuthorization(router, true). This grants position management rights, not token spending. - ZapRouter: Users call the router directly with USDC. No prior approval needed — USDC is transferred via
transferFromwith the exact mint amount.
All USDC entry points offer *WithPermit() variants (mintWithPermit, zapMintWithPermit, openLeverageWithPermit, addCollateralWithPermit) that accept EIP-2612 signatures for gasless approvals. Security properties:
- Signatures are single-use (nonce-based replay protection via USDC's permit implementation)
- Deadline parameter prevents stale signatures from being submitted
- Permits are executed atomically with the operation — no window for front-running between approval and spend
When to pause:
- Suspected oracle manipulation
- External protocol exploit affecting Plether
- Critical bug discovered
Effect of pause:
- Minting blocked
- Burning still allowed (users can always exit)
- Leverage operations blocked
Unpause procedure:
- Identify and fix root cause
- Verify oracle feeds are healthy
- Call
unpause()on affected contracts
Trigger condition:
- Oracle price >= CAP ($2.00 for plDXY)
How to trigger:
- Anyone can call
triggerLiquidation()when oracle reports price >= CAP - The function reverts if price is below CAP
Post-liquidation:
- Protocol enters SETTLED state
- Minting permanently disabled
- Users redeem at fixed CAP rate
- Any remaining USDC distributed pro-rata
If tokens are accidentally sent to contracts:
- Identify the stuck token
- Call
rescueToken(token, recipient)on the relevant contract - Note: Cannot rescue core assets (USDC, plDXY-BEAR, plDXY-BULL, yieldAdapter shares)
Contracts supporting rescueToken:
- SyntheticSplitter: Rescues any token except USDC, plDXY-BEAR, plDXY-BULL, and yieldAdapter shares
- VaultAdapter: Rescues any token except USDC (the underlying asset) and vault shares
If yield adapter is compromised:
- Pause the Splitter immediately
- Propose new adapter via
proposeAdapter(newAdapter) - Wait 7-day timelock (cannot be bypassed)
- Execute migration via
finalizeAdapter() - Unpause Splitter
Note: During the 7-day period, users can continue to redeem existing tokens.
- Call
emergencyWithdrawFromCurve()— setsemergencyActive, pauses, unstakes from gauge, callsremove_liquidityto recover USDC + BEAR, zeros LP accounting - Users exit via
lpWithdraw()(works when paused) — receives pro-rata USDC + BEAR - Once resolved, owner calls
redeployToCurve(minLpOut)— re-deploys BEAR + excess USDC to Curve, clearsemergencyActive - Call
unpause()
- Call
setEmergencyMode()— setsemergencyActive, pauses. LP accounting is preserved (not zeroed). - If gauge is also bricked, call
forceRemoveGauge()— writes off stuck gauge LP proportionally fromtrackedLpBalanceandcurveLpCostVp, zerosgaugeStakedLp - Users exit via
lpWithdraw()— receives pro-rata share of local USDC + BEAR only (no Curve LP burn since_lpBalance()is now only local LP) - If Curve recovers later, call
emergencyWithdrawFromCurve()to recover remaining LP
- Call
setEmergencyMode()— pauses, setsemergencyActive - Call
forceRemoveGauge()— writes off gauge-staked LP - Call
emergencyWithdrawFromCurve()— recovers remaining local LP from Curve - Users exit via
lpWithdraw(), or owner callsredeployToCurve()+unpause()to resume normal operation
Key property: lpWithdraw() intentionally lacks whenNotPaused, ensuring users can always exit even during emergencies. withdraw() (single-sided) and deposit() / lpDeposit() are blocked during emergency mode.
If adapter withdrawal fails due to constrained Morpho Vault vault liquidity:
- Pause the Splitter via
pause() - Call
withdrawFromAdapter(amount)to extract available liquidity - Repeat step 2 as vault liquidity frees up (borrowers repay or get liquidated)
- Once adapter is fully drained, propose new adapter via
proposeAdapter(newAdapter) - Wait 7-day timelock
- Execute migration via
finalizeAdapter()(skips redemption if adapter has 0 shares) - Unpause Splitter
Note: withdrawFromAdapter() requires the protocol to be paused and caps withdrawal to maxWithdraw() from the adapter.
For responsible disclosure of security vulnerabilities, please contact: contact@plether.com
Auditor: Cantina (Red-Swan, Security Researcher)
Review period: January 25–28, 2026
Report date: February 13, 2026
Commit: 596b0179
Report: audits/report-cli-cantina-plether-0126.pdf
Scope:
src/SyntheticSplitter.solsrc/SyntheticToken.solsrc/MorphoAdapter.sol(since replaced by VaultAdapter)src/oracles/BasketOracle.sol
Findings:
| Severity | Count | Fixed | Acknowledged |
|---|---|---|---|
| Critical | 0 | — | — |
| High | 0 | — | — |
| Medium | 2 | 2 | 0 |
| Low | 5 | 4 | 1 |
| Informational | 1 | 1 | 0 |
| Total | 8 | 7 | 1 |
Medium findings (all fixed):
- Basket weights not constrained to unit value — BasketOracle did not enforce weights summing to 1, which could cause deviation checks to fail or return zero prices. Fixed: added
requireenforcing unit sum. - Adapter asset doesn't account for accrued interest —
MorphoAdapter.totalAssetsdid not trigger interest accrual, causing stale values inharvestYieldandpreviewBurn. Mitigated: the original MorphoAdapter was replaced by VaultAdapter (Morpho Vault), whereaccrueInterest()is a no-op because Morpho Vault handles accrual internally. Any lag is negligible (a few blocks).
Acknowledged finding:
- Duplicate event emissions (Low) —
proposeCurvePool,setUrd, andproposeAdapteremit events even when the new value equals the old. Acknowledged as harmless.
Not in scope: Routers (ZapRouter, LeverageRouter, BullLeverageRouter), StakedToken, StakedOracle, MorphoOracle, RewardDistributor, VaultAdapter, PythAdapter.
| Component | Audit Status |
|---|---|
| SyntheticSplitter | Audited (Cantina, Jan 2026) |
| SyntheticToken | Audited (Cantina, Jan 2026) |
| BasketOracle | Audited (Cantina, Jan 2026) |
| InvarCoin | Not yet audited |
| ZapRouter | Not yet audited |
| LeverageRouter | Not yet audited |
| BullLeverageRouter | Not yet audited |
| StakedToken | Not yet audited |
| RewardDistributor | Not yet audited |
| VaultAdapter | Not yet audited |
| MorphoOracle / StakedOracle | Not yet audited |
| PythAdapter | Not yet audited |
| Date | Change |
|---|---|
| 2026-03-17 | Added InvarCoin security documentation: trust assumptions (Curve twocrypto, gauge, CRV minter, L2 sequencer), invariants, dual pricing model, buffer management, gauge integration, protected reward tokens, donation resistance, emergency procedures; updated owner capabilities, permissionless operations, and timelock list; added to coverage gaps |
| 2026-02-24 | Acknowledged BasketOracle deviation check freezing Morpho liquidations as intentional circuit breaker |
| 2026-02-13 | Added Cantina audit results (8 findings: 0 critical, 0 high, 2 medium, 5 low, 1 informational; 7 fixed, 1 acknowledged); added coverage gaps table |
| 2026-02-11 | Added VaultAdapter.claimRewards() security model, EIP-2612 permit support documentation, deployToAdapter()/distributeRewards() as permissionless operations; clarified Chainlink+Pyth dependency |
| 2026-02-11 | Replaced MorphoAdapter with VaultAdapter (Morpho Vault) for yield; updated trust assumptions, rescue docs, and liquidity risk sections |
| 2026-02-08 | PythAdapter: Updated staleness from 24h to 72h for weekend forex closures; documented self-attestation model and staleness tradeoff |
| 2026-02-07 | Added Token Approval Model section under Router Architecture: documents stateless router approval pattern and user authorization model |
| 2026-02-06 | StakedToken: Replaced permissionless donateYield acknowledgement with proportional stream extension fix; removed stale withdrawal delay references |
| 2026-02-02 | Added Pyth Network dependency for SEK/USD price feed (PythAdapter); documented pull-based oracle risks |
| 2026-01-30 | StakedToken: Added streaming rewards (1h linear vesting) and withdrawal delay (1h minimum) to prevent reward sniping |
| 2026-01-29 | Added RewardDistributor Security section: economic analysis of price manipulation and stale EMA attacks |
| 2026-01-21 | Added USDC (Circle) risks: depeg, blacklisting, upgradeability, and regulatory risks |
| 2026-01-15 | Documented acknowledged risk: permissionless donateYield() griefing vector |
| 2026-01-15 | Updated ownership model to reflect Ownable2Step pattern |
| 2026-01-15 | Added Governance Cooldown and Adapter Migration Safety sections under Trust Assumptions |
| 2026-01-14 | Added rescueToken() to SyntheticSplitter for recovering accidentally sent tokens (excludes USDC, plDXY-BEAR, plDXY-BULL) |
| 2026-01-14 | Added Upgradeability section (non-upgradeable contracts) and Protocol Invariants section (solvency, token, state invariants) |
| 2026-01-14 | Added Oracle/Market Price Deviation section documenting the 2% deviation check between Chainlink and Curve prices |
| 2026-01-14 | Added withdrawFromAdapter() for gradual liquidity extraction under tight Morpho utilization; documented new emergency procedure |
| 2026-01-14 | Added MEV Protection section under Router Architecture |
| 2026-01-13 | BasketOracle: Added 7-day timelock for Curve pool updates; refactored to use OpenZeppelin Ownable |
| 2026-01-13 | Documented Morpho liquidity risk: burns revert if adapter withdrawal fails due to high market utilization |
| 2026-01-11 | Reduced harvest caller reward from 1% to 0.1%; added Morpho token rewards documentation |
| 2026-01-09 | Added External Library Dependencies section with OpenZeppelin trust assumptions |
| 2026-01-04 | Added Router Architecture section; documented LeverageRouterBase, custom errors, and single flash loan pattern for BullLeverageRouter close |
| 2026-01-03 | Added protocol fees, flash mint, decimal handling, StakedToken, and Curve pool documentation |
| 2026-01-03 | Migrated to Morpho Blue as sole flash loan provider (removed Aave/Balancer dependencies) |
| 2025-01-03 | Initial security documentation |