Skip to content

feat(contracts): forward legacy ABI to old impl via fallback#2020

Draft
jonastheis wants to merge 9 commits into
jonas/router-decouplingfrom
jonas/market-legacy-fallback
Draft

feat(contracts): forward legacy ABI to old impl via fallback#2020
jonastheis wants to merge 9 commits into
jonas/router-decouplingfrom
jonas/market-legacy-fallback

Conversation

@jonastheis
Copy link
Copy Markdown
Contributor

@jonastheis jonastheis commented May 28, 2026

Summary

The router-decoupling rework on jonas/router-decoupling replaces the
market's pre-router fulfillment ABI with a slimmer FulfillmentBatch[]
shape and routes verification through BoundlessRouter. Old brokers will
keep submitting the original (Fulfillment[], AssessorReceipt) shape for
the duration of the broker rollout, so the deployed market must continue
to accept legacy calls without us rewriting or re-auditing the old
fulfillment logic.

This branch wires up a fallback() external payable on the new market
that delegate-calls into the already-deployed audited implementation
at the proxy's pre-upgrade impl address. Nothing new is written for the
legacy ABI surface — the audited bytecode handles it byte-identically to
pre-upgrade, just delegate-called. The only new on-chain code is a ~50 B
inline-assembly shim plus a LEGACY_IMPL constructor immutable.

A frozen copy of the audited source lives at contracts/src/legacy/, used
exclusively for tests and CI parity invariants — production deployments
point LEGACY_IMPL at the existing impl on Base mainnet. The legacy
sources mirror main at commit
507f7469
(BM-2598: add depositCollateralTo and depositCollateralWithPermitTo,
the last commit on main that touched any of the files in this tree).
The only deltas are file-basename + import renames; contract bodies are
unchanged.

Architecture

                ┌──────────────────────────────────────┐
                │  Proxy (BoundlessMarket, address P)  │
                │  unchanged across the upgrade        │
                └──────────────┬───────────────────────┘
                               │
                               ▼
        ┌──────────────────────────────────────────────────────────┐
        │  NEW impl (this branch)                                  │
        │                                                          │
        │  Selectors handled directly:                             │
        │    fulfill(FulfillmentBatch[])                           │
        │    fulfillAndWithdraw(FulfillmentBatch[])                │
        │    priceAndFulfill(SlimRequest[], FulfillmentBatch[])    │
        │    priceAndFulfillAndWithdraw(...)                       │
        │    submitRootAndFulfill(addr,bytes32,bytes,batch[])      │
        │    submitRootAndFulfillAndWithdraw(...)                  │
        │    submitRootAndPriceAndFulfill(...)                     │
        │    submitRootAndPriceAndFulfillAndWithdraw(...)          │
        │    initialize(address)                                   │
        │    LEGACY_IMPL(), ROUTER()                               │
        │                                                          │
        │  Selectors shared with legacy → also handled here:       │
        │    lockRequest, lockRequestWithSignature, priceRequest   │
        │    slash, withdraw, submitRequest, submitRoot            │
        │    deposit, depositTo, depositCollateral*, withdrawColl. │
        │    accounts, balanceOf, balanceOfCollateral              │
        │    requestIsLocked, requestIsFulfilled, requestIsSlashed │
        │    requestDeadline, requestLockDeadline, requestLocks    │
        │    eip712Domain, eip712DomainSeparator                   │
        │    COLLATERAL_TOKEN_CONTRACT                             │
        │    ADMIN_ROLE, grantRole, hasRole, ..., proxiableUUID    │
        │                                                          │
        │  fallback() → delegatecall(LEGACY_IMPL, msg.data)        │
        └──────────────────────────┬───────────────────────────────┘
                                   │ msg.sender, msg.value, proxy
                                   │ storage all preserved
                                   ▼
        ┌──────────────────────────────────────────────────────────┐
        │  LEGACY impl                                             │
        │  Base mainnet: 0x22bb6bbe5d221ef3e738029dab4d1d27ec725cd3│
        │  (the impl pointed to by the proxy before this upgrade)  │
        │                                                          │
        │  Selectors handled here (reached only via fallback):     │
        │    fulfill(Fulfillment[], AssessorReceipt)               │
        │    fulfillAndWithdraw(Fulfillment[], AssessorReceipt)    │
        │    priceAndFulfill(ProofRequest[], bytes[],              │
        │                    Fulfillment[], AssessorReceipt)       │
        │    priceAndFulfillAndWithdraw(...)                       │
        │    submitRootAndFulfill(addr,bytes32,bytes,              │
        │                         Fulfillment[], AssessorReceipt)  │
        │    submitRootAndFulfillAndWithdraw(...)                  │
        │    submitRootAndPriceAndFulfill(...)                     │
        │    submitRootAndPriceAndFulfillAndWithdraw(...)          │
        │    verifyDelivery(Fulfillment[], AssessorReceipt) [view] │
        │    imageInfo() [view]                                    │
        │    setImageUrl(string) [admin]                           │
        │    VERIFIER, APPLICATION_VERIFIER, ASSESSOR_ID [view]    │
        │    DEPRECATED_ASSESSOR_ID,                               │
        │    DEPRECATED_ASSESSOR_EXPIRES_AT [view]                 │
        │    DEFAULT_MAX_GAS_FOR_VERIFY [view]                     │
        └──────────────────────────────────────────────────────────┘

