diff --git a/packages/contracts/config/chain-bootstrap/arc.ts b/packages/contracts/config/chain-bootstrap/arc.ts index 1f7aba7d5..c0511c394 100644 --- a/packages/contracts/config/chain-bootstrap/arc.ts +++ b/packages/contracts/config/chain-bootstrap/arc.ts @@ -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 diff --git a/packages/contracts/config/chain-bootstrap/types.ts b/packages/contracts/config/chain-bootstrap/types.ts index 1aa5818f9..1ae6103b9 100644 --- a/packages/contracts/config/chain-bootstrap/types.ts +++ b/packages/contracts/config/chain-bootstrap/types.ts @@ -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. */ diff --git a/packages/contracts/helpers/tasks/bootstrap-markets.ts b/packages/contracts/helpers/tasks/bootstrap-markets.ts index 3a241c5f8..fb918c322 100644 --- a/packages/contracts/helpers/tasks/bootstrap-markets.ts +++ b/packages/contracts/helpers/tasks/bootstrap-markets.ts @@ -8,6 +8,8 @@ import { HardhatRuntimeEnvironment } from 'hardhat/types' import { ChainBootstrapConfig, MARKET_FEE_PERCENT, + MarketConfig, + MarketPurpose, PROTOCOL_FEE_BPS, } from '../../config/chain-bootstrap/types' @@ -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 = { + 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}`) @@ -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 ?? '' + }` + ) + 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`) @@ -302,31 +381,6 @@ task( // section leaves the riskiest half of the config unprinted. const marketId = created?.marketId ?? '' - // 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