Savings and send
USDC goes into ESADepositVault and CLRUSD comes out one-for-one; CLRUSD goes back in and the
same USDC comes out. The rest of the savings layer is built around those two calls: gasless ways
to reach them, a lock that stops CLRUSD backing drawn credit from leaving, and the escrow behind
link-based send.
On this page
Scope: ClearUSD, ClearUSDUpgradeable, ESADepositVault, SavingsIntentFactory,
SavingsIntentEscrow, ClaimEscrow, and the CCIP pool for CLRUSD. Addresses are in the root
README under Deployed addresses.
ClearUSDBurnMintERC20, 6 decimals, constructor-deployed, not upgradeable, no lockClearUSDUpgradeableBurnMintERC20UUPS, 6 decimals, carries the redemption lock and seizeESADepositVaultSavingsIntentFactorySavingsIntentEscrowClaimEscrowBurnMintTokenPoolcontracts/; deployed from @chainlink/contracts-ccip by deploy/15_deploy_CLRUSDTokenPool.tsCLRUSD
Both tokens are named "Clear USD", symbol CLRUSD, 6 decimals, with an optional maxSupply that
mint enforces when non-zero. Neither pays anything to holders; there is no accrual logic in either
contract.
Two tokens, one vault at a time
ClearUSD cannot be upgraded, so it could not be given the lock. ClearUSDUpgradeable replaces it
rather than changing it, and ESADepositVault.setClrusd is how the vault moves from one to the other.
setClrusd refuses while any of the old token exists. It reads totalSupply() on the token the
vault currently points at and reverts with ESADepositVaultTokenStillOutstanding if it is non-zero.
It then caches the new token's decimals() beside the address, because every conversion reads that
cache. The order is fixed by the guard: redeem the old supply to zero, then re-point.
The check reads supply on the vault's own chain. CLRUSD bridged out through a burn/mint pool is burned here, so it does not count toward that figure.
Roles
ClearUSDClearUSDUpgradeableDEFAULT_ADMIN_ROLEgrantIssuerRoles / revokeIssuerRoles (mint + burn together); setCCIPAdminsetEncumbranceSource; setCCIPAdminMINTER_ROLEmintmintBURNER_ROLEburn(amount), burnFrom (spends the holder's allowance)UPGRADER_ROLE_authorizeUpgradeLIQUIDATOR_ROLEseizeClearUSD's constructor hands DEFAULT_ADMIN_ROLE and the CCIP admin slot to the admin argument
and revokes the deployer's admin role if they differ. ClearUSDUpgradeable takes defaultAdmin and
defaultUpgrader in the inherited initialize.
ClearUSDUpgradeable inherits OpenZeppelin's AccessControlDefaultAdminRulesUpgradeable, so
handing over DEFAULT_ADMIN_ROLE is a two-step begin/accept. The base initializer calls plain
__AccessControl_init, which leaves defaultAdminDelay() at zero until changeDefaultAdminDelay
is called. deploy/21_deploy_ClearUSDUpgradeable.ts schedules a delay after deployment.
The redemption lock
ClearUSDUpgradeable._update refuses to let a holder's balance drop below what is encumbered.
Every outflow passes through _update: transfers, burnFrom during a vault redemption, and the
transfer into the CCIP pool. Minting has no from and is exempt, so an encumbered holder can still receive.
freeBalanceOf(holder)balanceOf(holder) minus encumberedOf(holder), floored at zero_update(from, to, value)ClearUSDEncumbered if from is non-zero and value > freeBalanceOf(from)_encumberedOf(holder)IEncumbranceSource. Returns zero if no source is set, or if the call revertssetEncumbranceSource(source)A fresh ClearUSDUpgradeable has no source and locks nothing. A source that reverts also locks
nothing: _encumberedOf catches the revert and returns zero.
Seizure
seize(holder, to, amount) is the one move allowed past the lock, and only up to the lock. It
requires LIQUIDATOR_ROLE, reverts with ClearUSDSeizureExceedsEncumbrance if amount exceeds
_encumberedOf(holder), and then calls the parent _update directly, skipping the check. It needs no
allowance from the holder. It cannot reach a free balance, and a holder with nothing encumbered has
nothing seizable.
seize does not decide whether a default has happened. Its caller is Liquidator; see the next
section and Credit.
Savings as credit collateral
The encumbrance source is CollateralRegistry, and what it locks follows what is drawn, not what is
pledged. CollateralRegistry.encumberedOf(holder) reads the kind named by clrusdKind
(setClrusdKind, OPERATOR_ROLE), and returns the units of that kind needed to cover the principal
the holder has drawn under it across their issuers (_requiredUnits, from the kind's haircutBps
and unitPrice). It returns zero if clrusdKind is unset or unregistered, and zero for a holder
who has drawn nothing. It does not read pledgedOf.
Pledges are recorded by CollateralRegistry.pledge and freed by release, both OPERATOR_ROLE.
release refuses to free units that freeCollateralOf says are backing drawn credit. The registry
holds no CLRUSD; the member's account does, and the token enforces the lock.
Liquidator uses seize in two places. liquidate (OPERATOR_ROLE) checks that the issuer reports
the member in default, seizes up to the tier's outstanding principal, records the seizure in the
registry, redeems the CLRUSD through ESADepositVault.redeem and settles the tier.
settleFromSavings is the member-initiated version against a trusted issuer's savings tier. Both
are bounded by the token to what is encumbered. The rest is in Credit.
ESADepositVault
The vault holds the accepted reserve tokens and nothing else. Its only outflow of reserve is
_executeRedeem; there is no sweep or admin withdrawal. It can be upgraded by DEFAULT_ADMIN_ROLE
(_authorizeUpgrade).
Deposit and redeem
deposit(token, amount, receiver) pulls amount of an accepted token from the caller and mints
CLRUSD to receiver. redeem(token, clrusdAmount, receiver) burns the caller's CLRUSD with
burnFrom and sends the reserve token to receiver. Both are whenNotPaused.
Conversion is by decimals only (_convertToClrUsd, _convertFromClrUsd). With USDC and CLRUSD both
at six, the amounts are equal. For a token with more decimals, both directions revert with
ESADepositVaultNonExactAmount unless the amount converts back exactly, so no dust is rounded away.
previewDeposit and previewRedeem return the converted figure without those checks.
A redemption names the token it wants back and reverts with ESADepositVaultInsufficientLiquidity if
the vault holds less of it than the conversion requires. burnFrom spends an allowance, so the
redeemer must have approved the vault for at least clrusdAmount. Under ClearUSDUpgradeable the
burn also passes the lock, so encumbered CLRUSD cannot be redeemed.
The vault needs MINTER_ROLE and BURNER_ROLE on whichever CLRUSD it points at.
Gasless paths
Each of these is OPERATOR_ROLE and whenNotPaused. The relayer holding OPERATOR_ROLE pays gas;
the member signs.
depositWithAuthorizationreceiveWithAuthorization on the reserve token, payee = vaultredeemWithAuthorizationRedeem(redeemer, token, clrusdAmount, receiver, nonce, deadline)redeemNonces[redeemer], used in order; deadlineexecuteMandateDepositDepositMandate(depositor, token, amountPerRun, interval, maxRuns, startAt, expiry, nonce)depositMandates[digest] run countredeemWithAuthorization recovers an ECDSA signer and requires it to equal redeemer. It still
burns through burnFrom, so the redeemer needs a standing CLRUSD allowance to the vault.
executeMandateDeposit is autopay: one signature authorizes up to maxRuns deposits of
amountPerRun, each pulled with safeTransferFrom against an allowance the depositor has already
granted, and minted to the depositor. The signature is checked with SignatureChecker, so EOAs and
EIP-1271 contract wallets both work. Run i is allowed from startAt + interval * i until expiry,
so runs that fell behind can execute back to back. cancelMandate(digest) is OPERATOR_ROLE; a
depositor can also stop it by removing the allowance.
Roles
DEFAULT_ADMIN_ROLEsetClrusd, upgrades, role grantsOPERATOR_ROLEsetAcceptedToken, the three gasless functions, cancelMandatePAUSER_ROLEpause, unpausedeposit, redeem (for their own CLRUSD), previewsinitialize gives all three roles to admin. Pausing stops deposits, redemptions and mandate runs;
it does not stop setAcceptedToken, setClrusd or cancelMandate.
Savings intents
A member funds an address before any contract exists there, with one ordinary transfer.
SavingsIntentFactory.predictIntentAddress(salt) returns the EIP-1167 clone address for a salt. The
member sends USDC (to save) or CLRUSD (to withdraw) to it. A settler later deploys the escrow and
runs the vault call in one transaction.
flowchart LR
M["Member"] -->|"one transfer"| P["Predicted escrow address"]
S["Settler (SETTLER_ROLE)"] -->|"settleDeterministic(salt, config)"| F["SavingsIntentFactory"]
F -->|"clone + initialize"| P
F -->|"finalize()"| P
P -->|"deposit or redeem"| V["ESADepositVault"]
V -->|"CLRUSD or USDC"| R["receiver"]IntentConfig carries depositor, receiver, transferToken (what the member sent), vaultToken
(the reserve token passed to the vault), vault, amount, expiry and action (0 deposit,
1 redeem).
createIntentDeterministicCREATOR_ROLEsettleDeterministicSETTLER_ROLESavingsIntentEscrow.finalizerefundDeterministicSETTLER_ROLESavingsIntentEscrow.refundThe salt is not derived from the config. The first provisioning call initializes the clone with
whatever config it passes; every later call must match that config field for field
(_assertConfigMatches). Neither contract checks vault against a known vault.
The escrow accepts calls only from its factory (onlyFactory).
finalizerequires statusPendingand a balance of exactlyamount(SavingsIntentEscrowFundingIncompleteotherwise). It approves the vault and callsdepositorredeemas itself, so the vault sees the escrow as caller. It does not checkexpiry.refundrequires statusPendingandblock.timestamp > expiry, and returns the escrow's whole balance oftransferTokentodepositor.
initialize rejects an expiry that has already passed, and refund rejects one that has not. An
escrow must therefore be provisioned (by createIntentDeterministic or a settle attempt) before its
expiry for refundDeterministic to succeed later. A balance that is not exactly amount cannot be
finalized and leaves only through refund.
Both factory roles and DEFAULT_ADMIN_ROLE go to admin in the constructor. The factory is not
upgradeable; the escrow implementation is deployed inside its constructor.
Link-based send
ClaimEscrow holds a sender's USDC under a transferId until a settler releases it or it expires.
It holds only the USDC set at initialize. PAYOUT_TREASURY is also set there and has no setter.
flowchart LR
C["createTransfer / createTransferWithAuthorization"] --> L["Locked: principal + sponsor fee"]
L -->|"claimToWallet (SETTLER_ROLE)"| W["principal to recipient, fee to PAYOUT_TREASURY"]
L -->|"claimToPayoutTreasury (SETTLER_ROLE)"| T["total to PAYOUT_TREASURY"]
L -->|"refundExpired (anyone, after expiry)"| B["total back to sender"]_record requires a non-zero, unused transferId, a non-zero principal and an expiry in the future,
and stores principalUsdc + sponsorFeeUsdc as totalLockedUsdc. The recipientHintHash is stored
and emitted; no function reads it.
createTransfermsg.sendercreateTransferWithAuthorizationSETTLER_ROLEsender and pulls the total with EIP-3009 receiveWithAuthorizationclaimToWalletSETTLER_ROLErecipientWallet, sponsor fee to PAYOUT_TREASURYclaimToPayoutTreasurySETTLER_ROLEPAYOUT_TREASURYrefundExpiredexpiry, the whole total back to the recorded senderEach record ends once: claims and refund all require !claimed && !refunded. The claim functions do
not check expiry, so a settler can still claim an expired transfer that nobody has refunded.
refundExpired pays only the recorded sender, whoever calls it.
initialize grants DEFAULT_ADMIN_ROLE and SETTLER_ROLE to admin. DEFAULT_ADMIN_ROLE authorizes
upgrades. The contract has no pause.
Cross-chain
CLRUSD crosses chains through Chainlink CCIP with a BurnMintTokenPool. On the way out the pool
calls the token's burn(amount) on what the router sent it; on the way in it calls mint. It needs
MINTER_ROLE and BURNER_ROLE on the token. Under ClearUSDUpgradeable the member's transfer to the
pool passes _update, so encumbered CLRUSD cannot be bridged.
The pool's token is fixed at construction. deploy/15_deploy_CLRUSDTokenPool.ts binds it to the
ClearUSD deployment record, or to CLRUSD_ADDRESS, so a pool for ClearUSDUpgradeable is a
separate deployment.
Not on-chain
Equity credits are not recorded by any contract; no contract under contracts/ refers to them.
Savings in this layer are CLRUSD balances and the reserve in the vault.