Methods that actually exercise the legacy contract

Forwarded through fallback() to the audited legacy impl. Everything in
the list below has a different selector on the new market (different param
types) or doesn't exist on it at all:

Class Methods
Fulfillment ABI fulfill(Fulfillment[],AssessorReceipt), fulfillAndWithdraw(...), priceAndFulfill(ProofRequest[],bytes[],Fulfillment[],AssessorReceipt), priceAndFulfillAndWithdraw(...)
SubmitRoot bundles submitRootAndFulfill(addr,bytes32,bytes,Fulfillment[],AssessorReceipt), submitRootAndFulfillAndWithdraw(...), submitRootAndPriceAndFulfill(...), submitRootAndPriceAndFulfillAndWithdraw(...)
Admin / metadata setImageUrl(string), legacy initialize(address,string)
Views verifyDelivery(Fulfillment[],AssessorReceipt), imageInfo(), VERIFIER(), APPLICATION_VERIFIER(), ASSESSOR_ID(), DEPRECATED_ASSESSOR_ID(), DEPRECATED_ASSESSOR_EXPIRES_AT(), DEFAULT_MAX_GAS_FOR_VERIFY()

Everything else — lockRequest, lockRequestWithSignature, slash,
withdraw, all the deposit*/depositCollateral* variants,
submitRoot, every view getter shared with the legacy ABI, every
AccessControl role method — has the same selector on the new market
and therefore runs on the new impl, even when invoked from a legacy
broker. The legacy impl's body for those methods is unreachable from the
proxy; the audited-bytecode-reuse story only covers the methods in the
table above. Shared methods are covered by the new impl's audit.

Gas impact

The fallback path adds one delegate-call hop + calldata/returndata copy on
every legacy-only call.

Per-method overhead (via-fallback BoundlessMarketLegacyViaFallbackBasicTest
gas snapshot vs. the standalone legacy suite running identical scenarios):

Method Standalone Via fallback Δ
fulfill: a locked request 87,293 89,513 +2,220 (+2.5%)
fulfill: a locked request with 10kB journal 344,971 349,365 +4,394 (+1.3%)
fulfill: another prover fulfills without payment 82,256 84,608 +2,352 (+2.9%)
fulfillAndWithdraw: a locked request 99,163 101,224 +2,061 (+2.1%)
submitRootAndFulfill: a locked request 121,966 123,789 +1,823 (+1.5%)
submitRootAndFulfillAndWithdraw: a locked request 133,271 134,935 +1,664 (+1.2%)

Mean overhead across the 43 measured methods: +566 gas. Typical
fulfillment path: ~2.2k gas extra. Worst case (10kB journal): ~4.4k gas
extra, dominated by the returndatacopy of the large output.

A handful of batch-fulfill rows show a small negative delta (a few
hundred gas faster via fallback). That's noise from different CREATE
nonce ordering in the test setups (cold/warm state on participating
contracts differs); the actual per-call overhead is positive and bounded
by the delegate-call cost.

Implementation runtime bytecode: 29,456 → 30,293 B (+837 B). The
overshoot vs the originally-budgeted ~50 B comes from the public
LEGACY_IMPL() getter the immutable generates and from the dispatcher's
new tail changing optimizer inlining decisions. Still over EIP-170;
the size shrink to fit under the cap is tracked separately on
jonas/market-bytecode-shrink.

How to review

The largest commit by line count is the legacy source / test imports.
Reviewing those line-by-line is wasteful — they're frozen copies of
main's sources. Use the commands below to confirm there are no
differences vs main beyond the explicit renames (file basenames and
import paths).

1. Production source tree (contracts/src/legacy/)

The renamed root contracts:

diff <(git show 507f7469:contracts/src/BoundlessMarket.sol) \
     <(cat contracts/src/legacy/BoundlessMarketLegacy.sol)

