Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/contracts/config/chain-bootstrap/arc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@ const config: ChainBootstrapConfig = {
paymentDefaultDuration: 5 * 60,
bidExpirationTime: 24 * 60 * 60,
},
{
// Arc's two markets above both spend their forwarder slot on the
// SmartCommitmentForwarder, and Arc's global slot holds the plain
// LenderCommitmentForwarder, so LenderCommitmentForwarderAlpha is
// trusted nowhere on this chain and no lending offer can be published
// into it. The slot holds one address, so granting Alpha on `long`
// would not add offers — it would take the pools off line.
//
// Hence a market of its own, which is what Base does: offers on market
// 22, pools on 18. Same 30-day term as `long` so the two sides of the
// chain quote the same loan.
key: 'offers',
label: '30 Day Offers',
purpose: 'offers',
durationSeconds: 30 * 24 * 60 * 60,
paymentDefaultDuration: 5 * 60,
bidExpirationTime: 24 * 60 * 60,
},
],

// ARGUS is only listed on the seven-day market, so that is the one whose
Expand Down
25 changes: 25 additions & 0 deletions packages/contracts/config/chain-bootstrap/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,34 @@ export const MARKET_FEE_PERCENT = 100
/** Protocol fee taken by TellerV2, in basis points. 10000 == 100%, so 5 == 5bps. */
export const PROTOCOL_FEE_BPS = 5

/**
* What a market is for, which decides the one forwarder its slot holds.
*
* TellerV2 trusts a forwarder on a market when the market's own slot names
* it, or when it is the global `lenderCommitmentForwarder`. The per-market
* slot holds exactly one address and `setTrustedMarketForwarder` overwrites
* it, so a market cannot serve both pools and offers unless the chain's
* global slot happens to hold the other one. Only Robinhood is like that, by
* accident — see config/global-commitment-forwarder.ts.
*
* Everywhere else the two need separate markets, which is why Base runs
* offers on market 22 and pools on 18. A chain bootstrapped with pools
* markets alone can never publish a lending offer: every attempt reverts
* with `Forwarder must be trusted by the market`, and no preview catches it,
* because a preview does not simulate the check.
*/
export type MarketPurpose = 'pools' | 'offers'

