Cross-Chain Bridge & Liquidity Supply (spec 067)¶
Two member surfaces, two per-network UUPS routers, two third-party protocols:
- Transfer → Bridge (
frontend/src/components/wallet/BridgeView.jsx) moves an asset a member already holds to another network, settled by Across Protocol V3. - Earn → Supply (
frontend/src/components/earn/SupplyView.jsx) supplies liquidity to curated pools — Uniswap V3 full-range trading pools, and Across HubPool bridge pools.
Neither surface takes custody. The member's wallet is the only signer, the member owns every position, and the routers exist for exactly two jobs: charge the spec-060 platform fee atomically where that is possible without custody, and be the on-chain control surface (curation, limits, killswitch) that decides what is on offer.
- Contracts:
contracts/bridge/BridgeRouter.sol(IBridgeRouter.sol),contracts/liquidity/LiquidityRouter.sol(ILiquidityRouter.sol) - Client libs:
frontend/src/lib/bridge/,frontend/src/lib/liquidity/ - Spec:
specs/067-bridge-pool-liquidity/—spec.md,research.md(the design-changing findings),contracts/(per-contract design notes) - Operator runbook: runbooks/bridge-liquidity-operations.md
- Related: platform-fees.md, staking-integration.md
(spec 066's
StakingRouteris the shape both routers copy), upgradeable-contracts.md
Deployment state: neither router is deployed on any network yet (issue #966). Everything below is the shipped, tested behaviour of code that has no addresses in
deployments/— which is also why every surface has to degrade honestly rather than assume a router is there.
Where each half can exist at all (research R8)¶
The binding constraint was never the protocols; it was which chains carry them.
| Network | Bridge (Across SpokePool) | Trading pools (Uniswap V3) | Bridge pools (Across HubPool) |
|---|---|---|---|
| Ethereum (1) | ✅ | ✅ | ✅ only chain |
| Optimism (10) | ✅ | ✅ | ❌ |
| Polygon (137) | ✅ | ✅ | ❌ |
| Base (8453) | ✅ | ✅ (distinct addresses — R4b) | ❌ |
| Arbitrum (42161) | ✅ | ✅ | ❌ |
| Ethereum Classic (61) / Mordor (63) | ❌ | ❌ | ❌ |
Across's HubPool is an L1 contract by design — the shared pot lives on Ethereum and the bridge
lends from it to every other network. So bridge-LP listings are Ethereum-only while trading
pools span all five mainnets. This asymmetry is not a rollout gap that will close; do not "fix" it by
copying hubPool onto an L2, and do not let copy imply both kinds of pool exist everywhere
(liquidityAvailabilityCopy() states the two halves separately for this reason).
ETC (61) and Mordor (63) have neither protocol and cannot host these routers at all. They do
carry dex config (ETCswap) for the swap surface, which is precisely why availability is not derived
from the DEX capability alone — see Availability.
Uniswap addresses are not the same on every chain. Ethereum, Polygon, Arbitrum and Optimism share
the canonical factory/position manager; Base does not. Copying the canonical pair everywhere
produces a router pointed at a non-contract (deposits revert) or, worse, at a same-address contract
belonging to something else. Every address is taken from that chain's own deployment record, asserted
to carry bytecode at deploy time, and re-checked by scripts/ops/verify-protocol-addresses.js.
BridgeRouter¶
Does: holds the curated route registry (inputToken + outputToken + destinationChainId, with
a per-transaction maxAmount and an advisory expectedFillSeconds), the Across SpokePool address,
the sanctions guard, and a per-network pause. bridgeWithFee pulls the member's gross amount, skims
the bridge.transfer fee to the FeeRouter's treasury, approves the SpokePool for the net, and
calls depositV3 — one transaction, nonReentrant, checks-effects-interactions, approvals reset to
zero, and a residual-balance assertion (ResidualFunds) at the end of both the ERC-20 and native legs.
Does not: hold anything between transactions, and has no rescue or claim-refund function. That absence is deliberate and load-bearing — see below.
The property this contract exists to get right¶
Across refunds a deposit that no relayer fills by fillDeadline to the depositor address on the
origin chain. depositor is an ordinary parameter, independent of msg.sender. The router therefore
passes msg.sender — the member — and never address(this).
Naming the router would send every unfilled bridge into a contract with no per-member accounting and
no withdrawal path, stranding funds on the one path that is supposed to be the safety net. The failure
is silent: the happy path is unaffected, so it only surfaces in production, on the unhappy path, with
real money. BridgeInitiated records depositor explicitly so the property is auditable without
decoding the SpokePool's own log, and the merge-blocking fork test asserts an expiry refund lands on
the member's address — a suite that only covers the fill path cannot detect this class of bug.
Because refunds and fills settle directly to the member, adding an operator "rescue" entrypoint would imply an operator can reach an in-flight transfer. They cannot, and the admin Operations panel says so rather than leaving anyone hunting for a button during an incident.
Other decisions worth knowing before you touch it¶
outputTokenis part of the route id. It was originally omitted, which meant re-runningsetRoutewith a different delivered asset overwrote the route in place under the same id: a member's quote could name one asset while the deposit delivered another, with no new route appearing anywhere an operator or indexer would notice.computeRouteIdalso mixes inblock.chainid, so ids are never portable across deployments.nativeInputis explicit, not inferred. Inferring it frommsg.value > 0would make a wrapped-token route and a native route indistinguishable. An ERC-20 route that receives native value revertsUnexpectedNativeValuerather than accepting funds it could not return.removeRouteexists here and pool removal does not exist on the other router. A route holds no member position, so removing it only stops new bridges. A pool listing does correspond to member positions, so it can only be retired.- Non-standard tokens fail closed. A fee-on-transfer token delivers less than
inputAmount, so the SpokePool's own pull ofnetreverts; a rebasing token tripsResidualFunds. Neither can silently under-deliver. Such tokens are simply not curatable as routes.
LiquidityRouter¶
Does: curates the pools members may supply — both TradingLp (Uniswap V3) and BridgeLp (Across
HubPool) listings, with per-transaction ceilings per leg — holds the Uniswap position manager address,
the sanctions guard, and a pause. mintFullRangeWithFee charges the liquidity.deposit fee and mints
a full-range Uniswap V3 position to the member (recipient: msg.sender). Full range means
±887272 aligned to the pool's tick spacing: no range UI, no out-of-range state, no rebalancing.
listPool cross-checks a TradingLp listing against the pool it names (token0/token1/fee).
Without that, listing a 0.3% pool's address with feeTier: 500 was accepted and the mint then
succeeded against a different pool than the one curated — reproduced on a Polygon fork. The listing
metadata is what the member is shown, so it has to be true.
Does not, and this is three separate rulings:
- It is never in an exit path. Trading-LP members own the position NFT and call Uniswap's
NonfungiblePositionManagerdirectly; bridge-LP members own the LP tokens and call Across's HubPool directly. A pause, misconfiguration, or upgrade can never block an exit, and no withdrawal can ever carry a platform fee — there is no code path that could charge one. - It does not touch bridge-LP deposits at all.
HubPool.addLiquidity(l1Token, amount)has no recipient parameter — LP tokens mint tomsg.sender. A fee-skimming wrapper would therefore receive the LP tokens itself, making FairWins the custodian of a position the member could never exit. So bridge-LP deposits are a direct member call and are fee-free, and the router is their registry and killswitch, not their path. This is the same rule spec 066 applied to delegated staking: a fee is charged only where it can be charged atomically without taking custody. Consequence:pause()here stops new Uniswap supplies only, and every operator surface must be labelled that way — an operator reaching for a killswitch during an incident must not believe they have stopped something they have not. - There is no
removePool. Retirement issetPoolEnabled(false). A retired pool stays listed, visible, and withdrawable, because members still hold positions in it. The copy for a closed pool says "no new deposits", never "gone".
positionManager may be zero at init (a network can curate bridge pools without Uniswap); the supply
path then reverts PositionManagerUnset rather than failing opaquely. A BridgeLp pool id passed to
mintFullRangeWithFee reverts NotTradingPool for the same reason.
UUPS and storage¶
Both routers inherit contracts/upgradeable/UUPSManaged.sol — do not re-roll the proxy or auth
wiring. Both replace the constructor with a one-time initialize, and both carry a trailing
uint256[44] private __gap.
Storage is append-only: never insert, reorder, or remove existing state; new state goes at the end
and comes out of the gap. Both contracts are registered in scripts/deploy/check-storage-layout.js
(bridgeRouter / liquidityRouter), so npm run check:storage-layout validates them and gates CI.
Ship logic changes as in-place upgrades via scripts/deploy/lib/upgradeable.js, never a fresh
redeploy — a redeploy would orphan the curated routes and pools an operator has already seeded, and
deployments/ records the proxy (bridgeRouter, liquidityRouter) separately from its current
implementation (bridgeRouterImpl, liquidityRouterImpl).
The role model¶
Each router has its own AccessControl instance, granted at initialize to the deploy admin (a
multisig in production).
| Role | Controls | Why it sits where it does |
|---|---|---|
LIQUIDITY_ADMIN_ROLE |
setRoute / setRouteEnabled / setRouteLimit / removeRoute; listPool / setPoolEnabled / setPoolLimit |
Curation. Which routes and pools are offered, at what per-transaction ceiling. |
GUARDIAN_ROLE |
pause / unpause |
The killswitch, and nothing else. |
DEFAULT_ADMIN_ROLE |
setSpokePool, setPositionManager, setFeeRouter, setSanctionsGuard; role grants |
The fund-path addresses, deliberately a role above curation. |
UPGRADER_ROLE |
_authorizeUpgrade (from UUPSManaged) |
Upgrades. |
The split exists because the two kinds of mistake are not equally recoverable. Curating a route or a
pool badly is reversible by a toggle — flip enabled false and the offer is gone. Pointing a
router at a hostile contract is not: spokePool and positionManager are approved and handed the
member's net amount, feeRouter names both the rate and the treasury() the fee is transferred to,
and sanctionsGuard is the compliance gate. Whoever can write those can redirect where member funds
go, so they are DEFAULT_ADMIN_ROLE while LIQUIDITY_ADMIN_ROLE stays what its name says. The
fee-amount bound described in the next section is the second half of this: even an admin cannot
configure a FeeRouter that takes more than MAX_FEE_BPS of a member's principal.
GUARDIAN_ROLE here is not the WagerRegistry guardian set¶
It is a role on this router's own AccessControl, granted per router. The app-wide useRoles()
flags cannot answer whether an operator holds it:
isGuardianmeans "holdsGUARDIAN_ROLEon theWagerRegistry" — a different contract, an unrelated set. An operator with the wager guardianship would be shown an enabled killswitch that reverts, which is exactly the belief the pause requirements exist to prevent.isLiquidityAdminis an OR across the two routers, while the role is granted per router on purpose. The OR showed an operator holding it on one router every write control on the other.- All of them read the wallet's connected chain, while control state is per-network and the admin tabs scope to a network the operator picks. A Polygon guardian whose wallet sat on Ethereum lost the Polygon controls.
So the operator surfaces (frontend/src/components/admin/BridgeTab.jsx, SupplyTab.jsx) ask the
contract that will enforce it, via readRouterAuthority in
frontend/src/components/admin/liquidityAdminCommon.js — hasRole against the router in scope, on
the network in scope. The app-wide flags remain good for one thing only: deciding whether the tab
appears at all. And readable: false from that read means the question could not be put, not the
answer was no — the controls stay offered with the authority marked unconfirmed, because withdrawing
a killswitch because an RPC timed out is itself the failure.
The two fee services¶
Both are spec-060 FeeRouter services. The rate lives there and only there — never hardcode a bps
value, never cache one in these routers.
| Service id | Charged on | Kind | Cap | Launch rate |
|---|---|---|---|---|
keccak256("bridge.transfer") |
a bridge submission (value-out) | ConfigOnly |
250 bps | 0 |
keccak256("liquidity.deposit") |
a Uniswap full-range supply (value-in) | ConfigOnly |
250 bps | 0 |
Client constants: FEE_SERVICES.BRIDGE_TRANSFER / FEE_SERVICES.LIQUIDITY_DEPOSIT in
frontend/src/lib/fees/feeQuote.js. A zero or unset rate produces no fee line at all — not a line
reading 0.00 — and behaviour byte-identical to fee-free.
There is deliberately no bridge-LP service. Registering a rate that cannot be charged (research R3) would put a settable control in the admin panel that silently does nothing, which is worse than no control.
Why ConfigOnly. Wrapped services are the ones FeeRouter.depositToVaultWithFee will charge;
registering these as Wrapped would let a call like
depositToVaultWithFee("bridge.transfer", someVault, …) pass the kind check and treat a bridge fee as
an ERC-4626 deposit. quoteFee / feeBps / setFeeBps behave identically for either kind, so nothing
is lost.
Why each router repeats MAX_FEE_BPS = 250. FeeRouter applies its own MAX_WRAPPED_FEE_BPS
only to Wrapped services. Declaring the ceiling on the charging contract makes 250 bps a property of
the code that moves the money rather than of a registration argument, and bounds the damage if
feeRouter is ever repointed.
Three guards, and what each one is actually for¶
- The consent ceiling. The frontend passes the quoted bps back as
maxFeeBps; a live rate above it revertsFeeAboveQuotedinstead of overcharging. Never synthesise amaxFeeBpsyou did not display. It only bites when a fee is actually charged (fee > 0), so a treasury-unset network quoting zero is never blocked by a stale configured rate. - The amount bound. Both routers bound the fee amount
quoteFeereturns toMAX_FEE_BPSof the principal, and require an exactfee + net == grosssplit (FeeSplitMismatch). The cap binds the fee actually taken, not the rate the FeeRouter reports about itself. Both ceiling checks readfeeBps(), which is the FeeRouter's own claim: an implementation reportingfeeBps() = 0whilequoteFee()hands back most of the amount satisfies them both, and the transfer would then send the member's principal to its treasury. Exact-split matters for the mirror-image reason —netis what reaches Across or the position manager, so a router that under-reportsnetwould strand the difference or shrink the member's position. - The residual assertion. Every leg compares the closing balance to the opening one and reverts
ResidualFundson any difference. Transient custody stays transient.
The fee is charged on capital actually consumed¶
For Uniswap supplies, LiquidityRouter._supply quotes the fee after the mint, against the
amount0/amount1 the position manager actually took. An earlier version skimmed from the member's
gross desired amounts before the mint. Uniswap almost never consumes both legs exactly — it takes
whatever the current price ratio needs and refunds the rest — so members were charged a fee on capital
that was handed straight back to them, unsupplied. Adversarial review reproduced it on a live fork;
four independent reviewers found it.
Two consequences you cannot design around:
- The remainder comes back whole, with no fee on it. The router approves only the net to the position manager, so the fee can never be silently deployed as liquidity, and everything the position did not take and the fee did not claim is transferred back to the member.
- No function computes "the fee" from a desired amount, because no such number exists before the
mint.
frontend/src/lib/liquidity/liquidityRouter.jsexposesmaxSupplyFee— an explicit upper bound — and the confirm step discloses the rate, says it applies to whatever is actually supplied, and says the remainder returns whole. A precise figure computed off the amount the member typed would be too high whenever the pool's ratio takes less than both legs.
The bridge fee has the opposite shape and is simpler: it is skimmed from the gross the member entered
and only the net goes to Across — which is why acrossQuotes.js fetches the Across quote for the
net. Quoting the gross would overstate what arrives.
Availability: how a surface decides to exist¶
Three independent conditions compose. Getting this wrong in either direction is a member-facing failure: claiming availability we do not have, or hiding a surface that works.
1. Capability flags (frontend/src/config/networks.js, per network):
| Flag | Derived from | Gates |
|---|---|---|
capabilities.dex |
SWAP_CHAIN_IDS.has(chainId) && Boolean(this.dex) |
In-app swapping: the Trade surface, the asset sheet's Swap action, DEX spot pricing |
capabilities.liquidity |
Boolean(this.dex?.positionManager) |
Earn → Supply (trading pools) |
capabilities.bridge |
Boolean(this.bridge?.spokePool) |
Transfer → Bridge |
dex is an explicit allow-list, not Boolean(this.dex), because spec 067 adds positionManager
addresses for liquidity — a different reason — and deriving the swap capability from the presence of
dex config would switch token swapping on as a side effect of a routine config edit. That is exactly
how Ethereum ended up swap-less: it had no dex block, so the capability was false by accident of
configuration rather than by decision. Keeping swap and liquidity as two flags also keeps the states
representable: a network may have pools worth supplying before FairWins exposes swapping there, or the
reverse.
The bridge block in networks.js is a build-time display fallback only. The authoritative
SpokePool/HubPool addresses, route availability, limits, and pause state are read from the routers at
runtime.
2. Router deployment. capabilities.liquidity alone is not enough to name a network — it is true
wherever a Uniswap-shaped position manager is configured, which includes ETC and Mordor via ETCswap,
where FairWins has shipped nothing. So tradingLiquidityNetworks() in
frontend/src/lib/liquidity/liquidityCopy.js requires both the capability and a deployed
liquidityRouter address. bridgeLiquidityNetworks() needs a configured hubPool (Ethereum only).
Both lists are derived, never asserted, so they stay true as deployments land — and an empty list
produces honest "not set up in this build yet" copy rather than a false roster.
2a. The cohort bounds every member-facing roster (issue #1265). All four — tradingLiquidityNetworks(),
bridgeLiquidityNetworks() in both liquidityCopy.js and acrossLpPositions.js, and bridgeNetworks()
in lib/bridge/bridgeCopy.js — enumerate cohortChainIds(), never listSupportedChainIds(), and so
does useLiquidityCatalog in SupplyView.jsx, which is the one that actually opens connections. Reads
never cross the testnet/mainnet boundary (constitution III), and naming a network a build cannot supply to
is a promise it cannot keep. Since the spec-067 routers are mainnet-only, a testnet-cohort build lists no
pools and no bridge networks and says so in its own words.
The Bridge FORM is bounded by the same roster, and had to be. Its asset and destination selectors
draw on useSelectableAssets({catalog: true}) → getPortfolioChainIds(), which is mainnets-always plus a
member opt-in and knows nothing about the cohort (that model is deliberate and is not folded into the
cohort rule — it still governs the portfolio elsewhere). Bounding only the copy would have left a testnet
build able to quote, sign and record a mainnet bridge while stating underneath that no network here
bridges at all. So BridgeView intersects the catalog with bridgeNetworks(), and names what that drops
with the reason that is true of it (FR-006c): "the bridge protocol is not deployed there" for ETC and
Mordor, "this build does not bridge on those networks" for the other cohort.
BridgeStatusList then has TWO rosters, and they are not the same list. What it READS over the
network is bridgeNetworks() — cohort-bounded, because a status poll is a read. What it LISTS is that
plus every origin chain the member's own ledger has a bridge on (listBridgeChainIds, local storage, no
network). FR-053 says the in-flight list renders underneath the form in every case and must never hide a
transfer that is already moving, so a record predating the bound — or restored from a backup written by
another build — still renders, with its last recorded status and a line saying this build did not check
that network. The "no network is set up for bridging in this build" empty state is reached only when the
ledger is also empty, which makes it a result rather than an assumption.
The admin roster is the deliberate exception: adminNetworks(capability) lists every capable network
whether or not a router is deployed there — and, unlike the four above, is not cohort-bounded, because
the undeployed ones are the ones an operator most needs to see (see the comment on the function).
3. The quoting gateway. A bridge price is not derivable client-side — it needs Across's
relayer-fee oracle — so quoting goes through the relay-gateway proxy
(services/relay-gateway/src/bridge/, base URL VITE_BRIDGE_GATEWAY_URL — falling back to
VITE_RELAYER_URL, which is the same gateway and stays the deployed default — module env BRIDGE_ENABLED,
BRIDGE_CHAIN_IDS, BRIDGE_KILLSWITCH, quota and TTL vars). The module is optional infrastructure
and off unless enabled; when off it answers 503 bridge_disabled rather than 404, so the client
can tell "an operator turned it off" from "this gateway is too old". Supply needs no gateway at all —
its reads are direct RPC.
Composed, via useBridgeAvailability (BRIDGE_UNAVAILABLE_REASON) and the copy tables in
bridgeCopy.js / liquidityCopy.js:
| Condition | Bridge shows | Supply shows |
|---|---|---|
| Capability false (ETC, Mordor, Bitcoin) | no_protocol / bitcoin — permanent here, not a fault, and names where it is available |
Honest per-network empty state naming where pools exist |
| Capability true, router undeployed | router_undeployed — a deployment state; Transfer works exactly as before |
Network absent from the roster; other networks' pools still listed |
| Router deployed, unreachable | router_unreachable — availability withheld, with "as of last read" framing |
Same; existing positions still read directly from the protocol |
| Router says paused | New bridges refused; in-flight tracking unaffected | New Uniswap supplies refused; positions still visible and withdrawable |
| Route / pool disabled | Route not offered, with the reason | Pool shown as closed to new deposits, still visible and withdrawable |
FeeRouter present but unreadable |
Fee-bearing path blocked — never quoted at a rate we are unsure of | Same |
| Gateway unset / killswitched / unreachable | Surface hides (gateway). In-flight bridges keep resolving from chain evidence |
Unaffected |
That last row is the one that makes hiding an acceptable degradation rather than a trap: a gateway outage can stop a member starting a bridge, but can never strand one already moving.
The fallback direction is always toward withholding, never inventing. Where spec 066 could fall back to "fee-free direct staking" because a safe default existed, no safe default exists for a bridge route — offering one we cannot price or verify would be inventing data. So the honest fallback is absence with a stated reason.
The R11b predicate inversion¶
Two-asset surfaces pin the network on the first selection and filter the second list. One
mechanism, two predicates that are exact inverses, both in
frontend/src/lib/assets/networkPin.js so the inversion is visible at the point of use:
samePair(o, pin) // o.chainId === pin.pinnedChainId
bridgeDest(o, pin) // o.symbol === pin.pinnedSymbol && o.chainId !== pin.pinnedChainId
PIN_MODE.SAME_NETWORK— a Uniswap pair and an in-app swap both live within one network, so once the member picks the first asset itschainIdpins the counterpart list.PIN_MODE.OTHER_NETWORK_SAME_ASSET— the bridge is the exact inverse. Its whole purpose is that the destination is a different network holding the same asset.
Applying samePair to the bridge silently reduces it to a same-chain transfer. Nothing throws:
the destination would be on the source chain, the gateway would still return a quote, and the member
would still sign. That plausible copy-paste error is why the two rules live side by side in one module
instead of being re-derived inline at each call site.
bridgeDest matches the symbol exactly, so USDC.e is not a destination for USDC — they are
genuinely different assets and quoting them as one would be dishonest. Both predicates run over spec
064's SelectableAsset shape from useSelectableAssets, unchanged. Bitcoin options carry a string
chainId (spec 061) and are excluded from both contexts with a stated reason
(nonEvmReason) so the selector disables them visibly rather than dropping them. And pinning can
legitimately produce an empty second list — noPairCounterpartCopy / noBridgeDestinationCopy say
what would change it rather than rendering a dead dropdown.
The Supply list: a row summarises, the sheet discloses¶
Earn → Supply is a list of dense, scannable rows (LiquidityPoolCard.jsx), not cards. Each row
carries what you choose a pool by — the pair or asset, the kind, protocol · network · pool fee, the
total supplied, and the estimated return — and the whole row is a <button> that opens
SupplySheet. Three rules govern edits here:
- Moving a disclosure is fine; losing one is not. FR-017's list (return, total, risk summary, every InfoTip) is satisfied across the row and the sheet it opens. The reasons behind a missing figure, the kind-specific risk, and every tip live in the sheet's facts block, above the tabs, so they are read before an amount is typed. If you take something off the row, it lands there.
- No InfoTip inside a row. The row is one interactive element; nesting a tip button inside it is an accessibility failure. That is the same reason the Lend vault row has none.
- A closed pool still opens, and each closed state names itself.
poolStateLabel()maps the pool'sunavailableReasonto one short chip — Closed to new deposits / New deposits paused / Protocol unreachable / Fee rate unavailable — because the four have four different remedies. The row never collapses them into one "closed", never hides the pool, and never disables itself:SupplyViewopens a closed pool the member holds straight onto Withdraw (FR-021/FR-024).
Narrowing the list¶
The list spans every network with a router and holds both kinds together, so it comes with a search
box and a All / Trading / Bridge chip row (lib/liquidity/poolSearch.js). Search matches, as an
AND over whitespace-separated terms, the words the row itself shows — assets, the joined pair label,
protocol, network, kind label — so anything a member can read they can type. Two rules:
- Narrowing is by identity, never by state.
poolSearchText()deliberately excludesavailable/unavailableReason, so no query can act as a "hide the closed ones" filter. A pool a member has money in stays findable by its own name (FR-024). - It narrows the catalog, never the positions. The Your pools list above renders from
positionsand is not filtered at all — a control that could hide a position would hide money.
The ?token= deep link (tokenFilter, an exact symbol match) composes with both rather than being
replaced by either; the chip row is withheld when only one kind is curated and the search box when
there is a single pool. An empty result says which kind of empty it is: noPoolMatchCopy() for a
search that reached nothing (a fact about the search, with a Clear search control) versus
NO_POOLS_COPY for an empty catalog (a fact about the estate).
The asymmetric availability copy sits in a collapsed <details> below the list — "Where pools are
available" — rather than as a paragraph above it, and it carries the active-network note when there
is one (the note ends with the same sentence, so the two never render together).
Folded, not dropped: a member who cannot find out that bridge pools are Ethereum-only meets that
fact at the confirm step instead.
The row draws no sparkline and no composition bar, unlike the design it follows. Neither has an honest source — no price feed backs a two-asset split, no history feed backs a trend — and on a value surface a decorative chart is a claim (rule 4 of the invariants below).
The Supply surface also says nothing when sanctions screening is unavailable (FR-033a). These pools are a curated list of protocol contracts, not counterparties a member picked, so an "unscreenable network" notice warned about a risk the deposit does not carry. A wallet that IS listed is still refused, at display and again past the cache at submission (FR-032) — that half is untouched.
Client libraries¶
Both lib/ trees follow the same read/degrade contract, copied from lib/staking/stakingRouter.js
on purpose: read the router, return null on any core read failure, and let the caller choose the
honest fallback. null means "we could not establish this", never "no". Nothing invents availability,
and a failed read is never rendered as a zero — a member shown "0" when the truth is "we could not
read it" has been told something false about their own money.
frontend/src/lib/bridge/¶
| Module | Job |
|---|---|
bridgeRouter.js |
Read the router config (routes, limits, SpokePool, paused) and build the bridgeWithFee call as {target, data, value} entries for the spec-041 unified send rail. ERC-20 routes get an approve leg; native routes carry inputAmount as value. |
acrossQuotes.js |
Assemble one quote from exactly two sources — the gateway's Across suggested-fees proxy and the live FeeRouter rate. Every cost is its own labelled line; a figure the upstream did not give us is marked unavailable, never rendered as 0. Quotes carry a validity window (isQuoteStale) and the quoted bps travels back as maxFeeBps. |
bridgeStatus.js |
The status machine and cross-session reconciler. delivered is reachable only from confirmed destination-side evidence (a fill tx hash) — not an upstream string, not an elapsed timer. needs_attention is about the clock, not the outcome, and is not terminal. |
bridgeActivityBuffer.js |
One notification record per state transition, ever; nothing is re-derived on drain. |
bridgeCopy.js |
All member-facing wording: cost, timing, risk, the honest-unavailable table, and the named settlement protocol. |
Persistence for in-flight bridges is the client ledger store (data/ledger/sources/bridgeLedgerSource.js,
LEDGER_CLASS.BRIDGE), which is per-account and rides the spec-032 encrypted backup — so a bridge
survives the app closing, and the gateway status endpoint is a convenience, not the authority.
frontend/src/lib/liquidity/¶
| Module | Job |
|---|---|
liquidityRouter.js |
Read the pool registry and build supply calls. Refuses a BridgeLp pool rather than producing calldata that reverts; chargesPlatformFee(pool) is false for one; exposes maxSupplyFee (an upper bound) and no "the fee" function. |
uniswapPositions.js |
Local full-range tick derivation (identical to fullRangeTicks), position discovery, value/earnings/composition — every figure flagged isEstimate — and the exit calls (decreaseLiquidity + collect) straight to the position manager. positionManager is always an explicit argument, never hardcoded (R4b). |
acrossLpPositions.js |
Bridge-pool supply, read, and exit. Nothing here imports, resolves, or targets LiquidityRouter; there is no maxFeeBps argument and no fee-quoting function, because there is no fee. Answers for unsupported networks with available: false and a reason naming where it is available. |
liquidityCopy.js |
All member-facing wording, including the asymmetric availability copy and liquidityFeeCopy returning null for bridge pools, always. |
poolSearch.js |
Narrowing the Supply list — search haystack, kind chips, and the filterPools composition of query + kind + ?token=. Matches on identity only; a pool's state is never part of the haystack. |
Ledger/notification wiring: LEDGER_CLASS.LIQUIDITY + liquidityLedgerSource.js, and the bridge /
liquidity notification domains. Both class names are new and additive — nothing is reclassified,
so historical ledger data and the encrypted backup are unaffected. Note the vocabulary rule from
research R6: "Pool" stays with wager pools; the Earn area is Supply, and the wager-pool feed
label was corrected to "Wager Pool".
Invariants you must not break¶
- The member is Across's
depositor. Neveraddress(this). An unfilled deposit refunds to the member on the origin chain, and there is deliberately no rescue or claim-refund function to make up for getting this wrong. - No custody, ever. Uniswap position NFTs mint to the member; Across LP tokens mint to the member
because their deposit never touches a FairWins contract at all. Both routers hold value only within
a single transaction, and assert it on the way out (
ResidualFunds). - A pause stops new activity and can never trap value. In-flight bridges settle in the SpokePool;
positions exit through the protocol with the router nowhere in the path. Both
pause()functions depend on nothing but their own contract's state, so they stay exercisable while every optional service is degraded. The Supply pause covers Uniswap supplies only — label it that way. - Degrade honestly. Return
null, name the reason, and withhold the surface. Never invent a price, an availability, or a zero for a read that failed; never let a fee-bearing action proceed on a rate you could not read. - Storage is append-only. New state at the end, out of the
__gap;npm run check:storage-layoutgreen before any upgrade; in-place upgrades only.
Deploy, sync, and test¶
# 1. deploy both UUPS proxies + register the two fee services (cap 250 / rate 0)
npx hardhat run scripts/deploy/deploy-bridge-liquidity.js --network <net>
# 2. seed routes and pools per the R8 matrix (HubPool listings on Ethereum only)
# 3. addresses + ABIs reach the frontend only through the generated artifacts
npm run sync:frontend-contracts
# 4. gating before any subsequent upgrade
npm run check:storage-layout
The deploy script asserts non-empty bytecode at every configured protocol address before writing a
record, and writes bridgeRouter / bridgeRouterImpl / liquidityRouter / liquidityRouterImpl.
The fee rate is then set from the AdminPanel Fees tab (FEE_ADMIN_ROLE) — it is read-only on
the Bridge and Supply tabs, which link to it.
Tests:
- Contracts —
test/bridge/BridgeRouter.test.js,test/liquidity/LiquidityRouter.test.js, including the hostile-FeeRouter case (contracts/mocks/MockLyingFeeRouter.sol) that the amount bound exists for. - Fork —
test/fork/bridgeRouter.fork.test.js(the expiry-refund case is merge-blocking) andtest/fork/liquidityRouter.fork.test.js. - Frontend —
frontend/src/lib/bridge/__tests__/,frontend/src/lib/liquidity/__tests__/,frontend/src/lib/assets/__tests__/networkPin.test.js, and the admin suites underfrontend/src/test/admin/(least-privilege, network scoping, pause-never-traps). - End to end —
frontend/cypress/e2e/full/30-bridge-liquidity.cy.js(on-chain tier, spec 094 matrix rowsbridge.*/liquidity.*). See below for what it settles and why it needs a chain.
The end-to-end flows, and why they are on-chain¶
Invariants 1–3 above are all statements about who ends up holding the money, and none of them is
a rendering fact. A component test can assert what the app intends to send; only a chain can answer
who owns the position NFT and whose address Across wrote down — and both recipient: address(this)
and depositor: address(this) are perfectly ordinary-looking lines to write. So the four flows drive
the real surfaces and then read the answer off the chain:
| Flow | Settles |
|---|---|
BL-01 supply |
The position NFT's owner is the member, the router's balance of both legs is zero |
BL-02 pause |
A withdrawal completes while the router is paused, and the tokens arrive |
BL-03 bridge |
MockAcrossSpokePool.lastDepositor() is the member, and exactly one deposit landed |
BL-04 ceiling |
The rate is raised after the member read it: the transfer is refused, not repriced, and nothing reaches Across. Then at rate 0 there is no fee line at all (FR-029) |
Two seams make this possible and neither ships:
VITE_E2E_AMOY_LOCAL=1(DEV-only,config/networks.js+config/contracts.js) resolves both routers, the SpokePool and the Supply surface on the chain-80002-shaped local node. Real Amoy has neither protocol; a production bundle drops the branch entirely.- The quote endpoint is stubbed and only that. A bridge price is not derivable client-side, so
there is nothing on a local node to quote against — but the stub answers arithmetic the protocol
itself enforces (
net - totalRelayFee == outputAmount), because a quote that does not reconcile makes the app drop its itemization and hide the very linesBL-04reads.
contracts/mocks/MockAcrossSpokePool.sol and contracts/mocks/MockPositionManager.sol model the
parts these flows read back: the recorded depositor, the V3FundsDeposited log the SPA parses a
submission out of, and the member-called exit legs (decreaseLiquidity / collect) that make "a
pause cannot trap a position" a testable claim rather than a design intention.