Skip to the content
TAPEHOUSE

Contracts · Lending

Margin accounts

The contract where you hold collateral and borrow USDG against a portfolio of Stock Tokens, with every external function, its events, roles and SDK calls.

MarginAccounts is where a borrower holds collateral and borrows USDG from the supply vault, which has these accounts as its one borrower. Each address has a cross position and, for any asset it chooses, an isolated position. A position holds Stock Tokens, USDG and WETH, owns a share of the vault's debt, and is margined by the margin engine.

Address#

Robinhood Chain testnet, chain 46630, from deployments/46630.json, under tapehouse.MarginAccounts.

NameAddress
Margin accounts0xcE8C3CB4E1A8b944BC607353055cE5a3D07eC101

Testnet, not audited.

Read a position#

TypeScript
import { readFileSync } from 'node:fs'
import { createPublicClient, http } from 'viem'
import { accounts, CROSS, parseDeployments } from '@tapehouse/sdk'

const deployments = parseDeployments(JSON.parse(readFileSync('deployments/46630.json', 'utf8')))
const client = createPublicClient({ transport: http('https://rpc.testnet.chain.robinhood.com') })
const account = '0xB986a345655097302cac8b4af6C948FfaF7f55FC'

const { equity, requirement, missing, regime } = await accounts.health(client, deployments, account, CROSS)
const price = await accounts.liquidationPrice(client, deployments, account, CROSS, 'TSLA', 50_000_000n)

CROSS is bytes32(0). An isolated position is named by its asset's symbol as bytes32, which toBytes32('TSLA') produces. Equity and requirement are USD with 18 decimals. The liquidation price is USD with 8 decimals.

Positions and checks#

  • An asset sits in one position of an account at a time, because a split holding would pay a smaller liquidity add-on than the whole. An isolated position is margined alone, so it gets no diversification and its loss never reaches the cross position.
  • Stock Tokens are held in raw units and valued at the low edge of their band, which already carries the token's multiplier. A halted band counts for nothing. WETH counts for 80% of its Chainlink price, and for nothing when that price is over a day old. USDG counts at par.
  • Borrowing, and withdrawing from a position in debt, need the position's equity to meet the engine's currentRequirement over its Stock Tokens at those prices. A position without Stock Tokens needs only non-negative equity. A position holding USDG does not borrow: it repays with repayWithCollateral.
  • Deposits and repayments stay open whatever the band, the session and the guardian. Borrowing and withdrawing from a position in debt close for a position holding an asset whose band is halted, whose Stock Token has a multiplier change pending or whose liquidity the engine cannot read; for any position while the L2 sequencer is not settled; and for a position holding Stock Tokens while the band cannot tell the session.
  • After a band was last seen halted or with a multiplier change pending, borrowing and withdrawing against it stay closed for RECOVERY, 30 minutes, and revert with AssetRecovering(symbol, until). observe(symbol) records the sighting.
  • From the regular open on the last trading day before a weekend or holiday close until the reopening, a loan or withdrawal must leave gross exposure to Stock Tokens at most weekendLeverage over equity.
  • While the 24/5 session is closed, each position's debt carries a premium of premiumRate basis points a year on top of the vault's interest. A repayment pays the debt first and the premium after.

Functions#

position is CROSS or an asset's symbol. account is the account's owner. Where the table says "Account or authorized", the caller is the account, an address it passed to setAuthorization, or an agent with an unexpired mandate.

Deposit and withdraw#

FunctionWho may callWhat it doesKey reverts
deposit(bytes32 position, address token, uint256 amount, address account)Anyone for WETH; account or authorized for USDG, Stock Tokens and basket sharesMoves amount of token from the caller into the position. Stock Tokens, held or through a basket, count against their caps.ZeroAmount, UnknownPosition, UnsupportedToken, AssetInOtherPosition, AssetCapExceeded, AssetWrittenOff, Blocked, Unauthorized
depositWithPermit(bytes32 position, address token, uint256 amount, address account, uint256 deadline, uint8 v, bytes32 r, bytes32 s)As depositDeposits with an EIP-2612 permit from the caller for the token. A permit already used does not stop the deposit if the allowance stands.As deposit
withdraw(bytes32 position, address token, uint256 amount, address account, address receiver)Account or authorizedSends collateral to receiver. A position in debt must then pass its checks.InsufficientCollateral, InsufficientMargin, WeekendLeverageExceeded, BorrowingIsPaused, AssetRecovering, AssetFrozen, Blocked, Unauthorized, MandateExpired, MandateReceiver, MandateDebtLimit, MandateLeverageLimit
unwrap(address account, address basket, uint256 shares)The account, an authorized address or the liquidator; anyone once the position falls shortRedeems basket shares in the cross position for the Stock Tokens they hold, which the position then holds. Returns the amounts.UnsupportedToken, Unauthorized, AssetWrittenOff