diff <(git show 507f7469:contracts/src/IBoundlessMarket.sol) \
     <(cat contracts/src/legacy/IBoundlessMarketLegacy.sol)

diff <(git show 507f7469:contracts/src/IBoundlessMarketCallback.sol) \
     <(cat contracts/src/legacy/IBoundlessMarketCallbackLegacy.sol)

Expected diffs: import-path strings rewritten to point at the renamed
files inside legacy/. Nothing else.

The frozen library + type subtrees should be byte-identical to main:

for f in contracts/src/legacy/libraries/*.sol contracts/src/legacy/types/*.sol; do
  base="${f#contracts/src/legacy/}"
  diff <(git show 507f7469:contracts/src/$base) <(cat $f) \
       || echo ">> drift in $base"
done

The two commands above should produce no output. If they do, that's
where to focus the source review.

2. Test tree (contracts/test/legacy/)

The standalone-legacy port of the test suite:

diff <(git show 507f7469:contracts/test/BoundlessMarket.t.sol) \
     <(cat contracts/test/legacy/BoundlessMarketLegacy.t.sol)

Expected diffs: import paths rewritten to legacy/, the four top-level
test contracts prefixed with Legacy so gas snapshots land in their own
JSON files.

Helpers (frozen):

for f in TestUtils.sol MockCallback.sol clients/BaseClient.sol \
         clients/Client.sol clients/SmartContractClient.sol \
         clients/MockSmartContractWallet.sol; do
  diff <(git show 507f7469:contracts/test/$f) <(cat contracts/test/legacy/$f) \
       || echo ">> drift in $f"
done

Expected diffs: import paths only (../src/../../src/legacy/ or
../../src/).

The via-fallback port:

diff contracts/test/legacy/BoundlessMarketLegacy.t.sol \
     contracts/test/legacy/BoundlessMarketLegacyViaFallback.t.sol

Expected diffs: setUp rewires the proxy to point at the new market with
LEGACY_IMPL set to a freshly-deployed legacy impl; test contracts
prefixed with ViaFallback; two regression assertions re-baselined for
the shifted proxy address (see comment in the file).

3. Bytecode-parity invariant

The truly load-bearing check that the legacy tree is the audited code:

just check-legacy-bytecode
just check-storage-layout

The first asserts legacy/BoundlessMarketLegacy.sol compiles
byte-identical to cast code 0x22bb...cd3 --rpc-url $BASE_RPC (modulo
masked immutable slots; values cross-checked against
contracts/test/legacy/deployed-bytecode.meta.toml). The second asserts
the new and legacy storage layouts agree at every slot reachable from
either contract.

Both run automatically in CI (legacy-bytecode-parity job in
.github/workflows/contracts.yml).

4. Commits to review individually

The substantive changes are in these commits — review the diffs directly:

Commit What changed
feat(contracts): forward legacy ABI to a configurable impl via fallback BoundlessMarket.sol (LEGACY_IMPL, fallback()), BoundlessMarketLib, every constructor call site + payable casts
chore(contracts): verify legacy/ bytecode parity against deployed OLD impl Verifier script + reference snapshot + meta + CI job + justfile recipe
chore(contracts): enforce storage layout interop between src/ and src/legacy/ Storage-layout verifier script + CI step + justfile recipe
test(contracts): pin cross-ABI invariants in a focused suite CrossABI.t.sol — the 4 invariants unique to the fallback shape
chore(contracts): wire legacy impl into Deploy.s.sol Localnet auto-deploys a fresh legacy impl; production uses BOUNDLESS_LEGACY_IMPL env var

Adds a frozen copy of the audited OLD market sources under
contracts/src/legacy/ to serve as the delegatecall target of the new
market's forthcoming legacy-ABI fallback shim. Old brokers continue to
hit the deployed OLD impl bytecode via the proxy without translation
layers in the new market.

File basenames are suffixed with "Legacy" to keep forge artifacts in
distinct out/ directories; contract and interface names are unchanged
so the compiled bytecode can be byte-matched against the deployed OLD
impl in a follow-up. Imports rewritten to point at the renamed files.
No other source modifications.
Mirrors the production legacy tree by adding a frozen copy of main's
BoundlessMarket test suite plus the helpers it depends on (TestUtils,
MockCallback, clients/{BaseClient,Client,SmartContractClient,
MockSmartContractWallet}). All imports rewritten to point at
contracts/src/legacy/ for the diverged sources; HitPoints and
BoundlessMarketCallback are reused from src/ since they haven't
changed.

Top-level test contracts are prefixed with "Legacy" (e.g.
BoundlessMarketBasicTest -> BoundlessMarketLegacyBasicTest) so gas
snapshots land in their own JSON files instead of overwriting the new
market's. 133 tests pass.
… impl

Pins contracts/src/legacy/ to the audited BoundlessMarket implementation
currently deployed on Base mainnet (0x22bb...cd3 behind proxy
0xfd15...fe82). The check ensures the frozen tree continues to compile to
the same bytecode as the audited deployment, so the new market's
forthcoming fallback() can delegate-call into it without revisiting the
audit for the legacy ABI surface.

Adds:
  - contracts/test/legacy/deployed-bytecode.hex: snapshot of the deployed
    runtime bytecode (24,371 B), pulled at Base block 46,576,272.
  - contracts/test/legacy/deployed-bytecode.meta.toml: provenance plus
    expected constructor immutable values (VERIFIER, ASSESSOR_ID,
    COLLATERAL_TOKEN_CONTRACT, DEPRECATED_ASSESSOR_*, APPLICATION_VERIFIER).
  - contracts/scripts/verify-legacy-bytecode.py: stdlib-only verifier that
    (1) masks all immutable slots and asserts the rest matches byte-for-byte
    and (2) extracts each declared immutable's baked value from the
    deployed bytecode and asserts it matches the expected meta values.
    Skips the inherited UUPS __self immutable (always address(this)).
  - .github/workflows/contracts.yml: new legacy-bytecode-parity job,
    gated by existing src/foundry/test/scripts/ci path filters.
  - justfile: check-legacy-bytecode recipe, wired into the umbrella check.
  - license-check.py: legacy/IBoundlessMarketLegacy.sol on APACHE_PATHS to
    mirror its src/ counterpart's license header.
…/legacy/

Adds an invariant check that the new market and the frozen legacy market
agree on every storage slot reachable from both ABIs. Delegate-calling the
legacy impl from the new market's forthcoming fallback only works if each
(slot, offset, width) the legacy code touches means the same thing in the
new market's view of storage; this guards against silent drift if either
tree adds, removes, or reorders state fields.

The verifier compares forge-emitted storageLayout for both artifacts:
  1. Top-level variables at slot 0/1/2 (requestLocks, accounts, imageUrl)
     agree on label, slot, offset, and normalized type.
  2. Every struct transitively referenced from those slots (Account,
     RequestLock) has identical member layouts in both contracts.

AST id suffixes embedded in type identifiers are stripped before
comparison so two artifacts with different compile-time ids still match
when their underlying type names do. Wired into `just check` and the
existing contracts-CI job.
Adds a LEGACY_IMPL immutable to BoundlessMarket plus a payable fallback()
that delegate-calls into it for any selector the new contract does not
declare. The legacy ABI (Fulfillment[] + AssessorReceipt shape, the four
submitRootAnd* variants, etc.) is preserved without re-introducing the
bodies into the new market: in-flight transactions and old broker
clients stay functional during the migration window, while the new code
path goes through the BoundlessRouter as before.

The constructor now takes the legacy impl address as a third argument.
On Base mainnet this is the pre-upgrade implementation pointed to by
the proxy; the deployed bytecode there is already audited (see
contracts/scripts/verify-legacy-bytecode.py for the parity invariant).
On dev/localnet, tests and deploy scripts stand one up from
contracts/src/legacy/BoundlessMarketLegacy.sol.

Plumbing:
  - BoundlessMarket.sol: new error InvalidLegacyImpl, new constructor
    arg + zero-address check, public immutable LEGACY_IMPL, payable
    fallback that calldatacopy / delegatecall / returndatacopy /
    revert-or-return.
  - BoundlessMarketLib.encodeConstructorArgs: extended to include
    legacyImpl for the OZ upgrades safety checks.
  - Deploy.s.sol / Manage.s.sol: read BOUNDLESS_LEGACY_IMPL env var,
    thread through constructor + encode, assert the deployed market's
    LEGACY_IMPL() matches, payable() casts on BoundlessMarket(addr)
    conversions (required now that the type has a payable fallback).
  - BoundlessMarket.t.sol: setUp deploys a BoundlessMarketLegacy impl
    first and feeds the address into every BoundlessMarket constructor.

Runtime size: 29,456 -> 30,293 B (+837). Larger than a bare assembly
shim because the public LEGACY_IMPL getter and the dispatcher tail
change pull in more than just the fallback body. Still over EIP-170;
tracked separately on the size-shrink branch.

353 non-legacy tests pass; bytecode-parity and storage-layout invariants
remain green.
Clones the legacy test suite into BoundlessMarketLegacyViaFallback.t.sol
and rewires setUp so the proxy points at the new market impl while
LEGACY_IMPL points at a fresh legacy impl deployed alongside. Tests are
typed against the legacy BoundlessMarket so every call emits the legacy
ABI selectors; selectors the new market declares (lockRequest, slash,
withdraw, view getters, etc.) execute on the new impl, while legacy-only
selectors (fulfill with the old shape, imageInfo, verifyDelivery, the
submitRootAnd* variants with AssessorReceipt) fall through to the legacy
impl via fallback().

This is the load-bearing validation of the architecture: 133 tests pass
end-to-end, proving that (a) the fallback routes legacy selectors
correctly, (b) storage interop holds between the two impls in both
directions, and (c) the new market is behaviorally compatible with the
legacy on every shared method the legacy suite exercises.

Two regression tests had their hard-coded recovered-address assertions
re-baselined: the proxy address shifts by one CREATE nonce because setUp
now deploys an extra contract before the proxy, which shifts the EIP-712
domain separator and therefore the deterministic garbage address that
ECDSA.recover produces for a malformed signature. Annotated inline.

Test contracts are prefixed with "ViaFallback" so their gas snapshots
land in their own JSON files rather than overwriting the standalone
legacy suite's.
Adds CrossABI.t.sol next to the via-fallback suite. Inherits its setup
(new market behind the proxy, legacy impl as fallback target) and
isolates the four behaviors that are unique to this deployment shape:

  - testLegacyImageInfoViaFallback: legacy-only view callable through
    fallback; returns the legacy impl's baked-in ASSESSOR_IMAGE_ID and
    an empty imageUrl since the new market's initialize(address)
    signature does not set it.
  - testLegacyImmutablesReadableViaFallback: VERIFIER() and ASSESSOR_ID()
    getters auto-generated from legacy immutables route through fallback
    and return values baked into the legacy bytecode.
  - testFallbackRevertsOnUnknownSelector: a selector missing on both
    contracts reverts cleanly through the fallback's delegatecall path.
  - testSharedViewReadsLegacyFulfilledState: lock via the shared
    lockRequest (executes on the new impl), fulfill via the legacy
    fulfill(Fulfillment[], AssessorReceipt) selector (executes on the
    legacy impl via fallback), then read state via shared
    requestIsFulfilled (executes on the new impl). Confirms writes from
    the legacy impl are visible to the new impl's view — the
    load-bearing storage interop invariant.

The contract docstring captures why the rest of the originally-planned
matrix is moot: shared selectors always run on the new impl regardless
of which "ABI" the caller meant to use, so a "lock legacy vs lock new"
distinction does not exist on-chain and is already covered by the
via-fallback suite.
@jonastheis jonastheis marked this pull request as draft May 28, 2026 13:44
…oyment-test

Deploy.s.sol now resolves the legacy impl address optionally: if
BOUNDLESS_LEGACY_IMPL is set in the environment (production / mainnet
upgrade paths) use it as-is, otherwise deploy a fresh legacy impl from
contracts/src/legacy/ wired to the same verifier, applicationVerifier,
assessor image id, and collateral token the new market is about to use.
The address is threaded into the new market's third constructor arg.
Manage.s.sol's production path still requires BOUNDLESS_LEGACY_IMPL to be
set explicitly, so operators cannot accidentally ship a freshly-deployed
unaudited legacy impl to mainnet.

The deployment-test profile (contracts/deployment-test/Deploymnet.t.sol)
was already broken on this branch because the router-decoupling work
removed AssessorReceipt and related types from src/types/. Redirected
its imports to contracts/src/legacy/. After the upgrade those
legacy-shape calls reach the deployed market through the new fallback,
so the test shape matches the production path. FOUNDRY_PROFILE=
deployment-test forge build is clean again. New-ABI coverage of the
deployed market is a follow-up.
Adds LEGACY-FROZEN.md alongside the frozen tree, capturing what the
folder is, what's in it, where it came from, and what to do when
something needs to change.

Records the source provenance (main commit 507f746, the last commit on
main that touched any of the files mirrored here), the on-chain identity
the bytecode must continue to match (Base mainnet impl
0x22bb6bbe5d221ef3e738029dab4d1d27ec725cd3), and the diff command
reviewers can run to verify each file's provenance.

Names the two CI invariants that keep the tree honest
(verify-legacy-bytecode.py and verify-storage-layout.py, both in the
legacy-bytecode-parity job) and states the freeze policy: no
modifications. If the deployed impl genuinely changes (CVE, compiler
bump, sunset), refresh deployed-bytecode.hex + .meta.toml and re-run
just check-legacy-bytecode.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant