Liquidity that stacks.
Every instant-redemption design in production today makes the same trade: to promise a holder they can exit now, you have to park idle stables against their asset, and that pot backs exactly one asset. Want to underwrite a second? Raise a second pot. A third? A third. Liquidity fragments, every new integration starts from zero, and most of that capital sits still most of the time — because redemptions are bursty, and you're sizing each pot for a spike that rarely comes.
Plexus breaks that one-to-one. Every vault is itself an ERC4626 over the same base asset, so vaults allocate into each other. One pool of stables can sit behind a dozen redemption desks at once, backstopping each of them, and earn yield at the leaves while it waits. The spike in one vault is absorbed by capital that's idle in another — which is exactly the capital you'd otherwise be paying to keep still.
The design is inspired by Morpho's vaults, which allocate into isolated markets and into other vaults so a curator can shape precisely the risk and yield a vault takes on. Plexus carries that idea into instant redemption: each vault allocates into other Plexus vaults, or into strategy vaults like the Vaultified Aave adapter, to curate both the risk it underwrites and the yield it earns on idle liquidity. Anything that can be wrapped as an ERC4626 — Morpho, Aave, a T-bill vault, another Plexus desk — becomes a routable venue, so liquidity is never stranded against a single asset.
The potential is a liquidity network for real-world assets. Any issuer can bring an asset and stand up an instant-redemption desk in one deployment, drawing on liquidity that already exists instead of raising its own. Curators compose that liquidity into layered graphs — a deep, conservative base vault wholesaling to specialist desks, each keeping its own risk and redemption logic and route the idle float through the best available yield venues, rebalancing as rates move. Depositors pick their altitude in the stack: diversified fee flow at the root, concentrated exposure at a leaf. The result is an open, composable market for immediacy, where the same dollar can underwrite many redemptions and earn yield the entire time it waits to be needed.
flowchart LR
LP(["LP"]) -->|deposits stables| A
A["Vault A<br/>base + RWA-A"]
B["Vault B<br/>base + RWA-B"]
C["Vault C<br/>base + RWA-C"]
D["Vault D<br/>base + RWA-D"]
M1[("Morpho<br/>vault")]
M2[("Morpho<br/>vault")]
A -->|50%| B
A -->|30%| C
A -->|20%| M1
B -->|40%| D
C -->|30%| M2
H(["RWA-B holder"]) -.->|"1 · redeemRwa"| B
B -.->|"2 · stables out, now"| H
classDef vault fill:#2563eb,stroke:#1e40af,color:#fff
classDef venue fill:#0f766e,stroke:#115e59,color:#fff
classDef actor fill:#e2e8f0,stroke:#64748b,color:#0f172a
class A,B,C,D vault
class M1,M2 venue
class LP,H actor
A holder redeems against vault B. B has no idle stables of its own — it pulls from Morpho, holding liquidity that came in as an LP deposit into A. B's fee accrues to B's shares, which A owns, so the yield flows back up to A's LPs. The same dollar underwrites redemptions at every level of that graph, and earns yield at the bottom of it.
- Onboard an asset without raising a pool. A curator who wants to underwrite instant redemption for a new tokenized treasury deploys a vault, points it at existing liquidity, and is live. No bootstrap, no fragmentation, no waiting to hit critical mass. The marginal cost of the next asset collapses.
- Redemption logic stays yours. Each vault names its own redeemer at onboarding — the contract, the calldata, the settlement flow. A T-bill fund with a 2-day window and a private credit deal with a 30-day one live side by side on shared liquidity and never touch each other's vault.
- Underwrite and farm with the same capital. Idle liquidity isn't dead weight parked against a spike. It's allocated into Morpho or any ERC4626 and pulled back the moment a redemption lands. Curators get a yield venue and a redemption backstop out of one balance sheet.
- Curators compose, not compete. A deep, conservative base vault can wholesale liquidity to specialist vaults downstream and earn their fees, while each specialist keeps its own risk. Liquidity providers pick their depth in the stack: root for diversified fee flow, leaf for concentrated exposure.
- Risk stays where it's underwritten. A vault holds shares of its downstream vaults, never their RWA. B taking on a distressed asset never puts that asset on A's books — A's exposure is bounded by its cap on B, and by B's own
rwaCap. Contagion is priced and capped per edge, not implicit. - Price the spread, not the wait. The fee an RWA holder pays for immediacy is the LP's yield. Curators tune
redemptionFeeandrwaCapper asset and let the market decide what jumping the settlement queue is worth.
The result is an open market for immediacy: anyone can bring an asset, anyone can bring liquidity, and the liquidity doesn't have to pick just one asset to believe in.
- Plexus Protocol
- What this unlocks
- Table of Contents
- Protocol Overview
- Liquidity Stacking
- Oracle Architecture
- Manager Roles
- User Guide
- Repository Structure
- Dependencies
- Development
- Security
- License
The core component is the Vault, which pairs one base asset with exactly one RWA and manages LP positions, allocation, and redemption.
flowchart TB
subgraph V ["Vault (one per RWA)"]
direction LR
E["ERC4626<br/>LP shares over the base asset"]
AL["Allocator (inherited)<br/>targets · absoluteCap · relativeCap"]
end
O["Oracle<br/>price() → RWA in base, 1e18"]
PF[["Chainlink-style<br/>price feed"]]
R{{"Redeemer<br/>issuer-specific, set at onboarding"}}
T1["Vault B<br/>another Plexus vault"]
T2[("Morpho vault<br/>or any ERC4626")]
V -->|"marks RWA"| O
O --> PF
V -->|"externalRedeem · finalizeExternalRedeem"| R
AL -->|allocate / deallocate| T1
AL -->|allocate / deallocate| T2
classDef core fill:#2563eb,stroke:#1e40af,color:#fff
classDef ext fill:#0f766e,stroke:#115e59,color:#fff
classDef out fill:#b45309,stroke:#92400e,color:#fff
class E,AL core
class T1,T2 ext
class O,PF,R out
Everything outside the vault is swappable per asset: the oracle, the redeemer, and the set of allocation targets. That is what lets a curator onboard a new asset without touching another vault's code.
- Vault: An ERC4626 over a base asset (a stable), paired with exactly one RWA. LPs deposit the base asset and receive shares; RWA holders swap into the base asset instantly. Inherits
Allocator. - Allocator: The allocation layer every vault inherits. Pushes idle base asset into external ERC4626 targets under per-target caps. Because targets are already ERC4626, the core needs no adapter layer.
- Strategy adapters: A venue that is not natively ERC4626 (Aave, for one) is wrapped as a standalone ERC4626 vault so it can be a target like any other.
VaultifiedAaveis the reference wrapper: it supplies the base asset to an Aave v3 market and reports the rebasing aToken balance as itstotalAssets. The core never learns about Aave — it only ever talks to an ERC4626. - Oracle: Wraps one Chainlink-style feed. Deployed per vault, and reports the price of one whole RWA in the base asset, scaled to 1e18.
- Redeemer: Not a Plexus contract. An issuer-specific address, set per vault at onboarding, that converts held RWA back into the base asset out-of-band. This is what keeps a new asset's redemption logic from touching any other vault.
- Targets:
setCapsregisters an ERC4626 target and bounds it with anabsoluteCap(max base asset in that target) and arelativeCap(max share oftotalAssets, in WAD). A target with an outstanding balance cannot be removed. - Liquidity target: One target designated as the default route. Deposits flow into it immediately, and withdrawals and redemptions pull back from it when local idle is short.
- Accounting:
totalAssets()returns a stored_totalAssets, refreshed by_accrue()at the start of every interaction.realAssets()is the live figure — idle base asset, plus base asset held across every target, plus RWA marked at the oracle price. Every state-changing entry point accrues first, so callers transact at a fresh price;accrue()is also permissionless, so anyone can fold in target yield at any time.
Because Vault is an ERC4626 over its base asset, a vault is a valid allocation target for another vault, and allocation composes to arbitrary depth.
Each hop stays independently risk-managed. absoluteCap and relativeCap bound what any one vault routes into any one target, and each vault's RWA exposure is bounded by its own rwaCap. A vault holds shares of its downstream vaults, never their RWA.
Depth has two costs. Yield is not instant: a leaf's gains reach the root one accrual per hop — each vault folds a target's current value into its own stored total when it accrues, so a k-deep stack needs k accruals (leaf to root) for the root's share price to catch up. Anyone can call accrue(), so a keeper or the next interaction closes the gap. And leaf liquidity is only reachable from the root as far as each intermediate hop's maxWithdraw allows.
Cycles do not brick anything, by construction. totalAssets() is a stored read, so when a parent values a target it calls the target's totalAssets() — a storage slot — and stops. It never descends into the target's own graph. A loop like A -> B -> A bottoms out at a slot on every hop instead of recursing:
A.realAssets() → B.totalAssets() // = B's stored slot, done — never reads back into A
flowchart LR
subgraph ok ["a diamond — always fine"]
direction LR
A1["Vault A"] --> B1["Vault B"]
A1 --> C1["Vault C"]
B1 --> M1[("Morpho")]
C1 --> M1
end
subgraph loop ["a cycle — configurable, harmless"]
direction LR
A2["Vault A"] --> B2["Vault B"]
B2 -.->|"reads A's stored total, stops"| A2
end
This is why the accounting is stored rather than a live recursive read: a live totalAssets() that walked A → B → A → … would recurse until it ran out of gas and brick every vault on the loop. Storing the total removes that failure mode entirely — no cycle check is needed, and none exists.
What a cycle can still do is double-count value if a curator deliberately routes the same capital in a loop (A funds B, B funds A). That is a solvency/curation concern, not a liveness one: it requires self-dealing across the loop and is bounded by each edge's caps. Targets are owner-curated, so this is the owner's responsibility — the protocol simply never crashes over it.
The vault also carries one virtual share and one virtual asset for first-depositor inflation resistance; the cost is that conversions round down by up to one wei per hop, in the vault's favour. setCaps rejects a direct self-target, and removeTarget checks the target's raw share balance rather than previewing through it, so it stays callable even on a target whose accounting is unreadable.
Oracleis deployed per vault and is the vault's only price entry point.price()takes no arguments and returns the price of one whole RWA denominated in the vault's base asset, scaled to 1e18.- It normalizes the feed's own decimals to 1e18 and nothing else. Token decimals are handled by the vault in
rwaValue, which scales by the base and RWA decimals — normalizing in both places would double-count. price()reverts on a non-positive answer, an unset round, and on any answer older thanmaxAge. There is no fallback: a stale feed halts redemption and share pricing rather than quoting a stale mark.
- Owner: Sets the oracle, the redeemer,
redemptionFeeandrwaCap, registers targets and their caps, designates the liquidity target, grants the allocator role, and drives external settlement. Fully trusted. - Allocator: Can
allocateanddeallocatebetween registered targets, within caps. Cannot move funds out of the vault. The owner holds this role implicitly.
LPs deposit the base asset and receive vault shares, earning the redemption fee and any yield from allocation targets.
- Call deposit: The user calls
deposit(assets, receiver)ormint(shares, receiver)on theVault. - Share calculation: The vault accrues first, then converts assets to shares against the freshly stored
totalAssets. - Allocation:
afterDepositroutes the deposit into the vault'sliquidityTargetimmediately, subject to that target's caps.
Withdrawals are single-step. The user calls withdraw(assets, receiver, owner) or redeem(shares, receiver, owner). beforeWithdraw pulls back from the liquidityTarget to cover the payout if local idle is short, bounded by that target's maxWithdraw. A withdrawal larger than what the graph can free reverts.
The protocol's purpose: an RWA holder exits to the base asset immediately rather than waiting on the issuer.
sequenceDiagram
autonumber
actor H as RWA holder
participant V as Vault
participant O as Oracle
participant T as Liquidity target
H->>V: redeemRwa(rwaAmount, minBaseOut)
V->>O: price()
O-->>V: RWA price, 1e18
Note over V: value = rwaValue(rwaAmount)<br/>baseOut = value − redemptionFee<br/>check rwaCap, check minBaseOut
H->>V: RWA in
opt local idle is short
V->>T: withdraw the shortfall
T-->>V: base asset
end
V->>H: base asset out
Note over V: vault now holds RWA worth<br/>more than the base it paid
- The holder calls
redeemRwa(rwaAmount, minBaseOut). - The vault values the RWA at the oracle price and applies
redemptionFee.previewRedeemRwaquotes this off-chain. - The total RWA exposure is checked against
rwaCap, and the payout againstminBaseOut. - The RWA transfers in, the vault sources base asset from its liquidity target if needed, and the base asset transfers out.
The fee is what LPs earn: the vault takes on RWA worth more than the base it pays out.
The RWA accumulates on the vault's books until the owner settles it back to the base asset through the vault's redeemer. Settlement is issuer-specific and not atomic, so the owner supplies the calldata and the call target is pinned to redeemer.
sequenceDiagram
autonumber
actor Own as Owner
participant V as Vault
participant R as Redeemer / issuer
participant T as Liquidity target
Own->>V: externalRedeem(data, value)
V->>R: approve RWA, then call with owner calldata
R-->>V: pulls the RWA
Note over V: rwaInRedemption += rwaIn<br/>still marked in totalAssets,<br/>so the share price does not move
rect rgb(245, 240, 225)
Note over R: off-chain settlement — days, not blocks
end
Own->>V: finalizeExternalRedeem(rwaAmount, data, value)
V->>R: call with owner calldata
R-->>V: base asset in
V->>V: rwaInRedemption −= rwaAmount
V->>T: allocate the proceeds
externalRedeem(data, value): Approves the vault's RWA balance to the redeemer and calls it. The RWA that leaves is booked asrwaInRedemptionand still counts towardtotalAssetsandrwaExposure, so the share price does not move.finalizeExternalRedeem(rwaAmount, data, value): Calls the redeemer to collect the base asset, clearsrwaAmountfromrwaInRedemption, and allocates the proceeds to the liquidity target.
plexus-protocol/
├── src/ # Main source code
│ ├── Vault.sol # ERC4626 base asset + one RWA; instant redemption, settlement
│ ├── Allocator.sol # Allocation layer inherited by every vault; targets and caps
│ ├── Oracle.sol # Per-vault Chainlink-style price feed wrapper
│ ├── adapters/ # ERC4626 wrappers for non-ERC4626 venues
│ │ └── VaultifiedAave.sol # Wraps an Aave v3 market as an allocation target
│ └── interfaces/ # Protocol interfaces
│ ├── IOracle.sol # Price entry point consumed by the vault
│ ├── IPriceFeed.sol # Minimal Chainlink AggregatorV3Interface
│ ├── IAavePool.sol # Minimal Aave v3 Pool surface (supply / withdraw)
│ └── IAToken.sol # Minimal aToken surface (balanceOf, underlying)
├── test/ # Test suite
│ ├── Vault.t.sol # Accounting, allocation, redemption, settlement, fuzz
│ ├── Stacking.t.sol # Vault-to-vault composition and cycle safety
│ ├── Oracle.t.sol # Feed normalization and staleness
│ ├── VaultifiedAave.t.sol # Aave wrapper, standalone and as a Plexus target
│ └── mocks/ # Test doubles (ERC20, yield vault, feed, redeemer, Aave)
├── sdk/ # Allocation-graph visualizer (Node CLI + library)
├── script/ # Deployment and interaction scripts
├── foundry.toml # Foundry configuration
└── lib/ # Foundry dependencies
Curators can render any vault's live allocation graph — which peers and strategy venues it feeds, and at what weight — with the sdk/ tool:
cd sdk && npm install
node plexus-graph.mjs <vaultAddress> --rpc <rpc-url> --out graph.svgIt walks the graph on-chain (guarding against cycles) and renders an image in the style of the diagram at the top of this README. See sdk/README.md.
Foundry - Development framework
curl -L https://foundry.paradigm.xyz | bash
foundryup # Update to latest versionDependencies are managed via git submodules in the lib directory. The protocol depends on solmate for ERC20, ERC4626, Owned, SafeTransferLib and FixedPointMathLib, and on forge-std for tests.
Solidity is pinned to 0.8.28, targeting the cancun EVM for portability across L2s.
forge buildforge test- Run a specific suite:
forge test --match-contract StackingTest - Run with verbosity:
forge test -vvv
forge fmt- The owner is fully trusted. It sets the redeemer and drives settlement, which involves calling that redeemer with owner-supplied calldata. The call target is pinned to
redeemer, but a malicious or compromised owner can point the vault at a redeemer of its choosing. - Allocation targets are trusted. When a vault accrues, it reads each target's stored
totalAssetsone level down. A target that misreports its value skews the vault's share price; a target that reverts ontotalAssetsorwithdrawblocks accrual and withdrawals. (A target reading back into the vault no longer matters — the stored total stops the recursion.) Targets are owner-curated for this reason. - The price feed is trusted within
maxAge. The vault marks all RWA — held and mid-settlement — at that price. - The issuer is trusted to settle. RWA sitting in
rwaInRedemptionis marked at full oracle value while off-chain. An issuer that never settles leaves that value permanently overstated.
- A misbehaving target degrades its vault. A cycle no longer bricks anything, but a target that misreports its value skews the vault's share price, and a target that reverts on
totalAssetsorwithdrawblocks accrual and withdrawals. Targets are owner-curated for this reason. - Assets in a reverting target are stuck.
deallocatewithdraws from the target, so it fails if the target'swithdrawreverts.removeTargetonly helps once the balance is zero. - Strategy adapters inherit their venue's risk.
VaultifiedAaveis only as available as the underlying Aave market — high utilization or a frozen reserve can block withdrawals, which propagates up as a redemption that cannot source liquidity from that leaf. - Yield lags with depth. Because the total is stored and refreshed per hop, a leaf's gains reach the root only after one accrual per level. Between accruals the root's share price slightly understates true value; a keeper calling
accrue()up the stack keeps the lag small. - A cycle can double-count value if curated deliberately. Stored accounting means a loop never bricks a vault, but a curator who routes the same capital in a circle can inflate
totalAssets. This requires self-dealing across the loop and is bounded per edge by caps; curation is the mitigation. - Redemption is oracle-priced, not market-priced.
redeemRwafills at the oracle mark less the fee, soredemptionFeeandrwaCapare the only buffer against a mispriced or depegging RWA.
Not yet audited. This code is unaudited and has not been reviewed for production use.
Further details will be made available soon.
Plexus Protocol is licensed under the Business Source License 1.1 (BUSL-1.1). The Licensed Work converts to the Change License on the Change Date named in the license file. Each Solidity file declares SPDX-License-Identifier: BUSL-1.1.