Borrow and repay#

FunctionWho may callWhat it doesKey reverts
borrow(bytes32 position, uint256 assets, address account, address receiver)Account or authorizedBorrows USDG from the vault to receiver. The position must hold no USDG and must then pass its checks.ZeroAmount, BorrowingIsPaused, BorrowingAgainstUsdg, DebtCapExceeded, WeekendDebtCapExceeded, InsufficientMargin, WeekendLeverageExceeded, SessionUnknown, LiquidityUnknown, SequencerNotSettled, AssetHalted, CorporateActionPending, AssetRecovering, AssetFrozen, Unauthorized, Mandate*
repay(bytes32 position, uint256 assets, address account)AnyoneRepays up to assets of the position's debt with the caller's USDG, then its premium with what is left. Returns what it paid.USDG's own ContractPaused() or AddressFrozen()
repayWithCollateral(bytes32 position, uint256 assets, address account)Account or authorizedRepays with the USDG the position holds. It needs no check and stays open while an asset is halted. Returns what it paid.Unauthorized

Lending the position's Stock Tokens#

FunctionWho may callWhat it doesKey reverts
lend(bytes32 position, address token, uint256 amount, address account)Account or authorizedLends the position's Stock Tokens through the asset's stock lending vault for its shares. A position in debt must then pass its checks.NoLending, UnsupportedToken, InsufficientCollateral, Blocked, BorrowingIsPaused
unlend(bytes32 position, address token, uint256 amount, address account)Account or authorizedTakes lent tokens back into the holding, as far as the vault can return them.OutOfReach(symbol, amount, reachable)
recall(bytes32 position, address token, uint256 amount, address account)Account or authorized, or the liquidatorRecalls lent tokens. What the vault holds free comes back at once and the rest queues as a ticket.NoLending, ZeroAmount, InsufficientCollateral, RecallTooSmall, TooManyRecalls
settle(address account, bytes32 position, address token)AnyoneTakes into the holding what the vault holds for the position's next recall.NoLending, NothingToSettle

Authorization and mandates#

FunctionWho may callWhat it doesKey reverts
setAuthorization(address authorized, bool allowed)Anyone, for its own accountLets authorized borrow, withdraw, repay with collateral and deposit for the caller. Emits AuthorizationSet.None
setMandate(address agent, uint64 expiresAt, uint128 maxDebt, uint32 maxLeverage)Anyone, for its own accountGives agent a bounded authority until expiresAt: it may deposit, repay and lend, and borrow and withdraw only to the account itself while the position's debt is at most maxDebt USDG and its leverage at most maxLeverage, in basis points of at least 10,000. A zero expiresAt ends the mandate.InvalidMandate

An address authorized in full is not bound by a mandate. A call outside a mandate reverts with MandateDebtLimit, MandateLeverageLimit or MandateReceiver, and after expiresAt with MandateExpired.

Permissionless upkeep#

FunctionWhat it does
observe(bytes32 symbol)Records the time when the asset's band is halted or has a multiplier change pending. Reverts with UnknownAsset.
accruePremium()Accrues the premium up to now and records the closure the band's session shows. Returns the premium index.
sync(bytes32 symbol)Brings every position's holding of symbol down in proportion when the issuer has burned from the accounts, and syncs the asset's lending vault. Reverts with UnknownAsset.
clear(address account, bytes32 position, bytes32 symbol)Clears a holding a burn left worth less than a raw unit. Reverts with HoldingNotEmpty.

Liquidator only#

FunctionWhat it does
seize(bytes32 position, address token, uint256 amount, address account, address receiver)Takes collateral from a position, whatever its health, from its holding first and then straight from the lending vault.
writeOff(address account, bytes32 position)Writes off what an empty position owes. Lenders bear it and the premium is forgiven. Returns what it wrote off. Reverts with PositionNotEmpty.
collectFee(uint256 amount)Adds the liquidator's fees to the reserve.

Everything here reverts with NotLiquidator(caller) for any other caller.