export interface MarketConfig {
/** Stable key used to name the market in the bootstrap receipt. */
key: string
/**
* Which forwarder this market trusts. Defaults to `pools`, the historical
* behaviour: the SmartCommitmentForwarder, which LenderCommitmentGroup
* pools call through. `offers` trusts LenderCommitmentForwarderAlpha
* instead and gets no pools deployed into it.
*/
purpose?: MarketPurpose
/** Human label, also used to build the market URI. */
label: string
/** Loan term in seconds. Doubles as the payment cycle for bullet loans. */
Expand Down
110 changes: 82 additions & 28 deletions packages/contracts/helpers/tasks/bootstrap-markets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { HardhatRuntimeEnvironment } from 'hardhat/types'
import {
ChainBootstrapConfig,
MARKET_FEE_PERCENT,
MarketConfig,
MarketPurpose,
PROTOCOL_FEE_BPS,
} from '../../config/chain-bootstrap/types'

Expand Down Expand Up @@ -207,9 +209,31 @@ task(
const factory = (await hre.contracts.get(
'LenderCommitmentGroupFactory_V2'
)) as unknown as PoolFactoryLike
const forwarder = await (
await hre.contracts.get('SmartCommitmentForwarder')
).getAddress()
// One forwarder per purpose. A market's slot holds a single address and
// setTrustedMarketForwarder overwrites it, so which one a market gets is
// decided once, at creation, and is the whole of what separates an
// offers market from a pools market. See MarketPurpose.
const purposeOf = (market: MarketConfig): MarketPurpose =>
market.purpose ?? 'pools'
const wantsOffers = config.markets.some((m) => purposeOf(m) === 'offers')

// Looked up only when a market asks for it. LenderCommitmentForwarderAlpha
// is not deployed on every chain this task can run against — five of the
// deployment sets have no record of it — and resolving it unconditionally
// would fail the whole bootstrap on those chains over a market they do not
// declare.
const forwarders: Record<MarketPurpose, string | null> = {
pools: await (
await hre.contracts.get('SmartCommitmentForwarder')
).getAddress(),
offers: wantsOffers
? await (
await hre.contracts.get('LenderCommitmentForwarderAlpha')
).getAddress()
: null,
}
console.log(` pools forwarder ${forwarders.pools}`)
if (wantsOffers) console.log(` offers forwarder ${forwarders.offers}`)

const protocolOwner: string = await tellerV2.owner()
console.log(` protocol owner ${protocolOwner}`)
Expand Down Expand Up @@ -285,12 +309,67 @@ task(
console.log(` -> market id ${marketId} (${rcpt?.hash ?? tx.hash})`)
}

// ---- Forwarder trust ---------------------------------------------------
//
// Creating a market does not grant it, and only the market owner can, so
// until this runs every pool deploy into a pools market reverts with
// `Forwarder must be trusted by the market` — and so does every lending
// offer published into an offers market.
//
// Checked rather than assumed, so a market created by an earlier run is
// repaired rather than skipped. Never re-granted when already correct:
// the slot holds one address, so writing it again on a market that has
// the right forwarder is a no-op, and writing the wrong one would take
// a working market off line.
for (const market of config.markets) {
const created = receipt.markets[market.key]
// A market the run has not created. On a real run that means an earlier
// step failed and there is nothing to grant yet. On a dry run it is the
// normal case for every new market, and skipping quietly would leave the
// grant — the step this whole task turns on — absent from the plan the
// dry run exists to show.
if (!created && !args.dryRun) continue
const purpose = purposeOf(market)
const forwarder = forwarders[purpose]
if (!forwarder) {
throw new Error(
`market ${market.key} wants the ${purpose} forwarder, which is not ` +
`deployed on this network`
)
}
// Only a market that exists can be asked whether it already trusts one.
const trusted = created
? await tellerV2.isTrustedMarketForwarder(created.marketId, forwarder)
: false
if (trusted) continue
console.log(
`\n trusting the ${purpose} forwarder for market ${
created?.marketId ?? '<pending>'
}`
)
console.log(` forwarder ${forwarder}`)
if (args.dryRun || !created) continue
const trustTx = await tellerV2.setTrustedMarketForwarder(
created.marketId,
forwarder
)
const trustRcpt = await trustTx.wait()
console.log(` -> ${trustRcpt?.hash ?? trustTx.hash}`)
}

// ---- Pools -------------------------------------------------------------
//
// One pool per collateral per market: a pool pins a single marketId and a
// single maxLoanDuration, so a 7 day and a 30 day offer against the same
// collateral are two separate pools.
for (const market of config.markets) {
// An offers market trusts Alpha, not the SmartCommitmentForwarder, so a
// pool's initialize() would revert in it. It exists to be published
// into, not deployed into.
if (purposeOf(market) === 'offers') {
console.log(`\n no pools for ${market.key}: it is an offers market`)
continue
}
const created = receipt.markets[market.key]
if (!created && !args.dryRun) {
console.log(`\n skipping pools for ${market.key}: market not created`)
Expand All @@ -302,31 +381,6 @@ task(
// section leaves the riskiest half of the config unprinted.
const marketId = created?.marketId ?? '<pending>'

// A pool's initialize() calls approveMarketForwarder on TellerV2, which
// reverts with "Forwarder must be trusted by the market" unless the
// market already trusts the SmartCommitmentForwarder. Only the market
// owner can grant that, and creating a market does not grant it, so
// every pool deploy fails until this runs. Checked rather than assumed,
// so a market created by an earlier run is repaired rather than skipped.
if (created) {
const trusted = await tellerV2.isTrustedMarketForwarder(
created.marketId,
forwarder
)
if (!trusted) {
console.log(
`\n trusting the forwarder for market ${created.marketId}`
)
console.log(` forwarder ${forwarder}`)
const trustTx = await tellerV2.setTrustedMarketForwarder(
created.marketId,
forwarder
)
const trustRcpt = await trustTx.wait()
console.log(` -> ${trustRcpt?.hash ?? trustTx.hash}`)
}
}

// Both directions in one list. An ordinary pool lends the chain's
// principal against an asset; an inverse pool lends the asset against the
// principal. Everything after this point is identical for the two, so
Expand Down
Loading