From 8d817bf560aea602bced8a8f2f93b2b17aacae66 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 15:19:44 +0000 Subject: [PATCH 1/3] Let a chain bootstrap a market that can take lending offers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bootstrap-markets only ever granted the SmartCommitmentForwarder, so every market it creates is a pools market. That is the whole reason no chain it has brought up can publish a lending offer: Alpha is trusted nowhere, and TellerV2 reverts with `Forwarder must be trusted by the market`. Arc is the live case — two markets, both pools, offers impossible. The trap is that it cannot be repaired in place. The per-market slot holds one address and setTrustedMarketForwarder overwrites it, so granting Alpha on Arc's `long` would not add offers, it would take the pools off line. Robinhood looks like the exception and is not: its global slot holds Alpha by accident, which leaves each market's own slot free. Everywhere else the two purposes need separate markets, which is what Base does — offers on 22, pools on 18. So a market config gains a `purpose`. `pools` is the default and the existing behaviour; `offers` trusts LenderCommitmentForwarderAlpha instead and has no pools deployed into it, because a pool's initialize() would revert there. Granting moved out of the pools loop into a step of its own, over every market rather than only the ones about to get pools — an offers market needs it just as much and would never have been reached. Still checked before writing, so an earlier run is repaired rather than skipped, and never rewritten when already correct: the slot holds one address, and writing the wrong one to a live market is the failure this is meant to prevent. Arc gets that market, at the same 30 days as `long` so both sides of the chain quote the same loan. No preview catches any of this. preview_pool_launch reports a market that will revert as deployable, because it reads the factory and the oracle route and never simulates the check — which cost three failed launches on Base before the cause was found. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01S1HkrFB3R5NcaNt5tLfXr7 --- .../contracts/config/chain-bootstrap/arc.ts | 18 ++++ .../contracts/config/chain-bootstrap/types.ts | 25 ++++++ .../helpers/tasks/bootstrap-markets.ts | 87 +++++++++++++------ 3 files changed, 103 insertions(+), 27 deletions(-) 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 014c70b96..53a1be94f 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' @@ -195,9 +197,22 @@ 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 forwarders: Record = { + pools: await ( + await hre.contracts.get('SmartCommitmentForwarder') + ).getAddress(), + offers: await ( + await hre.contracts.get('LenderCommitmentForwarderAlpha') + ).getAddress(), + } + const purposeOf = (market: MarketConfig): MarketPurpose => + market.purpose ?? 'pools' + console.log(` pools forwarder ${forwarders.pools}`) + console.log(` offers forwarder ${forwarders.offers}`) const protocolOwner: string = await tellerV2.owner() console.log(` protocol owner ${protocolOwner}`) @@ -273,12 +288,54 @@ 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] + if (!created) continue + const purpose = purposeOf(market) + const forwarder = forwarders[purpose] + const trusted = await tellerV2.isTrustedMarketForwarder( + created.marketId, + forwarder + ) + if (trusted) continue + console.log( + `\n trusting the ${purpose} forwarder for market ${created.marketId}` + ) + console.log(` forwarder ${forwarder}`) + if (args.dryRun) 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`) @@ -290,30 +347,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 From 6273c58d55a40747ef1a738c40a2e57a4ea6db85 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 17:22:58 +0000 Subject: [PATCH 2/3] Look up the offers forwarder only when a market asks for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The purpose split resolved both forwarders at the top of the task, which turns a chain that has no LenderCommitmentForwarderAlpha into a bootstrap that cannot run at all — five of the deployment sets here have no record of it, and none of them declare an offers market. Resolve it on demand instead, and name the missing contract if a market does ask. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01S1HkrFB3R5NcaNt5tLfXr7 --- .../helpers/tasks/bootstrap-markets.ts | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/contracts/helpers/tasks/bootstrap-markets.ts b/packages/contracts/helpers/tasks/bootstrap-markets.ts index 5b84c6ed1..151d4309c 100644 --- a/packages/contracts/helpers/tasks/bootstrap-markets.ts +++ b/packages/contracts/helpers/tasks/bootstrap-markets.ts @@ -213,18 +213,27 @@ task( // 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 forwarders: Record = { + 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: await ( - await hre.contracts.get('LenderCommitmentForwarderAlpha') - ).getAddress(), + offers: wantsOffers + ? await ( + await hre.contracts.get('LenderCommitmentForwarderAlpha') + ).getAddress() + : null, } - const purposeOf = (market: MarketConfig): MarketPurpose => - market.purpose ?? 'pools' console.log(` pools forwarder ${forwarders.pools}`) - console.log(` offers forwarder ${forwarders.offers}`) + if (wantsOffers) console.log(` offers forwarder ${forwarders.offers}`) const protocolOwner: string = await tellerV2.owner() console.log(` protocol owner ${protocolOwner}`) @@ -317,6 +326,12 @@ task( if (!created) 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` + ) + } const trusted = await tellerV2.isTrustedMarketForwarder( created.marketId, forwarder @@ -359,7 +374,6 @@ task( // section leaves the riskiest half of the config unprinted. const marketId = created?.marketId ?? '' - // 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 From 52b12afec3fb2af430bc53b9814443bd36df184d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 04:21:51 +0000 Subject: [PATCH 3/3] Show the forwarder grant in a dry run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trust step keyed off the receipt, and a dry run creates no markets, so it printed nothing at all for a market the run was about to create. That leaves the one step this task adds — which forwarder each market gets — out of the plan the dry run exists to show, on exactly the run somebody would read before signing with the deployer key. It now reports the pending grant with the id as ``, and still throws on a chain whose configured forwarder is not deployed, which is better learned from a dry run than a real one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01S1HkrFB3R5NcaNt5tLfXr7 --- .../helpers/tasks/bootstrap-markets.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/contracts/helpers/tasks/bootstrap-markets.ts b/packages/contracts/helpers/tasks/bootstrap-markets.ts index 151d4309c..fb918c322 100644 --- a/packages/contracts/helpers/tasks/bootstrap-markets.ts +++ b/packages/contracts/helpers/tasks/bootstrap-markets.ts @@ -323,7 +323,12 @@ task( // a working market off line. for (const market of config.markets) { const created = receipt.markets[market.key] - if (!created) continue + // 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) { @@ -332,16 +337,18 @@ task( `deployed on this network` ) } - const trusted = await tellerV2.isTrustedMarketForwarder( - created.marketId, - forwarder - ) + // 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}` + `\n trusting the ${purpose} forwarder for market ${ + created?.marketId ?? '' + }` ) console.log(` forwarder ${forwarder}`) - if (args.dryRun) continue + if (args.dryRun || !created) continue const trustTx = await tellerV2.setTrustedMarketForwarder( created.marketId, forwarder