Owner, guardian and backstop#

FunctionWho may callWhat it doesKey reverts
setLiquidator(address)Owner, onceSets the one address that seizes and writes off.LiquidatorAlreadySet, InvalidLiquidator
setBackstop(address)Owner, onceSets the address that claims the premium set aside.BackstopAlreadySet, InvalidBackstop
setLending(bytes32 symbol, address vault)Owner, once per assetSets the stock lending vault for an asset.UnknownAsset, LendingAlreadySet, InvalidLending
addBasket(address basket)OwnerAdds a basket whose shares the cross position takes, up to MAX_BASKETS, 8.BasketAlreadyAdded, TooManyBaskets, InvalidBasket
setPremiumRate(uint32 rate)OwnerSets the premium rate, at most 10,000 basis points a year, from now on. Only while the band can tell the session.InvalidPremium, SessionUnknown
withdrawReserve(address receiver, uint256 amount)OwnerSends part of the fee reserve to receiver.InvalidReceiver, InsufficientReserve
setGuardian(address)OwnerSets the address that may pause new borrowing. Zero leaves none.None
setBorrowingPaused(bool paused)GuardianPauses or resumes new borrowing and withdrawals from positions in debt. Deposits, repayments, seizures and write-offs stay open.NotGuardian
claimPremium()BackstopSends the backstop the premium set aside and returns the amount.NotBackstop
transferOwnership(address), acceptOwnership()Owner; pending owner for acceptOwnershipOwnable2Step behaviour.OwnableUnauthorizedAccount
renounceOwnership()NobodyAlways reverts.OwnershipCannotBeRenounced

Views#

FunctionReturns
health(address account, bytes32 position)int256 equity, uint256 requirement, uint8 missing, uint8 regime: equity net of debt, premium and the recall haircut, and the engine's requirement, in USD with 18 decimals
liquidationPrice(address account, bytes32 position, bytes32 symbol, uint256 borrowing)uint256, USD with 8 decimals: the price of one asset at or above which the position meets its requirement once it owes borrowing more USDG, found to within a 2^14th of the current price. Zero where no price leaves it short.
leverage(address account, bytes32 position)uint256, gross exposure over equity in basis points; the largest value for a position without equity
debt(address account, bytes32 position), premium(...), debtShares(...)What a full repayment takes, the premium owed in USDG, and the borrow shares
collateral(address account, bytes32 position, address token)What the position holds of USDG, WETH, a Stock Token or basket shares
mandateStatus(address account, address agent, bytes32 position)uint8 reason, uint256 debtRoom; reason is 0 Ok, 1 None, 2 Expired, 3 DebtLimit, 4 LeverageLimit
mandates(address account, address agent)uint64 expiresAt, uint128 maxDebt, uint32 maxLeverage
isAuthorized(address account, address authorized)bool
stocks()bytes32[] symbols, address[] tokens, in the engine's order
holding(bytes32 symbol)uint256 units, uint256 scale, uint256 cap
lent(...), sellable(...), claim(...), recalls(...), lending(bytes32 symbol)What a position lent, what the liquidator may take now, what it recalled, its open tickets, and the lending vault of an asset
inBaskets(address account, bytes32 position), baskets()What a position holds through its baskets, and the baskets taken
unusableAt(bytes32 symbol)When observe last saw the asset unusable
closure(), closureStart(), premiumRate(), premiumIndex(), totalDebtShares()The premium's closure and accounting
debtCap(), weekendDebtCap(), weekendLeverage(), reserveShare()Constants fixed at deployment
reserve(), backstopPremium(), borrowingPaused(), guardian(), liquidator(), backstop(), owner(), pendingOwner()State and roles
band(), engine(), vault(), usdg(), weth(), ethUsd()The immutables the accounts were built with
RECOVERY(), RECALL_HAIRCUT(), MAX_RECALLS(), MIN_RECALL(), MAX_BASKETS(), WETH_COLLATERAL_BPS(), MAX_FEED_AGE(), MAX_PREMIUM_RATE(), LIQUIDATION_PRICE_STEPS()Constants

Events#

