Abstract
This proposal defines an interface for a contract that lends an ERC-20 quote token into a concentrated-liquidity position that a holder pairs with base tokens it owns. The position, an ERC-721 token of a position manager, stays in the contract's escrow: the holder cannot withdraw it. Fees in the quote token pay the lender first. The holder closes the position by repaying what it owes and gets the position token back. While anything is owed, anyone may unwind the position once its value, taken from the price feed the contract names for the pool and never from the pool's own price, no longer covers the loan by the margin fixed at opening, or once it is due. A price set while the base token was not trading is never used. The unwind settles at the feed's price without a swap, pays the lender's base tokens to a fallback address the lender names, and leaves the holder's part in the position token, so a token whose transfer registry blocks an address cannot strand a position. When, past the due date, no price has come for a delay the lender lists, at least 14 days, or once a day has passed since the base token stopped trading for good, anyone may instead hand the position over without a price: the lender takes the quote, up to what it is owed, and the position token too if that quote falls short.
Motivation
Holders of tokenized stocks and other assets with a reliable price could earn pool fees on them if someone supplied the other side of the pool. Lenders with stablecoins could fund that side and be repaid from the fees, holding as security both the holder's tokens and the loan itself, inside one position. Lending against pool positions and leveraged liquidity already exist as products, each with its own contracts and events. A wallet that wants to show a holder its positions, a lender that wants to offer this to many holders, or a reader that rebuilds a borrower's credit history has to integrate each one separately.
Three failures recur in this kind of lending, and each is cheaper to rule out in the interface than in every implementation. Lenders that valued pool positions from the pool's own price have been drained by trades that moved that price for one transaction. Tokenized stocks carry transfer registries that can block an address, and a position held by an escrow the registry blocks, with no other way out, is lost to both sides. And stock prices stop while their market is closed and jump when it reopens, so a price that is a few hours old can be a price nobody can trade at. This proposal fixes the calls and events, and one rule for each of the three.
Specification
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.
Definitions
- Holder: the account that opens a position with its base tokens.
- Lender: the party whose quote tokens the contract lends.
- Base token and quote token: each supported pool pairs a base token with
quoteToken(). - Position manager and position token: an ERC-721 contract that represents each position in a concentrated-liquidity pool, liquidity between a lower and an upper tick as in Uniswap v3 and v4, by a token; and that token. Amounts withdrawn from a position, and its fees, stay owed to the position token until they are collected.
- Price feed: a contract with the two functions below, whose
answeris the price of one whole base token in whole quote tokens, multiplied by10 ** decimals().
interface IPriceFeed {
function decimals() external view returns (uint8);
function latestRoundData() external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound);
}- Status source: for a base token whose reference market trades in sessions or can stop trading, such as a tokenized stock, a contract with the function below, which reports the base token's trading status in that market and since when it holds. A base token traded continuously has no status source.
interface ITradingStatus {
enum TradingStatus { Unknown, Trading, SessionBreak, Halted, Terminal }
function tradingStatus() external view returns (TradingStatus status, uint64 since);
}- Trading status:
Tradingwhile the market is in session and the asset trades,sincethe session opened or trading last resumed;SessionBreakwhile the market is closed between sessions on its schedule, at night, over a weekend or a holiday, or in an intraday break,sincethe break began;Haltedwhile trading in the asset, or in its whole market, is stopped outside the schedule with no time set to resume, as in a halt or a suspension,sinceit stopped and through any scheduled break until trading resumes;Terminalonce the asset will not trade in that market again, as after a delisting, a redemption or the end of the token's program; andUnknown, which is zero so that an unset value never reads asTrading, when the source cannot tell.sinceis when the status began, not when the source last confirmed it. - Usable price: the answer of a position's feed (before opening, of the feed
oracleOf(pool)names) when all of these hold:answeris above zero;updatedAtis not zero, not later than the block timestamp, and not older thanmaxPriceAge;- if the base token has a status source, the source reports
Trading, andupdatedAtis at or after thesinceit reports; - the price it gives, once the decimals of the feed and of both tokens are applied, is above zero and not above the largest price the contract accepts.
- Value of a position at a usable price
P: the base and quote its liquidity holds when the price isP, plus the base and quote already owed to its position token, with the base valued atP. Fees that the position manager has not yet credited to the position token MAY be left out.
Interface
A conforming contract MUST implement ICreditMatchedLiquidity and MUST implement ERC-165, returning
true for the interface identifier of ICreditMatchedLiquidity and for 0x01ffc9a7.
interface ICreditMatchedLiquidity {
enum Status { None, Open, Closed, Unwound, HandedOver }
struct Position {
address holder;
uint256 agentId;
address pool;
address positionManager;
uint256 tokenId;
address priceFeed;
address statusSource;
uint32 maxPriceAge;
uint16 unwindBps;
uint16 gapBps;
uint64 openedAt;
uint64 dueAt;
uint32 handoverDelay;
uint256 baseIn;
uint256 quoteLent;
uint256 owed;
Status status;
}
event PositionOpened(uint256 indexed positionId, address indexed holder, uint256 indexed agentId, address pool, uint256 baseIn, uint256 quoteLent);
event FeesApplied(uint256 indexed positionId, uint256 toLender, uint256 toHolder);
event PositionClosed(uint256 indexed positionId, uint256 repaid);
event PositionUnwound(uint256 indexed positionId, uint256 recovered, uint256 shortfall);
event PositionHandedOver(uint256 indexed positionId, uint256 recovered, uint256 outstanding);
function quoteToken() external view returns (address);
function identityRegistry() external view returns (address);
function lenderFallback() external view returns (address);
function oracleOf(address pool) external view returns (address priceFeed);
function quoteLimit(address pool, uint256 baseIn) external view returns (uint256);
function open(address pool, uint256 baseIn, uint256 quoteToBorrow, int24 tickLower, int24 tickUpper, uint256 agentId) external returns (uint256 positionId);
function position(uint256 positionId) external view returns (Position memory);
function health(uint256 positionId) external view returns (uint256 valueInQuote, uint256 owed, bool unwindable);
function applyFees(uint256 positionId) external returns (uint256 toLender, uint256 toHolder);
function close(uint256 positionId) external;
function unwind(uint256 positionId) external;
}Tokens, pools and prices
quoteToken()MUST return the ERC-20 token lent and repaid, and MUST NOT change.quoteLent,owed,toLender,toHolder,repaid,recovered,shortfall,outstandingandvalueInQuoteare in its base units;baseInis in the base token's.identityRegistry()MUST return the ERC-721 contract theagentIdvalues refer to, such as the Identity Registry of ERC-8004, or the zero address, and MUST NOT change.oracleOf(pool)MUST return the price feed the contract uses for positions opened from now on inpool, and the zero address for a pool it does not support.- The contract MUST NOT use the pool's own price (its current tick, square-root price or reserves) to compute
quoteLimit,health, or what an unwind pays to whom. The pool's price decides only the proportions in which a mint takes the two tokens and an unwind withdraws them. - The contract MUST read
decimals()from the feed, and the decimals of both tokens, rather than assume them. - For a base token with a status source, the contract MUST read the trading status from it, and MUST treat a source
that reverts, returns no answer, or answers with a value outside
TradingStatus, as reportingUnknown. - The contract MUST define the largest price it accepts, and MUST NOT support a pool whose feed, when the pool is added,
gives a price above it or not above zero. It MUST compute every price conversion, valuation, threshold comparison and
settlement amount in full precision (a multiplication kept in 512 bits before its division, or equivalent), so that
none can overflow for a usable price and the amounts the contract documents as supported.
healthandunwindMUST NOT fail with an arithmetic error for any answer of the feed. - Every amount of the quote token the contract records or reports (
quoteLent, each reduction ofowed,toLender,toHolder,repaidandrecovered) MUST be the amount that actually moved, measured as the change in the balance of the account that received it, or forquoteLentof the lender, and not the amount requested. The base the lender receives at an unwind MUST likewise be valued as it arrived atlenderFallback(). A quote token that charges a fee on transfer therefore cannot be repaid throughclose, and lenders SHOULD NOT lend such a token.
Opening
quoteLimit(pool, baseIn)MUST return the most quote the contract would lend againstbaseInnow. It MUST be computed from the value ofbaseInat a usable price oforacleOf(pool), MUST NOT exceed the quote the lender can lend now, and MUST return0, not revert, for a pool the contract does not support or when the price is not usable.open(pool, baseIn, quoteToBorrow, tickLower, tickUpper, agentId):- MUST revert if
baseInorquoteToBorrowis zero, or ifquoteToBorrowis greater thanquoteLimit(pool, baseIn). - MUST revert if
agentIdis not zero andmsg.senderis notownerOf(agentId)inidentityRegistry(). - MUST take
baseInfrommsg.sender, add liquidity betweentickLowerandtickUpperwith at mostbaseInbase andquoteToBorrowquote, hold the position token, and return tomsg.senderthe base the position did not take. Quote the position did not take stays with the lender. - MUST record the position with
holderset tomsg.sender;baseInandquoteLentset to the amounts the position took;owedset toquoteLentplus the lender's charge, if any;priceFeedset tooracleOf(pool);statusSourceset to the base token's status source, or the zero address for a base token traded continuously; the lender'smaxPriceAge,unwindBpsandgapBps;openedAtset to the block timestamp;dueAtset to the due time, or0for none;handoverDelayset to the hand-over delay the lender lists; andstatusset toOpen. It MUST give the position apositionIdthat is not zero and is never reused, emitPositionOpened, and return the id. - MUST revert if the new position would be unwindable (as
healthdefines it) at its usable price lowered bygapBps / 10000.
- MUST revert if
unwindBpsMUST be greater than10000andgapBpsless than10000.- For a base token traded in sessions, the lender SHOULD set
gapBpsto at least the largest move it expects between one session's last price and the next session's first, across a weekend or a holiday and through news, and SHOULD chooseunwindBpsand the ratio behindquoteLimitso that a position at its unwind line is still worth more than it owes after that move. - A position's
handoverDelayMUST be at least 14 days (1,209,600 seconds):openMUST NOT record a shorter one, whatever delay the lender lists.
Escrow and terms
- While a position is
Open, the contract MUST hold its position token and MUST NOT withdraw liquidity from it, collect from it or transfer it, except asapplyFees,closeandunwinddefine. - A position's
priceFeed,statusSource,maxPriceAge,unwindBps,gapBps,dueAtandhandoverDelayMUST NOT change. ItsowedMUST NOT increase after it is opened. position(positionId)MUST return the position as recorded, with its currentowedandstatus, and a zero-valued record with statusNonefor an id never issued.
Health
- For an
Openposition,health(positionId)MUST revert when the position's price is not usable. Otherwise it MUST return the position's value at that price asvalueInQuote, itsowed, andunwindableset totrueexactly whenowedis not zero and eitherdueAtis not zero and the block timestamp is at or after it, orvalueInQuote * 10000 < owed * unwindBps. A position with nothing owed is never unwindable, at its due date or after it. - For a position that is not
Open,healthMUST return zeros andfalse.
Fees
applyFees(positionId)MUST be callable by any account and MUST revert unless the position isOpen. It MUST collect the position's fees in the quote token, pay the lender the lesser of those fees andowed, reduceowedby that amount, pay the rest to the holder, emitFeesApplied, and return both amounts.- Fees in the base token MUST stay with the position:
applyFeesMUST NOT pay them to anyone. They go to the holder with the position token atclose, and are settled with the rest of the position atunwind. applyFeesMUST NOT read a price.
Closing
close(positionId)MUST revert unlessmsg.senderis the position's holder and the position isOpen. It MUST takeowedfrom the holder in the quote token withtransferFrom, and MUST revert if less thanowedarrives. It MUST then setowedto zero and the status toClosed, transfer the position token to the holder, and emitPositionClosedwith the amount that arrived.closeMUST NOT read a price and MUST NOT transfer the base token.
Unwinding
unwind(positionId)MUST be callable by any account. It MUST revert unless the position isOpenand itsowedis not zero: a position with nothing owed is never unwound or handed over, and only its holder'scloseends it. When the position's price is usable,unwindMUST revert unlesshealthreports the position unwindable, and then unwinds it as below. When the price is not usable, it MUST revert unless a hand-over is due, and then hands the position over (see Hand-over); so it reverts when the price is not usable, including afterdueAt, until a hand-over is due.- To unwind, it MUST remove all of the position's liquidity, then settle everything owed to the position token, fees
included, at the price
Pthathealthused:- the lender receives the quote, up to
owed; - if that does not cover
owed, the lender receives the base worth the rest atP, rounded up, up to all of the base. The position manager MUST pay this base straight tolenderFallback(); - the rest is the holder's: the contract MUST leave it owed to the position token and transfer the position token to the holder.
- the lender receives the quote, up to
unwindMUST NOT transfer the base token to or from the contract itself, and MUST NOT revert because the base token refuses a transfer. If the base cannot be paid tolenderFallback(),unwindMUST transfer the position token, with the base still owed to it, tolenderFallback()instead of the holder.- It MUST set
owedto zero and the status toUnwound, and emitPositionUnwoundwithrecoveredset to the quote the lender received plus the base it received valued atP, at mostowed, andshortfallset toowedminusrecovered. lenderFallback()MUST NOT return the zero address. The lender sets it, by means outside this interface, to an address it expects the base token's transfer registry to accept.
Hand-over
TERMINAL_GRACEis 86,400 seconds (one day): the time a holder has to close once its base token stops trading for good, counted from thesinceits status source reports withTerminal.- A hand-over is due for an
Openposition whoseowedis not zero and whose price is not usable when:- its status source reports
Terminaland the block timestamp is at or aftersince + TERMINAL_GRACE, whateverdueAt; or dueAtis not zero, the block timestamp is at or afterdueAt + handoverDelay, and no price has come for the hand-over delay: the status source reportsHaltedwith asincemore thanhandoverDelaybefore the block timestamp; or the feed is silent while the status source reportsTrading,UnknownorTerminal, or while the base token has no status source.
- its status source reports
- Before
since + TERMINAL_GRACE, aTerminalstatus makes no hand-over due by itself:unwindreverts, as for any price that is not usable, unless condition 2 already makes a hand-over due, andcloseworks as always, since it needs no price. Asincelater than the block timestamp has begun no window: the status gives no price, and condition 1 holds only fromsince + TERMINAL_GRACE. - The feed is silent when its answer would not be a usable price even with
handoverDelayin place ofmaxPriceAgeand without condition 3 of a usable price, that is, when it has given no answer the contract could price within the lasthandoverDelay. A feed that answered within that time is late, not silent: the contract waits for it. - An
Unknownstatus, including one read from a source that reverts, returns no answer or answers outsideTradingStatus, makes a hand-over due only together with a silent feed, whateversinceit reports: a source that fails, for one call or for longer, never stands in for a price missing for the whole delay. - A
SessionBreaknever makes a hand-over due, however long it lasts: the contract waits for the reopening price. - To hand a position over,
unwindMUST remove all of the position's liquidity, collect the quote owed to the position token, up toowed, for the lender, and then transfer the position token, with everything still owed to it, to the holder when the quote the lender received equalsowed, and tolenderFallback()otherwise. Neither an amount it moves or reports nor where the position token goes may depend on a price, and it MUST NOT transfer the base token. It MUST setowedto zero and the status toHandedOver, and emitPositionHandedOver, notPositionUnwound, withrecoveredset to the quote the lender received, at mostowed, andoutstandingset toowedminusrecovered.
Rationale
- A feed's price, never the pool's. A pool's price is the cheapest number in the system to move: one large trade, funded by a flash loan and reversed in the same transaction, moves it as far as the attacker likes. A lender that values positions from it can be made to lend against a value that is not there, or to unwind a position that is healthy. A price feed aggregates trades the attacker does not control.
- Pairs with a reliable price only. A pool position cannot be valued without a price from outside the pool. For a pair without a feed any valuation falls back on the pool's own price, which this proposal excludes, so a pool is supported only when the contract names a feed for it.
- The loan is inside the position. ERC-7565 states loan terms against an NFT that is already locked and leaves its valuation aside; ERC-4626 accounts for deposits and shares in a vault; ERC-7246 pledges a token balance without moving it. Here the loan funds the position itself, the position changes composition with every trade, and its health is defined from a feed's price.
- Prices from a closed market do not count. A feed for a stock keeps the last session's answer through a weekend,
and may refresh
updatedAtwithout a new price, so the age of an answer cannot show that it still holds. Opening at Friday's price lends against a value Monday may not confirm; unwinding at it gives the lender base at a price nobody can trade at. The contract therefore asks a status source, and uses only an answer set after the current session opened or trading resumed. A halt gives no price either. Requiring the answer to postdate the session's opening also covers the minutes after a reopening, before the feed has caught up with the gap. - A trading status, not a session flag. A flag that only says whether the market is open treats a halt as a
weekend. A weekend is bounded and
gapBpscan be sized for it; a halt is not, and halts, delistings and redemptions are when the reopening price gaps the most, if it comes at all. The status therefore tells apart a session break, after which a price comes back, a halt, which may last, and a terminal state, after which no price will come. None of them gives a price; they differ past the due date: a break is waited out, a halt is waited out for the hand-over delay, and a terminal state is handed over after a day's grace, due or not, since waiting cannot bring a price.sincedates a price against the session it belongs to, times a halt and starts a terminal state's grace.Unknownis zero so that a source that was never set reads as unknown, not as trading, and a source that cannot answer reads the same, so that it lets no price through. An unknown status says nothing about how long the price has been missing, so it counts toward a hand-over only with a feed silent for the whole delay: a source that fails for a moment cannot cost the holder its part. - A small status interface of its own. Asset-status interfaces drafted for tokenized assets report more than the
escrow needs: the program's lifecycle, the session and any interruption separately, the state of the valuation
source.
ITradingStatusreduces them to the one answer the escrow acts on, and an adapter can derive it from such an interface, a feed's market-status report or an issuer's pause flag. Its function is namedtradingStatus()so that its selector differs from that of anassetStatus()view drafted for tokenized assets, whose answer has another layout and would otherwise decode as a wrong status. - Closing and fees need no price.
owedis in the quote token andclosehands back the position token, so neither depends on a price that may no longer hold, and a holder can repay over a weekend. Only opening and unwinding wait for the market. - A gap allowance at opening. Session prices move in jumps: the first price of a session can be far from the last of
the one before, with no price in between at which to unwind. A position opened with no room for that jump can go from
healthy to under water at once.
gapBpsmakes the room explicit and is recorded in the position, so a reader sees it. - Fees, lender first. Quote fees pay the lender before the holder sees any. Base fees stay in the position: applying them to a debt in the quote token would need a price, and paying a stock token to a lender may not be allowed by its registry.
- Closing returns the position token. The holder gets the position back as it is, liquidity and base fees included. No base token moves, so a registry that blocks the escrow or the holder cannot stop a repayment, and the holder can keep providing liquidity if it wants.
- Unwinding settles at the feed's price, without a swap. Swapping the base in the pool during an unwind is the trade
a manipulator wants to be on the other side of. The lender takes base at
Pinstead, and carries the risk of selling it. Withdrawing at a pool price other thanPnever lowers the value atPof what is withdrawn: a position trades along the pool's curve, so its holdings valued atPare lowest when the pool's price isP. Moving the pool before an unwind therefore costs the mover and takes nothing from the lender or the holder. - The lender's fallback address. The lender's book holds the quote token. The base it receives, and the position token when the base cannot leave the pool, go to an address the lender keeps acceptable to the base token's registry. The holder's part never needs a transfer of the base: it waits in the position token until the holder's address is allowed to collect it.
- A hand-over when no price comes. Without one, a feed that dies after the due date leaves the holder a free option:
close if the asset recovers, walk away if it does not, while the lender waits on a feed that may never return. Only
the base leg of an unwind needs a price; the quote leg does not. So once a hand-over is due,
unwindcollects the quote up toowed. When that quote coversowed, the lender is paid in full and everything left in the position token is the holder's, which takes no price to tell, so the token goes to the holder, as after an unwind at a price. When it falls short, the base cannot be split between the lender and the holder without a price, so the token goes to the lender whole, for the rest; the holder had the term and the delay to close, and closing needs no price. The pool's price at the hand-over decides which of the two happens; Security Considerations cover moving it. - A silent feed, not a late one. A hand-over whose quote falls short of
owedgives the lender more than an unwind at a price would, the holder's part of the position, so a lender gains from triggering one, and the rule must not hand over on a price that is only late. A session that has just opened has no price yet, and a feed can fall behindmaxPriceAgefor a while. The hand-over therefore waits until the feed has given no answer the contract could price for the whole delay, or a halt has lasted it, which a live feed, or a halt that ends with trading resuming, does not reach; an unknown status waits for the silent feed too. A session break never counts, however long a source reports it: breaks are scheduled and end with a price. - The hand-over delay is the lender's, with a floor. The delay is a term like
gapBps: the lender lists it and the position records it, so a holder knows at opening how long past the due date it can still close, and lenders can compete on it; one delay fixed in this proposal would not fit every base token and every lender. It MUST still be at least 14 days, for three reasons. It is how long a feed must be silent to count as dead, and some stock exchanges close on schedule for up to about ten days, around the lunar new year, so a shorter delay would let a feed that does not answer while its market is closed look dead at the reopening, minutes before the session's first price. It is how long a halt must last before the position is handed over, and a trading suspension by the United States securities regulator lasts up to ten business days, about 14 calendar days, while most halts end within the day. And it is the holder's last time to close after the due date, which two weeks give across a holiday. The floor is a requirement, not a recommendation, so that a holder can trust the delay of any conforming contract without checking it pool by pool: no conforming lender can list a few hours and take positions over an ordinary outage. The lender waits at most the delay past the later of the due date and the moment the price stopped coming, and a lender that wants to wait less can lend for a shorter term. - A day's grace after a terminal status. A base token that will never trade again will never give a price, so a
terminal status does not wait for the due date. A hand-over still takes the holder's part, and closing needs no
price, so the hand-over waits
TERMINAL_GRACE, one day, from thesincethe status source reports: a holder that is watching can repay and keep its part, and a wrongTerminalleaves holders a day to react. The lender, which can do nothing with a price that will not come, waits only that day. The window is fixed in this proposal rather than listed by the lender, since it only protects the holder and its length depends on how long a holder needs to repay in the quote token, not on the base token's market. It runs fromsincebecause a view keeps no record of when its answer changed: the contract cannot tell when a status was first reported, only when the source says it began. A hand-over due for another reason, a feed silent for the hand-over delay past the due date, does not wait for the window: the holder has already had the term and the delay to close. - Nothing owed, nothing to unwind. Fees can repay a position in full before it is due. A due date ends the lender's
exposure, and with nothing owed there is none, so such a position is never unwindable and never handed over; its
holder closes it, paying nothing, and gets the position token back as
Closed. Otherwise anyone could pull the holder's liquidity at the due date, and a reader of an identity's credit history would see an unwind for a loan repaid in full. - A due date, a charge fixed at opening, terms that do not move. A due date stops a position from holding the
lender's quote for ever. Folding the charge into
owedat opening lets the holder know the most it will owe. Fixing the feed, the status source, the price age, the unwind line and the hand-over delay per position stops a lender from unwinding, or taking over, a position that was healthy under the terms it was opened on. - The identity is the holder's.
agentIdlets readers count a position's opening, closing and unwinding as credit events of an identity, such as an ERC-8004 agent. Checking ownership at opening stops anyone from attributing a position, and its shortfall, to an identity that is not theirs. poolis an address. Uniswap v3 pools have one. A pool of a singleton pool manager has an identifier instead; an implementation for such pools names each supported pool by an address it documents. A later revision may take the identifier.
Backwards Compatibility
No backward compatibility issues found. The interface fits position managers that keep withdrawn amounts owed to the position token until they are collected, as v3-style managers do. A manager that settles every amount within the call that withdraws it, as v4-style managers do, needs a wrapper that keeps those amounts for the token, so that the holder's part of an unwind can wait for the holder.
Test Cases
A conforming implementation passes these cases. Each starts from a base token of 18 decimals that trades in sessions and
is priced 250 quote by a feed of 8 decimals, a quote token of 6 decimals, and a lender that lends up to the value of the
base brought, unwinds below an unwindBps of 11000, requires a gapBps of 2000, charges 1% for 30 days, hands over
14 days past the due date, and accepts prices at most a day old. The holder brings 100 base and borrows 25,000 quote.
The positions in the tests are full-range, so this one is worth 50,000 at 250, and 50,000 times the square root of
P / 250 at a price P.
quoteLimitfor 100 base is 25,000 and does not move with the pool's own price. An open of 25,000 takes 100 base and 25,000 quote, records 25,250 owed, keeps the position token, emitsPositionOpenedand returns id 1; an open of one unit more reverts, as does an open with an identity the caller does not own.- An open on a pool whose price was pushed up 100 times reverts: the position would be worth 5,000 at the feed's price.
With an
unwindBpsof 18000 an open reverts, since the position would be worth 44,721 after a 20% fall; with agapBpsof 0 it succeeds. - Quote fees of 300 and base fees of 2, applied by a stranger, pay the lender 300 and the holder nothing, leave 24,950 owed and leave the 2 base in the position. With 50 owed, quote fees of 300 pay the lender 50 and the holder 250.
- A close by anyone but the holder reverts. The holder's close takes 25,250 and returns the position token, also with no usable price and with the base token blocking the escrow and the holder.
- At 80 the position is worth 28,284 and
unwindreverts. A pool price of 10 or 100,000 leaveshealthunchanged. - At 70, in the feed and the pool, an unwind by a stranger pays the lender 13,228.76 quote and 171.732 base at its fallback address, and leaves 17.250 base in the position token, now the holder's; it reports 25,250 recovered and no shortfall. Opened at 256 and settled when due at 255 with the pool still at 256, where every amount is exact, the lender takes 25,600 quote and 1,003,921,568,627,450,981 base units (256/255 of 1e18, rounded up), and nothing is short. At 70 in the feed and 250 in the pool, the lender takes 25,000 quote and 3.571 base. At 20, the lender takes everything and the unwind reports 14,142.14 recovered and the rest as shortfall.
- An answer that is zero, negative, from the future or more than a day old, or a feed of 19 decimals, makes
healthandunwindrevert andquoteLimitreturn 0. One second beforedueAta healthy position cannot be unwound; atdueAtit can. - With quote fees that repay all that is owed, the position is not unwindable at
dueAt, and an unwind by anyone reverts atdueAt, past the hand-over time with the feed silent, and with a terminal status, at once and a day later. The holder's close takes nothing, emitsPositionClosedwith 0, and returns the position token asClosed. - With the base token blocking the escrow and the holder, the unwind at 70 still pays 171.732 base to the fallback address. With the base token blocking the pool, it pays the quote, gives the position token to the fallback address and reports the base it could not pay as shortfall.
- On a session break, a fresh answer of 70 is not used, even after
dueAt:healthandunwindrevert,quoteLimitis 0 andopenreverts, whileapplyFeesandclosesucceed. After the market reopens, an answer refreshed a minute before the opening is not used, and the first answer of the session, 70, allows the unwind. A halt gives no price either. A status source that reverts, has no code or answers outsideTradingStatusgives no price; a pool whose base trades continuously needs no status source. - No position records a
handoverDelaybelow 14 days: terms with a delay of 0, six hours or one second short of 14 days are refused, and a position opened on terms of 14 days records 14 days. - With the feed silent since the opening, an unwind one second before
dueAt + handoverDelayreverts. At that time an unwind by a stranger hands the position over: the lender receives 25,000 quote, and the position token with the 100 base still in it at its fallback address; no base token moves; the status isHandedOver; andPositionHandedOverreports 25,000 recovered and 250 outstanding. - With the pool's price at 300 at that time, the quote withdrawn, 27,386.13, covers what is owed: the lender receives
25,250, the holder receives the position token with 91.287 base and 2,136.13 quote still in it, the status is
HandedOver, andPositionHandedOverreports 25,250 recovered and nothing outstanding. The same holds a day after a terminal status; with the pool pushed back to 250 before the call, the quote falls short and the lender receives the position token with the 100 base, as above. - At the hand-over time, a feed whose last answer is two days old is late, not silent, and the unwind reverts until that answer is more than 14 days old; a fresh answer of zero counts as silence and hands the position over.
- A session break that runs past the hand-over time, even one reported for weeks with the feed silent, never hands over; once the market reopens, an answer from before the break is late, not silent, and the first answer of the session, 70, unwinds the position at a price, leaving the holder its part. The same stop reported as a halt hands over. A halt that began an hour before the hand-over time hands over only once it has lasted more than 14 days.
- A terminal status gives no price and hands nothing over for a day: with the feed still answering, an unwind reverts
at once and one second before
since + TERMINAL_GRACE, and at that time an unwind by a stranger hands a healthy position over, a month beforedueAt, while the base token blocks every transfer. Inside the day, with the feed dead, a stranger's unwind reverts and the holder's close takes 25,250 and returns the position token, with all of its liquidity, asClosed. A terminal status dated a day ahead of the block hands over two days later, and one dated a day back hands over at once. PastdueAt + handoverDelay, a terminal status that begins when the feed has been silent for the delay hands the position over at once; with the feed answering, it waits its day. - A status source that reverts or answers outside
TradingStatus, once or for longer, hands nothing over pastdueAt + handoverDelaywhile the feed answers; once the source answers again, the overdue position is unwound at the feed's 250 and the holder keeps its part. With the source failing and the feed silent for exactly 14 days the unwind reverts, and one second later it hands the position over. - With a quote token that burns 1% of every transfer, a close that would deliver 24,997.5 of the 25,250 owed reverts;
quote fees of 300 pay the lender the 297 that arrive and reduce
owedby 297; and the unwind at 70 takes base for what is owed minus the quote that arrived. - For bases of 6, 8 and 18 decimals, quotes of 6 and 18 and feeds of 8 and 18,
quoteLimitfor 100 base is exactly 25,000 quote, 25,250 is owed, and the unwind at 70 pays the lender 171.732 base. - For the same bases and quotes and feeds of 0, 8 and 18 decimals, with 2^100 base units owed to the position token, the
largest answer the contract accepts lets
healthanswer and an unwind pastdueAtrecover all that is owed, and one unit more is not used. An answer oftype(int128).maxmakeshealthandunwindeither answer or refuse the price, never fail with an arithmetic error. A pool whose feed gives a price out of bounds cannot be added.
Reference Implementation
CreditMatchedLiquidity accompanies this proposal with its tests and mocks. It has one lender: its own quote balance is
what it lends, and the lender lists each pool with a price feed, a status source for a base traded in sessions, the
ratio behind quoteLimit, unwindBps, gapBps, a charge, a term, a hand-over delay and maxPriceAge. Its listPool
reverts with BadTerms() for a hand-over delay below MIN_HANDOVER_DELAY, 14 days, so that no position records a
shorter one, and it hands a position over for a terminal status TERMINAL_GRACE, one day, after the status's since;
both are public constants. It talks to a v3-style position manager through a reduced interface whose amountsAt
returns what a position holds at a given price; against a Uniswap v3 manager an implementation computes the same with
that protocol's tick and liquidity math. It reads the trading status through ITradingStatus, which an adapter can
derive from a status interface on the token, a feed's market-status report or an issuer's pause flag, with a low-level
call whose answer it checks, so that a source that reverts, has no code or answers outside TradingStatus reads as
Unknown and makes no call revert. It accepts prices up to 1e48 quote units per 1e18 base units, which keeps every
amount in range for token amounts up to 2^128, computes with a 512-bit multiply-then-divide, and measures every
movement of the quote token by balance. It is not audited and must never be deployed without an external audit.
Security Considerations
- Not audited. The reference implementation is a draft, written against mocks to show the interface. It has not been audited and must never be deployed without an external audit.
- Oracle manipulation and staleness. Everything that protects the lender rests on the feed. A feed that can be moved,
because its market is thin, it has one reporter, or someone in the loan controls it, can be used to unwind healthy
positions or to open against a value that is not there. Lenders SHOULD use feeds aggregated from several sources, with
a heartbeat shorter than
maxPriceAge; holders SHOULD read a position'spriceFeed,statusSourceandhandoverDelaybefore opening. A feed that stops updating stops opening and unwinding: the position then waits for the feed or for the holder to close, until the feed has been silent for the hand-over delay past the due date and the position is handed over. On rollups a halted sequencer delays feed updates, and implementations there SHOULD also check the sequencer's uptime. Decimals are read, never assumed, and implausibly large answers are rejected. - Market closures and gaps. No price exists while a stock's market is closed, and the first price after it reopens
can differ from the last by more than
gapBps. A position can then be worth less than it owes when it first becomes unwindable, and the lender takes the shortfall;gapBpsandunwindBpsare its only protection, since there is no price in a closed market at which an unwind could do better. A halt, a delisting or a redemption can gap further, or end with no price at all; past the hand-over delay, or a day after a terminal status began, the position is then handed over without a price. - The hand-over and the holder. A hand-over whose quote falls short of
owedgives the lender the whole position token: the holder's part of the base goes with it, and whatever the lender returns of it is settled outside this interface. One whose quote coversowedgives the holder the position token, as an unwind at a price would. A holder that wants to be sure of keeping its part has to close beforedueAt + handoverDelay; closing needs no price, so no outage or halt stops it. A terminal status hands a position overTERMINAL_GRACE, one day, after thesinceits source reports, before its due date too, so holders of a base token that can be delisted or redeemed SHOULD watch its status and keep the quote to close at hand. - A short hand-over delay. The delay is the holder's last time to close after the due date, and how long a feed
must be silent, or a halt last, before the lender takes the whole position without a price. A delay of hours would
let a lender take positions over an outage or a halt that would have ended in an unwind at a price, in which the
holder keeps its part, and a lender that runs the feed, or can hold back its updates, could arrange such an outage.
A delay shorter than the longest scheduled closure of the base token's market would let a feed that does not answer
while the market is closed look silent at the reopening, before the session's first price, when anyone, the lender's
keeper first, could hand the position over. The floor of 14 days is therefore a requirement: a contract that records
a shorter
handoverDelaydoes not conform, and a holder can rely on the floor without reading each pool's terms. - A wrong or malicious status source. The status source is trusted like the feed, and one the lender controls is
worse than a feed it controls: by reporting
Terminalit hands every open position over a day later, due or not, holder's part included, and at once if it dates the status a day back. Holders SHOULD open only against a status source the lender cannot write, such as the token issuer's own status or an adapter over it. A source that reportsTradingduring a halt or a break lets a stale price through. One that reports a session break for ever stops every unwind and every hand-over, and the lender waits on it as on a feed that never returns; so does one that reportsTerminalwith asincefar ahead while the feed keeps answering. One that wrongly reportsHaltedcan hand over only a position already past its due date and delay, and only once the halt it reports has lasted the delay. A source that reverts, has no code or answers outsideTradingStatusreads asUnknown, and so does one that reports it: it lets no price through, and it hands a position over only fromdueAt + handoverDelayand only when the feed has also been silent for the whole delay, the same bar as a silent feed alone, so a source that fails for one call hands nothing over. A source that stays unknown while the feed keeps answering stops every unwind and every hand-over, like one that reports a session break for ever. - A backdated
since. The grace after a terminal status runs from thesincethe status source reports, which the contract cannot check. A source that reportsTerminalwith asincea day or more in the past hands positions over with no window at all, and an honest source that learns of a delisting late, and dates it from when it took effect, leaves less than a day. Trusting a source withsinceis the same trust as trusting it withTerminal: a source that would backdatesincecould as well reportTerminalfalsely, and the grace protects holders only against a terminal state that is real and reported when it happens. Holders limit the risk as they limit a falseTerminal: by opening only against a source the lender cannot write and that reports promptly, such as the token issuer's own status or an adapter over it, and by watching the base token's status, and the issuer's notices of a delisting or a redemption, which often come before it takes effect, so as to close as soon as one is announced or reported. Asincelater than the block timestamp begins no window: it holds the hand-over back untilsince + TERMINAL_GRACE, unless a feed silent for the hand-over delay past the due date makes one due first. - Telling a hand-over from an unwind. A hand-over emits
PositionHandedOverand leaves the statusHandedOver; an unwind emitsPositionUnwoundand leavesUnwound, and neither emits the other's event.shortfallin an unwind is a loss valued at the feed's price.outstandingin a hand-over is not valued: it is what the quote did not cover, and when it is not zero the lender holds the position token against it, which may be worth more or less; when it is zero the holder has the position token. A reader that builds a credit history SHOULD NOT countoutstandingas a loss. A hand-over beforedueAt + handoverDelaycan only come from a terminal status of the base token, not from the holder; one at or after that time means the holder had not repaid by then. - Pool-price manipulation. Lenders that valued pool shares from the pool's own reserves have been drained by flash
loans that moved those reserves within one transaction, the Warp Finance exploit of 2020 being the best known. This
interface never values a position from its pool. The pool's price still sets the proportions of a mint: a holder whose
opening is sandwiched deposits at a moved price and loses as any liquidity provider would. The gap check makes such
an opening revert when it would leave the lender exposed, and holders SHOULD open through a private route or a
contract that bounds the pool's price against the feed's. At an unwind a moved pool price changes the mix withdrawn,
not its value at the feed's price. At a hand-over it decides whether the quote withdrawn covers
owed, and so whether the position token goes to the holder or to the lender: a lender that pushes the pool's price down before callingunwindcan make the quote fall short and take the whole position, the holder's part included, as any hand-over that falls short does. It can do so only once a hand-over is due; a holder that wants to be sure of its part closes before then, which needs no price. - Fee accounting. Fees are what the position manager credits to the position token. Quote fees pay the lender before
the holder, and base fees stay in the position until it is closed or unwound, so no fee leaves for the holder while
anything is owed.
healthmay leave out fees not yet credited, which only makes a position look worse than it is. A holder that trades through its own range pays fees to every provider in range, itself included, and its own share pays the lender first, so fees cannot move value out of the position. Charges and the lender's base round up, values round down. - Transfer-blocked tokens. A tokenized stock's registry can block any address. No base token passes through the
contract at closing, unwinding or a hand-over: at an unwind the lender's base goes from the position manager to
lenderFallback()and the holder's part stays owed to the position token, and at a hand-over all of the base stays owed to the position token, which the lender or the holder receives. If the registry blocks the pool or the position manager, no base can leave it; the unwind then pays the quote, gives the position token, with all of its base, to the lender's fallback address and reports the base it could not pay as shortfall. The holder's part of that base is then held by the lender, outside this interface. A quote token that blocks the contract or the holder can stopapplyFeesandclose. - Tokens that do not move exact amounts. A token that charges on transfer, or rebases, makes the amount requested
differ from the amount received. Accounting by the amount requested would let a holder close while the lender
receives less than it lent, and would overstate what fees and unwinds repaid. Measuring each movement by balance keeps
owedand the events true; such a quote token cannot be repaid in full throughclose, so lenders should not lend it. - Arithmetic. A feed can report any answer, and decimals vary: a base of 6 decimals priced by a feed of 0 decimals
in a quote of 18 turns a large answer into a very large price per base unit. Products of prices and amounts that
overflow would make
healthandunwindrevert exactly when a position needs unwinding. Bounding prices, refusing pools whose feed is out of bounds, and computing in full precision keep both answering. - Liquidation front-running. An unwind is open to anyone, pays no reward and settles at the feed's price, so racing another caller gains nothing: the caller only chooses the moment. A holder that sees a feed update coming may close or apply fees first, which is allowed. Anyone may unwind in the block that makes a position unwindable, but since the unwind pays at the feed's price, nobody profits at the holder's expense. An implementation that rewards the caller creates a race; it SHOULD pay the reward from the lender's share and cap it.
- Re-entrancy. The position manager, both tokens, the feed and the status source are external contracts.
Implementations SHOULD guard
open,applyFees,closeandunwindagainst re-entry. - Identity.
agentIdis checked against the registry at opening. A reader that counts positions as credit events of an identity still trusts the contract to have checked it.
Copyright
Copyright and related rights waived via CC0.
