Liquidity Nodes
DobDex uses a permissionless Liquidity Node (LN) system. Anyone can deposit USDC and earn yield by providing exit liquidity for RWA token sells and liquidations. Unlike a generic AMM, LPs do not deposit into a shared bonding curve — they create an LP position, then explicitly back specific assets with their own conditions, and are matched by the hook FIFO-style at fill time.
The two-step LP model
Becoming an LN takes two steps:
- Register an LP position with USDC.
- Back individual assets with conditions (price floor, minimum discount, exposure cap) and an earmarked USDC allocation.
struct LPPosition {
uint256 usdcDeposited; // lifetime deposit
uint256 usdcAvailable; // not yet allocated to any asset
uint48 registeredAt; // anti-flash-loan timestamp
bool active;
}
struct AssetBacking {
uint256 minOraclePrice; // 18-decimal USD floor — fills only when oracle >= this
uint16 minPenaltyBps; // minimum discount (LP-priced exit margin)
uint256 maxExposure; // dobRWA cap for this asset
uint256 currentExposure;
uint256 usdcAllocated;
uint256 usdcUsed;
uint48 backedAt; // anti-frontrun timestamp
bool active;
}
So an LP "node" really means (LPPosition, AssetBacking): one LP, one specific asset, one set of conditions. A single LP can back many assets simultaneously (one AssetBacking per asset).
Functions an LP calls
| Function | What it does |
|---|---|
register(amount) | One-time. Deposits USDC, creates the LPPosition. |
depositMore(amount) | Add more USDC to the position later. |
backAsset(rwaToken, minOraclePrice, minPenaltyBps, maxExposure, usdcAllocation) | Earmark USDC for a specific asset under specific conditions. |
updateConditions(rwaToken, ...) | Change conditions for an existing backing (re-sorts the per-asset backer list if minPenaltyBps changes). |
increaseAllocation(rwaToken, amount) | Move more USDC from usdcAvailable into a specific backing. |
stopBacking(rwaToken) | Stop backing — see Stop-backing and the reserve hold below. |
requestWithdrawal(amount) | Start the 24h timer to withdraw usdcAvailable. |
executeWithdrawal() | After 24h, pull the USDC out. |
cancelWithdrawal() | Cancel a pending withdrawal. |
claimRwaTokens(rwaToken, dobRwaAmount) | Convert dobRWA earned from fills into the underlying RWA via the hook + vault. |
releaseReserve(holdIndex) | Unlock a 33% reserve hold (see below). |
How the hook matches LPs
The hook calls one of two functions on the registry during a sell, both nonReentrant and gated to msg.sender == hook:
| Hook path | Function | Discount source |
|---|---|---|
| Liquidation enabled | queryAndFill(rwaToken, oraclePrice, currentPenaltyBps, usdcNeeded) | Protocol-mandated penaltyBps. |
| Normal sell | queryAndFillAtMarket(rwaToken, oraclePrice, usdcNeeded) | Each LP's own minPenaltyBps. |
Both iterate the per-asset backer list, which is sorted ascending by minPenaltyBps (cheapest fills first), and skip any backer that:
- is younger than
MIN_BACKING_AGE(1 hour), - has
oraclePrice < minOraclePrice, - has
currentPenaltyBps < minPenaltyBps(liquidation path only), - has zero
usdcAllocated - usdcUsed, - has zero
maxExposure - currentExposure.
For each filled LP, the registry charges PROTOCOL_FEE_BPS = 150 (1.5%) into accumulatedFees, transfers the net USDC to the hook, and credits rwaOwed[lp][rwaToken] for later claim.
Constants
uint48 public constant WITHDRAWAL_DELAY = 24 hours;
uint48 public constant MIN_BACKING_AGE = 1 hours;
uint8 public constant MAX_BACKERS_PER_ASSET = 50; // per-asset cap
uint16 public constant PROTOCOL_FEE_BPS = 150; // 1.5% of every LP fill
uint16 public constant RESERVE_BPS = 3300; // 33% reserve on distressed exit
uint48 public constant RESERVE_WITHDRAWAL_DELAY = 7 days;
MIN_BACKING_AGE blocks flash-loan-then-fill attacks. WITHDRAWAL_DELAY blocks LPs from front-running known liquidations. MAX_BACKERS_PER_ASSET = 50 keeps gas bounded for the O(n) matching loop.
Stop-backing and the reserve hold
stopBacking(rwaToken) always returns the LP's unused USDC allocation, but if the asset is currently in distress (liquidation enabled with penaltyBps > 0), the registry holds back 33% of the returned USDC as a ReserveHold:
struct ReserveHold {
uint256 amount; // 33% locked
address rwaToken; // distressed asset that triggered the hold
uint48 createdAt;
}
The reserve unlocks when either:
- the asset returns to healthy (liquidation disabled or
penaltyBps == 0), or RESERVE_WITHDRAWAL_DELAY = 7 dayshas elapsed sincecreatedAt.
The LP then calls releaseReserve(holdIndex) to move the USDC back to usdcAvailable. This is the only place where the 33% reserve applies — it does not continuously lock 33% of every LP's deposit.
Returns for LPs
LPs earn returns in two complementary ways:
- Discount on RWA tokens received during fills. The seller pays the discount (
minPenaltyBpsfor normal sells, protocolpenaltyBpsfor liquidations); the LP receivesdobRwa = fillUsdc * 10000 / (10000 - effectivePenaltyBps). - Underlying RWA yield. After
claimRwaTokens, the LP holds the actual RWA tokens and earns whatever those tokens yield off-chain.
Net of fees: every LP fill is taxed PROTOCOL_FEE_BPS = 150 (1.5%) — the protocol fee accrues to accumulatedFees and is withdrawn by the owner via withdrawFees() to protocolTreasury.
LP fill math (normal sell @ market):
penalty = backing.minPenaltyBps (each LP's own choice)
fillUsdc = remaining (capped by usdcAllocated - usdcUsed)
dobRwaAmount = fillUsdc * 10000 / (10000 - penalty)
LP fill math (liquidation):
penalty = registry.liquidations[token].penaltyBps (protocol-set)
Reading LP state
positions(lp) -> LPPosition
backings(lp, asset) -> AssetBacking
getAssetBackers(rwaToken) -> address[] // sorted ascending by minPenaltyBps
getAssetBackerCount(rwaToken) -> uint256
getAssetLiquidity(rwaToken) -> uint256 // total available USDC across active backers
getReserveHolds(lp) -> ReserveHold[]
rwaOwed(lp, rwaToken) -> uint256 // claimable dobRWA
Risk factors
| Risk | Mitigation in the code |
|---|---|
| Backing a stale-priced or worthless asset | minOraclePrice per-backing floor. |
| Flash loan in / out around a known fill | MIN_BACKING_AGE = 1h. |
| Front-run withdrawal before liquidation | WITHDRAWAL_DELAY = 24h. |
| Cascading distressed exits | RESERVE_BPS = 33% reserve hold for 7d on stop-backing into distress. |
| Concentration | per-backing maxExposure cap. |
| Per-asset gas | MAX_BACKERS_PER_ASSET = 50 per token. |
See also
- Liquidity-Node Pricing — how the hook actually routes buys vs sells.
- Pooled Liquidity Node — DobPooledLN, the shared LN vault that wraps a single LP position.
- Liquidations — protocol-mandated discount path triggered by the validator.