EventEmitted when
Deposit(caller, account, position, token, amount)A deposit.
Withdraw(caller, account, position, token, amount, receiver)A withdrawal.
Borrow(caller, account, position, assets, shares, receiver)A loan.
Repay(caller, account, position, assets, shares)A repayment.
PremiumPaid(account, position, premium, toReserve), PremiumAccrued(index), ClosureSet(closesMs, reopensMs), PremiumRateSet(rate), PremiumClaimed(backstop, amount)The weekend premium.
Lend, Unlend, Recall, LendingSetLending a position's Stock Tokens.
Seize, WriteOff, FeeCollected, ReserveWithdrawn, HoldingClearedLiquidation and the reserve.
AuthorizationSet, MandateSetWho may act for an account.
AssetUnusable(symbol, at), AssetWrittenDown(symbol, counted, balance)A band recovery begins, or a burn lowers every holding of an asset.
LiquidatorSet, BackstopSet, GuardianSet, BorrowingPausedSet, BasketAdded, UnwrapRoles and baskets.
OwnershipTransferStarted, OwnershipTransferredOwnership moves in two steps.

Roles and admin powers#

  • The owner sets the liquidator, backstop and each lending vault once, adds baskets, sets the premium rate (at most 100% a year), withdraws the fee reserve and names the guardian. It cannot move a position's collateral, and the band, engine, vault, caps and reserve share are fixed at deployment.
  • The guardian can pause new borrowing and withdrawals from positions in debt. Deposits, repayments, seizures and write-offs stay open.
  • The liquidator is the only address that seizes collateral and writes off debt.
  • The backstop claims the premium set aside for it. Of each premium paid, reserveShare goes to the fee reserve and the rest is set aside for the backstop.
  • USDG's and Stock Tokens' issuer can pause tokens, blocklist addresses and burn balances. A paused Stock Token, or one whose issuer has blocklisted the accounts, backs no new risk (AssetFrozen). If the issuer burns from the accounts, every holding of the asset falls in proportion once sync runs.

Invariants#

  • Each position's debt is a share of the vault's debt, kept as borrow shares with Morpho Blue's virtual offsets. Only the accounts repay the vault.
  • The accounts hold each asset up to a cap fixed at deployment, and the positions' total debt is capped by debtCap, with weekendDebtCap bounding a new loan before and across a closure.
  • Collateral USDG is never lent.
  • A write-off retires the position's borrow shares and the vault writes the same amount off, so lenders bear it and no other position's debt moves.

SDK#

TypeScript, module accounts in @tapehouse/sdk. Transactions return viem contract calls for simulateContract and writeContract.

Contract functionSDK call
setAuthorizationaccounts.setAuthorization(deployments, authorized, allowed)
setMandateaccounts.setMandate(deployments, { agent, expiresAt, maxDebt, maxLeverage })
depositaccounts.deposit(deployments, { position, token, amount, account })
depositWithPermitaccounts.depositWithPermit(deployments, { position, token, amount, account, deadline, signature })
WETH for depositaccounts.wrap(deployments, { account, value })
withdrawaccounts.withdraw(deployments, { position, token, amount, account, receiver })
borrowaccounts.borrow(deployments, { position, assets, account, receiver })
repayaccounts.repay(deployments, { position, assets, account })
unwrapaccounts.unwrap(deployments, { account, basket, shares }), with the basket's registry name
lend, unlend, recallaccounts.lend, accounts.unlend, accounts.recall, each with { position, token, amount, account }
settleaccounts.settle(deployments, { account, position, token })
observeaccounts.observe(deployments, { asset })
healthaccounts.health(client, deployments, account, position)
liquidationPriceaccounts.liquidationPrice(client, deployments, account, position, asset, borrowing)
leverage, collateral, isAuthorizedaccounts.leverage, accounts.collateral, accounts.isAuthorized
mandates, mandateStatusaccounts.mandate(client, deployments, { account, agent }), accounts.mandateStatus(client, deployments, { account, agent, position })
debt and premiumaccounts.repayment(client, deployments, account, position), read at one block
stocksaccounts.stocks(client, deployments), and accounts.positions for every position an account may hold
lent, sellable, claim, recallsaccounts.lending(client, deployments, account, position, token)
holdingaccounts.holding(client, deployments, asset, token)
debtCap, weekendDebtCap, weekendLeverage, borrowingPaused, premiumRateaccounts.limits(client, deployments)
unusableAt, RECOVERYaccounts.recovery(client, deployments, asset)
inBasketsaccounts.inBaskets(client, deployments, account, position)

repayWithCollateral, accruePremium, sync, clear and the owner, guardian and liquidator functions have no wrapper: use marginAccountsAbi, which the package exports.

Next#