Credit
One ledger, several issuers. ClearCredit records what each address owes and holds; the
issuers decide who may draw, how much, at what carry, and when a position has defaulted. All of them
write to the same ledger through the same NetworkRegistry. Deployed addresses are in the root
README.md (Deployed addresses).
MutualCredit.solStableCredit.solClearCredit.solinitialize to StableCredit and nothing elseCreditIssuer.solRevolvingIssuer.solTermIssuer.soltermLimitOfOffersIssuer.solCollateralRegistry.solLimitCalculator.solLiquidator.solAccessManager.solMembershipRegistry.sol, NetworkRegistry.solAccessManager.isMember reads; issuer → (ledger, pool, oracle) and member → issuersCreditPool.soldiscountRate, serviced from a queue. No deploy script or spec uses itCarryIndex.sol, ExposureMath.solThe ledger
Each address has two figures: credits held (balanceOf) and credits owed (creditBalanceOf).
Held credits are claims on the network — a merchant's balance is what it is owed. The token has 6
decimals; creditLimitOf is the ceiling. A transfer nets the two. MutualCredit._beforeTransfer mints whatever the sender's held balance
does not cover, and raises their creditBalance by the same amount, reverting with
Insufficient credit past creditLimitLeftOf. _afterTransfer burns whatever arrives against
the recipient's creditBalance first. Mints that are not transfers do not net:
_accrueCredit raises an obligation and mints the matching claim to someone else, without
consulting what the obligor already holds, so an address can hold credits and owe at once.
Entries that are not transfers
originatePurchase(member, purchase, merchant, payout, coop, discount)purchase == payout + discount and owed + purchase <= creditLimitOf, then _accrueCredit to the merchant and the co-opreversePurchase(...)_transferObligation (RefundOwedByMerchant)repayRefund(member, paid, merchant, merchantShare, coop, coopShare, carryWithheld)payoutPool.payRefund(member, paid − carryWithheld)accrueCarry(member, recipient, amount)_accrueCredit: the member owes more, the recipient holds more. Not limit-checkedsettleClaim(holder, amount)writeOffCreditLine(member, amount)amount of the member's debt to the ledger's own line, adds it to lostDebt(), and zeroes the caller's ceiling allocationOrigination is capital-free: three entries that sum to zero and no reserve token moves. The refund arithmetic is in Refunds and repayments.
Who may write, and the ceiling
onlyCreditIssuer admits a caller only when networkRegistry.isIssuerOf(caller, address(this)).
A transfer requires the sender to be a member, an operator or the payout pool (senderIsMember),
then asks every issuer either party is enrolled with (_validateAcrossIssuers); one refusal reverts
with StableCreditTransactionInvalid. _systemTransfer — opening a line, a write-off, an undirected
repayment — notifies issuers through syncCreditPositions and cannot be refused.
The ceiling is a sum. issuerCreditLimit[issuer][member] is each issuer's part, set by
createCreditLine (which enrols the member and grants the MEMBER role if needed) and
updateCreditLimit (which refuses a total below what is owed). _syncCreditLimit rewrites
creditLimitOf as the sum over networkRegistry.issuersOf(member) on every change.
Repayment
repayCreditBalance(member, amount) (anyone, in reserve token) and credits sent to a member who
owes are undirected: the waterfall below attributes them. repayCreditBalanceFor(payer, member, amount) is issuer-only and directed: the calling issuer records it and no other is told. Reserve
token goes through _routeRepayment: the payout pool takes up to totalSupply() − payoutPool.held(), the AssurancePool (depositRepayment) the rest, or all of it when no payout pool
is set. See settlement.md and reserve.md.
An undirected repayment clears the dearest position first, across issuers.
_settleRepayment asks each issuer for nextRepaymentRate(member), takes the highest, and offers
the remaining amount to every issuer through absorbRepayment(member, remaining, band). Each takes
positions at or above that rate and returns what it took. The ledger repeats for up to
MAX_WATERFALL_BANDS (16) bands. Anything unattributed stays visible as a gap between the ledger
and the issuers rather than being corrected.
Lost debt. The ledger's own address has a credit line of type(uint128).max − 1.
writeOffCreditLine transfers from it to the member, burning their debt and leaving the credits
they spent in circulation. lostDebtOutstanding rises only there and falls only in burnLostDebt,
where a holder gives up credits for AssurancePool.reimburse, capped at lostDebt().
Issuers
CreditIssuer — the base
A credit period (issuedAt, expiration, graceLength, paused) is set by the onlyOperator
initializeCreditLine or updateCreditPeriod.
expiration!inCompliance!inComplianceexpireCreditPeriodThe base inCompliance is creditBalanceOf(member) == 0. A sender spending only what they hold
always passes. expireCreditPeriod deletes the period; in default it also records defaultedAt
(read by hasDefaulted, so the fact outlives the period), calls writeOffCreditLine(member, _writeOffAmount(member)) and _onDefault, which in the base revokes the MEMBER role. On a
defaulted sender's transfer it returns false and the ledger reverts everything, write-off included;
the permissionless syncCreditPeriod(member) runs the same check without a transfer and is the call
that makes a default stick. itdOf and meetsITD expose an income-to-debt ratio that nothing
enforces.
RevolvingIssuer — the tiered line
One balance, one ceiling, composed of tiers ordered by ascending rate. addTier reverts with
RevolvingIssuerRatesMustAscend below the previous tier's rate and RevolvingIssuerCycleMismatch
on a different cycle length; setTierRate keeps the order both ways and checkpoints the index.
setTierActive(false) stops new draws only. openLine sets a capacity per tier, opens the ledger
line at their sum and starts a period of one cycle when periodLength is 0. setTierCapacity
(onlyOperator) reverts below what is drawn and writes the new total to the ledger.
Draw cheapest first, repay dearest first. On a transfer, _syncCreditPositions materialises
both parties' carry, then _draws the sender's shortfall into active tiers in order, each up to
its headroom (capacity − drawnOf, carry included). _absorbRepayment walks the tiers from the
top, skipping any below the band the ledger offered.
Each tier has one CarryIndex. _materialiseCarry — on every interaction, and public as
materialiseCarry(member) — writes accrued carry to the ledger and raises tierPrincipal to match.
Card settlement. A swipe outside the network is paid from the co-op's float and recorded at
clearing, with the whole claim minted to the operator-set cardSettlementAccount and no discount
leg. All four functions require isCardSettler[msg.sender]. settleCardSpend, repayCardSpend and
repayForMember accept a given ref (the card issuer's transaction reference, hashed) once;
reverseCardSpend may be called on a settled ref until its amount is used up.
settleCardSpend(ref, member, amount)amount. _draws the tiers, then originatePurchase(member, amount, cardSettlementAccount, amount, cardSettlementAccount, 0)reverseCardSpend(ref, amount)ref settled. Clears tiers dearest first and reversePurchase from the floatrepayCardSpend(ref, member, amount)amount <= totalPrincipalOf(member) and the float to hold amount, then clears tiers and burns the float's claim against the member's obligation with reversePurchase. No reserve token movesrepayForMember(ref, member, amount)setAutoRepay(true). Pulls the member's USDC with repayCreditBalanceFor(member, member, amount) and clears tiers dearest firstTermIssuer — plans
A plan is a purchase with its own rate, clock and schedule. openPlan (onlyOperator)
reverts unless installments is 1, 2, 4, 6 or 12 (isOfferedSplit), the member is neither
suspended nor behind on any plan, and totalPrincipalOf(member) + purchase <= termLimitOf[member].
It initialises the plan's own CarryIndex, fixes its schedule, and calls originatePurchase(member, purchase, merchant, payout, _feeRecipient(), purchase − payout).
The schedule is fixed when it opens. _fixSchedule sets scheduleTotal with
CarryIndex.scheduleCost — a level annuity over the plan's own index — and installmentAmount
as that total divided by installments, rounded up. installmentsDue runs on wall-clock time.
scheduledPrincipalDue is scheduleFloor + installmentAmount × due, or scheduleFloor + scheduleTotal once every installment is due. arrearsOf is that figure less plan.repaid.
setSplit(planId, installments) re-spreads what is outstanding from now. Arrears are not spread:
they go into scheduleFloor and are due at once. A member's own re-split reverts while behind, after
MAX_SPLIT_CHANGES (3), or within one installmentLength of the last; an operator's does not.
payPlan(planId, amount)repayCreditBalanceFor, then the plan records itcollectForMember(ref, member, amount)mandateSourceautoRepayEnabled is on in the revolving issuer. Pays each plan's arrears in opening order, then residual carry. Never ahead of schedulepayResidualCarry(member, amount)residualCarryOf: what the member owes beyond every open plan and revolving tier. Zero until mandateSource is set_absorbRepayment takes plans at or above the band, oldest firstLate handling. inCompliance is overridden to totalArrearsOf(member) == 0. TermIssuer's own
functions never open a credit period, so falling behind refuses new plans and the member's own
re-splits but not ledger transfers. delinquentSince is the due date of the oldest installment not
yet covered; defaultableAt adds DEFAULT_AFTER_INSTALLMENTS (2) installment lengths, after which
anyone may call declareDefault(member) — see Default.
Refunds. closePlanForRefund(planId, amount, merchant, payout) (onlyOperator) reverses up to
the principal still owed and pays back, in cash, up to what was repaid and not already refunded
(refundedOf). Carry materialised in the call is withheld from the cash, up to the cash. The plan
closes once what is outstanding is no more than that carry.
Fee and carry are separate recipients. The co-op's leg of each purchase is minted to
_feeRecipient() — feeRecipient, or carryTreasury while unset — and refunds burn it from there.
Two lines, not one
A split plan draws only on termLimitOf. The revolving tiers are a separate line.
tierCapacity, written by setTierCapacity / LimitCalculator.pushCapacitiestermLimitOf[member], written by setTermLimit (onlyOperator)LimitCalculator.attest)settleCardSpendopenPlan onlyoriginatePurchase mints rather than transfers, so no issuer's _syncCreditPositions runs and a
plan never touches a tier. LimitCalculator writes only to the revolving issuer it is pointed at.
The ledger, though, sees one number. creditLimitOf is the sum of both allocations, and
originatePurchase also checks creditBalanceOf + purchase <= creditLimitOf, so a plan inside
termLimitOf still reverts with StableCreditCeilingExceeded when the member's combined debt
already fills the combined ceiling. A plain ledger transfer is checked only against that combined
ceiling: _draw places what the tiers have headroom for and does not refuse the rest.
settleCardSpend checks tier headroom itself.
OffersIssuer — the offers line
The co-op owes itself, on a capped line of its own. setCap (owner or operator) opens or
resizes this contract's ledger line: cap() is its creditLimitOf, owed() its creditBalanceOf.
grant(member)cap(). Enrols the member here at a zero limit, then transfers offerAmount from this lineexpire(member)grantedAt + expiryWindow, takes back the unspent part, capped at what the member holds and what the line owes, with reversePurchase(this, amount, member, amount, address(0), 0)settle()treasury up to its allowance and balance (treasuryAvailable) and repays the line with repayCreditBalance, which routes it like any repaymentMembers are enrolled only so _syncCreditPositions can count spending against unspentOf;
_validateCreditTransaction always returns true. It is not exposure: debtByKind returns
nothing, so the line adds nothing to CollateralRegistry.exposureOf.
Carry
Carry is derived from an index, not charged. CarryIndex.currentIndex compounds
1 + ratePerCycle / 10_000 over whole cycles since rateStart and accrues linearly within the
current one, so it reads the same however often it is touched. setRate checkpoints base first.
Rates above MAX_RATE_PER_CYCLE (5,000 bps) revert. Positions store amount / index, rounded up on
draws and on repayment reductions (normalizeUp).
Carry reaches the ledger only when an issuer materialises it through accrueCarry, which is not
limit-checked: a member may come to owe more than their ceiling, and creditLimitLeftOf is then 0.
Where carry goes. A revolving tier's carry is minted to tierCarryRecipient[tier], or the
issuer's carryTreasury when unset; a plan's to TermIssuer.carryTreasury. All are operator-set,
and neither treasury can be zero. What the PayoutPool does with carry it holds is in
settlement.md.
Collateral and the ceiling
A collateralised tier's capacity is the pledge after the same haircut exposure uses.
CollateralRegistry records pledges (pledge, pledgeItem, OPERATOR_ROLE); it does not hold
them. collateralValueOf asks the kind's valuer when one is set — zero if it reverts — and is
otherwise units × unitPrice / 1e18. haircutBps is the realisable share, at most 10,000.
LimitCalculator.capacityOf(member, kind) is collateralValue × haircutBps / 10_000 when anything
is pledged under that kind, else the attested figure (attest, ATTESTOR_ROLE), which takes effect
at its effectiveFrom. pushCapacities(member) is permissionless: it writes every tier through
setTierCapacity, reverts with LimitCalculatorYieldExceedsCarry when collateralYieldOf[kind] is
at or above the tier's rate, and calls refresh(member).
Savings feed the ceiling as the SAVINGS kind. Pledged CLRUSD is valued and haircut like any
other kind. clrusdKind names it, and encumberedOf(holder) returns the units
needed to cover what is drawn under it: drawn × 10_000 / haircutBps, converted to units. CLRUSD
refuses any transfer out of that amount and lets a LIQUIDATOR_ROLE holder seize up to it. The
savings side is in savings.md.
exposureOf(member) — what the pool would pay if the member defaulted on everything — applies
ExposureMath.positionExposure to each issuer's debtByKind: zero for Savings, debt − collateral × haircut floored at zero for AssetExternal and AssetInternal, all of it for
Unsecured. An unregistered kind falls back to ExposureMath.backingOf: anything but SAVINGS,
ASSET_EXTERNAL and ASSET_INTERNAL is unsecured, including term debt (TERM). totalExposure is
a running sum that the permissionless refresh(member) adjusts. release will not free units still
needed; releaseItem will not free anything while the kind is drawn.
deploy/20_deploy_CreditCore.ts adds five tiers in this order — SAVINGS (Savings, 10,000),
BOND (AssetInternal, 9,500, valued by a valuer), POOL_SHARE (AssetInternal, 7,000), INCOME and
BOOST (Unsecured) — and sets clrusdKind to SAVINGS.
Default
flowchart TD
R["Revolving: period expires,<br/>grace over, balance owed"] -->|"expireCreditPeriod"| WR["writeOffCreditLine<br/>min(exposureOf, totalDrawnOf)"]
WR --> OD["_onDefault: tier capacities 0,<br/>MEMBER role revoked"]
OD --> LQ["Liquidator.liquidate<br/>or liquidateShares"]
LQ -->|"seize, redeem"| SF["settleFromCollateral<br/>on the pledged tier"]
T["Term: past defaultableAt"] -->|"declareDefault (anyone)"| WT["writeOffCreditLine<br/>totalPrincipalOf"]
WT --> TS["plans closed, termLimitOf 0,<br/>termSuspended"]
WR --> LD["lostDebt()"]
WT --> LD
LD -->|"burnLostDebt"| AP["AssurancePool.reimburse"]Revolving. _writeOffAmount is CollateralRegistry.exposureOf(member) — which counts every
issuer the member is enrolled with — capped at totalDrawnOf(member), or the whole drawn amount
with no exposure source. _onDefault zeroes tier capacities and revokes the MEMBER role; positions
stay.
Write-offs are attributed like repayments. writeOffCreditLine credits the member through
_systemTransfer, so _settleRepayment offers the written-off amount to the member's issuers by
rate band, dearest first, whichever issuer declared the default. On a revolving-only member that
clears the dearest tiers — the unsecured ones in the deployed configuration — and leaves the savings
tier drawn for liquidation. A term write-off on a member who also holds a dearer revolving tier is
absorbed by that tier first; TermIssuer._onDefault then closes the plans regardless.
Liquidation. Liquidator.liquidate(member, issuer, tierId, kind) (OPERATOR_ROLE) requires
inDefault or hasDefaulted on that issuer. It seizes min(principalOf(member, tierId), CLRUSD balance) with ClearUSD.seize, records the seizure in the registry, redeems the CLRUSD at
ESADepositVault, and calls settleFromCollateral(member, tierId, redeemed). That settles the named
tier through repayCreditBalanceFor, so the waterfall is not told. liquidateShares does the same
with pool shares, redeems what the pool can pay now and queues the rest; settleRealized applies
cash the Liquidator holds to the tier later. The Liquidator has no call that claims its queued
request, and it needs the pool's LIQUIDATOR_ROLE, which no deploy script grants
(Settlement → LendingPool). settleFromSavings lets a member do the same to their own SAVINGS tier at an
issuer the Liquidator trusts, without being in default. The Liquidator needs AccessManager operator
(settleFromCollateral is onlyOperator), the registry's OPERATOR_ROLE and CLRUSD's
LIQUIDATOR_ROLE.
Term. declareDefault materialises carry on every plan, writes off totalPrincipalOf, adds it
to writtenOffOf, closes every open plan, sets termLimitOf to 0 and termSuspended. Membership is
not revoked. repayWrittenOff (anyone) deposits reserve token into the AssurancePool against
writtenOffOf − recoveredOf. reinstate lifts the suspension for anyone once that is fully repaid,
and for an operator at any time; a new setTermLimit is still needed. What the AssurancePool holds
and how reimburse pays is in reserve.md.
Access
StableCreditaccess.isAdmin for setters and UUPS upgrades; NetworkRegistry.isIssuerOf for every issuer entryCreditIssuer and children, CreditPoolstableCredit.access().isOperator; issuer UUPS upgrades by owner()OffersIssuersetCap, setTerms: owner or operator; setTreasury: ownerCollateralRegistry, LimitCalculator, Liquidator, NetworkRegistryOPERATOR_ROLE and DEFAULT_ADMIN_ROLE; LimitCalculator adds ATTESTOR_ROLEMembershipRegistryREGISTRAR_ROLE, PAUSER_ROLEAccessManager.isOperator is the "OPERATOR" role or admin. isMember is the "MEMBER" role, an
operator, or membershipRegistry.isActiveMember (ACTIVE, expiry zero or ahead), so revokeMember
does not remove a registry member. grantOperator is onlyAdmin. grantMember and revokeOperator
pass onlyOperator, but the inner grantRole/revokeRole require the role admin — "OPERATOR" for
"MEMBER", DEFAULT_ADMIN_ROLE for "OPERATOR".
NetworkRegistry.registerIssuer binds an issuer to a ledger, AssurancePool and oracle;
enrolMember is open to the issuer, its ledger or an operator; deregisterIssuer refuses while
anyone is enrolled. deploy/20 makes ClearCredit, both issuers and LimitCalculator AccessManager
operators.