Skip to content

Let a chain bootstrap a market that can take lending offers - #355

Merged
rbcp18 merged 4 commits into
developfrom
claude/stoic-mccarthy-9a1ddp
Sep 22, 2026
Merged

rbcp18 merged 4 commits into
developfrom
claude/stoic-mccarthy-9a1ddp

Conversation

@rbcp18

@rbcp18 rbcp18 commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

The problem

bootstrap-markets only ever granted the SmartCommitmentForwarder, so every market it has ever created is a pools market. LenderCommitmentForwarderAlpha is trusted nowhere it has been, which is why no chain it brought up can publish a lending offer: TellerV2 reverts with Forwarder must be trusted by the market.

Arc is the live case. Two markets, both pools, offers impossible.

Why it cannot be repaired in place

The per-market slot holds one address and setTrustedMarketForwarder overwrites it. 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 forwarder slot happens to hold Alpha, which leaves each market's own slot free for the pool forwarder. That is an accident of its deploy, not a pattern to copy. Everywhere else the two purposes need separate markets, which is what Base already does by hand: offers on market 22, pools on 18.

The change

A market config gains purpose:

  • pools is the default and is exactly today's behaviour.
  • offers trusts LenderCommitmentForwarderAlpha instead, and has no pools deployed into it — a pool's initialize() calls approveMarketForwarder and 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 the grant just as much and would never have been reached. It is still checked before writing, so a market created by an earlier run is repaired rather than skipped, and never re-granted when already correct — the slot holds one address, and writing the wrong one to a live market is the failure this exists to prevent. The new step honours --dry-run, which the old inline one did not.

Arc gets an offers market at the same 30 days as long, so both sides of the chain quote the same loan.

The Alpha lookup is lazy: five of the deployment sets here (clarity, goerli, mantle, mantle-testnet, sepolia) have no record of that contract, and resolving it unconditionally at the top of the task would fail the whole bootstrap on chains that never declared an offers market. A market that does ask for it and cannot get it throws with the reason named.

Verified

TellerV2.isTrustedMarketForwarder(marketId, forwarder) is a public view, so the premise was read off the chains rather than assumed. Every market Teller Pro's on-demand lending targets was swept against both forwarders; Arc is the only one of the eight with no market that trusts Alpha, and both of its markets trust the pool forwarder — which is what makes the separate-market approach necessary rather than a repair.

deployments/arc/LenderCommitmentForwarderAlpha.json carries 0x2ac0Cb7b93E9E8c205Ee51552298BB4249CD4136, so the lookup resolves there.

Note for whoever runs it

Per the deployer's own rules, a run that writes pushes artifacts and that push has to fast-forward the artifact branch — so this needs to be on develop before a real BOOTSTRAP_MARKETS run, and the deployer image repinned to the merge commit. A --dry-run is safe against any pin.

🤖 Generated with Claude Code

https://claude.ai/code/session_01S1HkrFB3R5NcaNt5tLfXr7


Generated by Claude Code

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1HkrFB3R5NcaNt5tLfXr7
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1HkrFB3R5NcaNt5tLfXr7
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 `<pending>`, 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1HkrFB3R5NcaNt5tLfXr7
@rbcp18
rbcp18 merged commit df97964 into develop Sep 22, 2026
2 checks passed
@rbcp18
rbcp18 deleted the claude/stoic-mccarthy-9a1ddp branch September 22, 2026 04:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants