elHub
Multi-chain intent-based DeFi money market with hub-side accounting on Base and spoke execution on other L2s likes BSC, Worldchain and Polygon.
- Hub contracts (Base): money market, risk manager, intent inbox, lock manager, settlement, verifier, custody, token registry.
- Spoke contracts (Worldchain/BSC): portal for supply/repay initiation + withdraw fills, and Across borrow receiver for borrow fills.
- ZK plumbing: verifier interface + dev mode + circuit scaffold.
- Services:
services/indexer: canonical lifecycle/status API.services/relayer: lock/Across dispatch orchestration + proof finalization for deposits and borrow fills.services/prover: settlement batching + proof generation plumbing.
- Next.js app (
apps/web) with wallet flows for dashboard, supply, borrow, repay, withdraw, activity. - Monorepo packages:
packages/abis: generated ABIs from Foundry artifacts.packages/sdk: shared intent signing, hashing, and protocol clients.
/apps
/web
/services
/relayer
/indexer
/prover
/packages
/sdk
/abis
/contracts
/src
/test
/script
/circuits
- Node.js
>=22 - Foundry (
forge,cast,anvil) pnpmvia Corepack
# from repo root
pnpm install
pnpm devpnpm dev runs:
- Hub-local anvil (
:8545, chain id fromHUB_NETWORK) - Spoke-local anvil (
:9545, chain id from first network inSPOKE_NETWORKS) - Hub + spoke deployments (
contracts/script/deploy-local.sh) - ABI generation (
packages/abis) indexer,prover,relayer, andwebapps
- Web UI:
http://127.0.0.1:3000 - Indexer API:
http://127.0.0.1:3030 - Relayer API:
http://127.0.0.1:3040 - Prover API:
http://127.0.0.1:3050
HubMoneyMarket: share-based supply/debt accounting, interest accrual, settlement hooks, liquidation skeleton.HubRiskManager: HF math + lock/borrow/withdraw checks + caps.ChainlinkPriceOracle: ChainlinkAggregatorV3adapter with heartbeat/staleness checks, bounds, and decimal normalization toe8.HubIntentInbox: EIP-712 validation + nonce consumption.HubLockManager: mandatory lock/reservation for borrow/withdraw intents.HubSettlement: batched settlement with verifier, replay protection, lock/fill/deposit checks.Verifier:DEV_MODEdummy proof support + real verifier slot.DepositProofVerifier: witness->public-input adapter for deposit proof verification.BorrowFillProofVerifier: witness->public-input adapter for borrow fill proof verification.HubCustody: bridged funds intake + controlled release to market.HubAcrossReceiver: Across callback receiver that records pending fills, supports timeout expiry + recovery sweep, and finalizes deposits only after proof verification.HubAcrossBorrowDispatcher: hub-side Across dispatcher for borrow fulfillment transport.HubAcrossBorrowFinalizer: hub-side proof-gated recorder for borrow fill evidence.TokenRegistry: token mappings (hub/spoke), decimals, risk, bridge adapter id.
SpokePortal: supply/repay initiation (escrow + bridge call).MockBridgeAdapter: local bridging simulation event sink.AcrossBridgeAdapter: Across V3 transport adapter with route + caller controls and message binding for proof finalization.MockAcrossSpokePool: local Across-style SpokePool used for source deposit event emission and local callback simulation in E2E harnesses.SpokeAcrossBorrowReceiver: spoke Across callback receiver that authenticates hub origin before transfers and emits proof-bound source event.
- User calls
SpokePortal.initiateSupplyorinitiateRepay. - Across transport emits source deposit event on spoke.
- Across destination fill triggers hub callback;
HubAcrossReceiverrecordspending_fill(untrusted message, no custody credit yet) with finalize/sweep deadlines. - Anyone can call
HubAcrossReceiver.finalizePendingDepositwith a valid deposit proof while pending isACTIVE/EXPIREDand not swept. - On proof success, receiver moves bridged funds into
HubCustodyand registers the bridged deposit exactly once. - If proof finalization is delayed/fails, pending deposits can be marked expired and later swept to the recovery vault (
PendingDepositExpired/PendingDepositSwept). - Prover batches deposit actions and submits settlement proof.
- Hub settlement credits supply or repays debt.
- User signs EIP-712 intent in UI.
- Relayer locks intent on hub (
HubLockManager.lock). - Relayer dispatches hub->spoke Across fill via
HubAcrossBorrowDispatcher.dispatchBorrowFill. - Across destination fill calls
SpokeAcrossBorrowReceiver.handleV3AcrossMessageand emitsBorrowFillRecordedonly if spoke pool sender plus hub dispatcher/finalizer and source/destination chain bindings match expected values (sourceChainId = spoke chain,destinationChainId = hub chain). - Relayer/prover submit borrow fill proof to
HubAcrossBorrowFinalizer.finalizeBorrowFill. - Finalizer records proof-verified borrow fill evidence in settlement.
- Prover batches finalize actions and settles.
- Settlement consumes lock, updates accounting, reimburses relayer on hub.
- Borrow-fill finalization retries until lock expiry; if fill/finalization does not complete before lock expiry (
fillDeadline + 30m, capped by lock TTL/intent deadline), relayer auto-callscancelExpiredLockand status becomesexpired_unwound.
- User signs EIP-712 intent in UI.
- Relayer locks intent on hub (
HubLockManager.lock). - Relayer dispatches hub->spoke Across fill via
HubAcrossBorrowDispatcher.dispatchBorrowFillwithintentType=WITHDRAW. - Across destination fill calls
SpokeAcrossBorrowReceiver.handleV3AcrossMessageand emitsBorrowFillRecordedonly after origin/auth checks pass (sourceChainId = spoke chain,destinationChainId = hub chain). - Relayer/prover submit withdraw fill proof to
HubAcrossBorrowFinalizer.finalizeBorrowFill. - Finalizer records proof-verified withdraw fill evidence in settlement.
- Prover batches finalize actions and settles.
- Settlement consumes lock, updates accounting, reimburses relayer on hub.
- Withdraw-fill finalization retries until lock expiry; if fill/finalization does not complete before lock expiry (
fillDeadline + 30m, capped by lock TTL/intent deadline), relayer auto-callscancelExpiredLockand status becomesexpired_unwound.
cd contracts
forge build
forge test --offlineTests cover:
- Interest accrual invariants (indices monotonic, shares-to-assets behavior)
- HF checks for borrow/withdraw locks
- Chainlink oracle adapter checks (staleness, non-positive answers, decimal normalization)
- Risk manager oracle bound enforcement
- Supply+borrow lock/fill/settle happy path
- Across pending-fill + proof-gated bridge crediting invariants
- Replay protections (batch, intent, fill)
- Failure paths (missing lock/fill, expired intent)
- Settlement atomicity rollback on mid-batch failure
- Settlement max action cap enforcement (
MAX_BATCH_ACTIONS = 50)
Run focused oracle/risk hardening tests:
cd contracts
forge test --offline --match-contract ChainlinkOracleAndRiskBoundsTest -vvStart an anvil fork of Base:
anvil --fork-url "$BASE_RPC_URL" --port 8545Run the fork test suite:
cd contracts
RUN_FORK_TESTS=1 BASE_FORK_URL=http://127.0.0.1:8545 forge test --match-contract ForkBaseSupplyBorrowTest -vvNotes:
- The test uses canonical Base
WETHfor ETH supply (ETH -> WETH -> supply). - The borrow leg uses a freshly deployed
USDCmock on the fork for deterministic liquidity across Forge versions. - Coverage includes:
- supply ETH collateral + borrow USDC
- full lifecycle: borrow -> repay -> withdraw collateral
- liquidation when ETH price drops below safe collateralization
If you run a hub fork on :8545 and spoke fork on :8546, execute:
HUB_NETWORK=base \
HUB_CHAIN_ID=8453 \
SPOKE_NETWORKS=worldchain \
HUB_RPC_URL=http://127.0.0.1:8545 \
SPOKE_RPC_URL=http://127.0.0.1:8546 \
BASE_TENDERLY_RPC_URL=http://127.0.0.1:8545 \
WORLDCHAIN_TENDERLY_RPC_URL=http://127.0.0.1:8546 \
pnpm test:e2e:forkThe E2E runner will:
- build + deploy contracts to the fork nodes
- start
indexer,prover, andrelayer - run supply->settle flow
- run borrow->lock/Across-dispatch/proof-finalize->settle flow
- assert hub supply/debt state
Notes:
scripts/e2e-fork.mjsnow reads.envautomatically.- RPC resolution order:
- explicit process env (
HUB_NETWORK,SPOKE_NETWORKS,<NETWORK>_TENDERLY_RPC_URL,<NETWORK>_RPC_URL) .envwith the same keys- local fallbacks (
http://127.0.0.1:8545hub,http://127.0.0.1:8546spoke)
- explicit process env (
scripts/e2e-fork.mjsuses the first entry inSPOKE_NETWORKSas the active spoke.- When RPCs are Tenderly,
scripts/e2e-fork.mjscan fund deployer/relayer/bridge/prover withtenderly_setBalance. - Funding knobs:
E2E_USE_TENDERLY_FUNDING(default1)E2E_MIN_DEPLOYER_GAS_ETH(default2)E2E_MIN_OPERATOR_GAS_ETH(default0.05)
To run only the inbound supply path for Base-hub semantics (default spoke: Worldchain):
HUB_NETWORK=base \
HUB_CHAIN_ID=8453 \
SPOKE_NETWORKS=worldchain \
HUB_RPC_URL=http://127.0.0.1:8545 \
SPOKE_RPC_URL=http://127.0.0.1:8546 \
BASE_TENDERLY_RPC_URL=http://127.0.0.1:8545 \
WORLDCHAIN_TENDERLY_RPC_URL=http://127.0.0.1:8546 \
pnpm test:e2e:base-hub-supplyThis wrapper runs scripts/e2e-fork.mjs with E2E_SUPPLY_ONLY=1 and asserts:
- deposit reaches
pending_fill - deposit is proof-finalized to
bridged - settlement credits supply on hub
Legacy alias: pnpm test:e2e:base-mainnet-supply still forwards to the Base-hub supply wrapper.
For local/fork tests only, the script simulates the destination relay callback with MockAcrossSpokePool.relayV3Deposit; production relayer runtime no longer performs this relay simulation.
The relayer now persists a durable finalization queue (RELAYER_TRACKING_PATH) so finalization failures are retried instead of dropped when cursors advance.
This run executes against live chain RPCs (no Tenderly vnets) and validates real Across processing:
HUB_NETWORK=base \
SPOKE_NETWORKS=worldchain,bsc \
BASE_RPC_URL=<base-mainnet-rpc> \
WORLDCHAIN_RPC_URL=<worldchain-mainnet-rpc> \
BSC_RPC_URL=<bsc-mainnet-rpc> \
HUB_GROTH16_VERIFIER_ADDRESS=<groth16-verifier-on-base> \
HUB_LIGHT_CLIENT_VERIFIER_ADDRESS=<light-client-verifier-on-base> \
HUB_ACROSS_DEPOSIT_EVENT_VERIFIER_ADDRESS=<deposit-event-verifier-on-base> \
HUB_ACROSS_BORROW_FILL_EVENT_VERIFIER_ADDRESS=<borrow-fill-event-verifier-on-base> \
pnpm test:e2e:live:base-world-bscRerun without redeploying:
E2E_LIVE_SKIP_DEPLOY=1 pnpm test:e2e:live:base-world-bscNotes:
scripts/e2e-live-base-world-bsc.mjshard-fails if any configured RPC URL is Tenderly inLIVE_MODE=1.- The live runner never calls relay simulation paths (
simulateAcrossRelay/MockAcrossSpokePool.relayV3Deposit). - Deployment artifacts are written under
contracts/deployments/live-*.{json,env}for local reuse; do not commit the generatedlive-*.env. - Live deploy runs are fresh-deploy oriented:
contracts/script/deploy-live-multi.mjsignores prior manifest state for the target and rewritescontracts/deployments/live-*.manifest.jsonfor that run. Action logs are appended tocontracts/deployments/live_deployed_contracts.log.
Active E2E commands:
pnpm test:e2e:base-hub-supply(smoke path for inbound supply lifecycle to Base hub)pnpm test:e2e:fork(full supply + borrow lifecycle)pnpm test:e2e:live:base-world-bsc(Base hub + Worldchain/BSC live scenario on real RPCs)pnpm test:e2e(runs both local/fork active E2E commands)
Use the diagnostics utility to pinpoint the first failing borrow-proof stage (revert vs false) with a relayer borrow-finalization payload:
node scripts/debug-borrow-proof-path.mjs \
--deployment contracts/deployments/live-base-hub-worldchain-bsc.json \
--payload /path/to/borrow-fill-task-or-payload.json- GitHub Actions workflow:
.github/workflows/ci.yml - Jobs:
contracts:forge build+forge test --offlinemonorepo-build: install deps, regenerate ABIs, build all workspaces
pnpm abis:generateReads Foundry artifacts from contracts/out and writes JSON ABIs into packages/abis/src/generated.
After local deploy:
contracts/deployments/local.jsoncontracts/deployments/local.env- copied to
apps/web/public/deployments/local.json
- Local dev uses
DEV_MODE=trueverifier with proof payloadZKHUB_DEV_PROOF. - Production mode requires deploying
Verifierwith:DEV_MODE=false- non-zero
initialGroth16Verifier PUBLIC_INPUT_COUNT=4(batchId, hubChainId, spokeChainId, actionsRoot)
actionsRootis SNARK-field-safe and deterministic from settlement action ordering.- Real-proof plumbing is implemented in
services/proverviaCircuitProofProvider(snarkjs groth16 fullprove). - Build circuit artifacts with
bash ./circuits/prover/build-artifacts.sh. - Set
PROVER_MODE=circuitto use real Groth16 proofs. contracts/script/deploy-local.mjssupports verifier modes:HUB_VERIFIER_DEV_MODE=1(default): deployVerifierin dev proof mode.HUB_VERIFIER_DEV_MODE=0: requiresHUB_GROTH16_VERIFIER_ADDRESSand deploysGroth16VerifierAdapter+ prodVerifier.
- Configure oracle stack (recommended):
- Deploy
ChainlinkPriceOracle(owner). - For each supported hub asset, call:
ChainlinkPriceOracle.setFeed(asset, feed, heartbeat, minPriceE8, maxPriceE8)
- Deploy
HubRiskManager(owner, tokenRegistry, moneyMarket, chainlinkOracle). - Optionally set global bounds on risk manager:
HubRiskManager.setOracleBounds(minPriceE8, maxPriceE8)
- Deploy
- Oracle notes:
ChainlinkPriceOraclerejects stale rounds (block.timestamp - updatedAt > heartbeat), non-positive answers, and invalid rounds.- Feed decimals are normalized to protocol-wide
e8. - Keep heartbeat and bounds conservative per asset and chain.
- Configure
AcrossBridgeAdapter(recommended inbound transport path):setAllowedCaller(<SpokePortal>, true)setRoute(localToken, acrossSpokePool, hubToken, address(0), fillDeadlineBuffer, true)per tokenSpokePortal.setBridgeAdapter(<AcrossBridgeAdapter>)
- Configure hub-side Across receiver:
- deploy
HubAcrossReceiver(admin, custody, depositProofVerifier, hubSpokePool, recoveryVault, pendingFinalizeTtl, recoverySweepDelay) - grant
CANONICAL_BRIDGE_RECEIVER_ROLEonHubCustodytoHubAcrossReceiver - set recovery config as needed (
setRecoveryConfig(recoveryVault, pendingFinalizeTtl, recoverySweepDelay)) - do not grant attester/operator EOAs any custody bridge registration role
- deploy
- Configure Across borrow fulfillment path:
- deploy
HubAcrossBorrowDispatcher(admin, hubAcrossBorrowFinalizer) - deploy
SpokeAcrossBorrowReceiver(admin, spokeAcrossSpokePool, hubAcrossBorrowDispatcher, hubAcrossBorrowFinalizer, hubChainId) - configure dispatcher routes per hub asset (
setRoute) with public fills (no exclusivity) and allow relayer caller (setAllowedCaller) - set
AcrossBorrowFillProofBackend.setDestinationDispatcher(hubAcrossBorrowDispatcher) - grant
PROOF_FILL_ROLEonHubSettlementtoHubAcrossBorrowFinalizer
- deploy
- Relayer inbound behavior:
- observe spoke Across deposit logs for source metadata (
initiated):- canonical live event:
FundsDeposited - local/fork mock event:
V3FundsDeposited
- canonical live event:
- observe hub
PendingDepositRecordedforpending_fill - request proof from prover and call
finalizePendingDeposit - do not call
relayV3Depositin production runtime
- observe spoke Across deposit logs for source metadata (
- Relayer borrow behavior:
- lock intent on hub and dispatch borrow/withdraw via
HubAcrossBorrowDispatcher - observe spoke
BorrowFillRecordedand reject mismatchedhubDispatcher/hubFinalizer - request proof from prover and call
HubAcrossBorrowFinalizer.finalizeBorrowFill - do not use any direct spoke fill function in production runtime
- lock intent on hub and dispatch borrow/withdraw via
- For settlement verifier, deploy generated Groth16 verifier bytecode and wire it through
Groth16VerifierAdapter:- deploy generated verifier (from
snarkjs zkey export solidityverifier) - deploy
Groth16VerifierAdapter(owner, generatedVerifier) - set
Verifier.setGroth16Verifier(<adapter>)withDEV_MODE=false.
- deploy generated verifier (from
- Production-verifier settlement path is covered in tests (
test_prodVerifierPath_settlementRejectsTamperedProofAndAcceptsValid).
- Hub is source of truth for all accounting and risk checks.
- No fast credit for collateral: supply/repay only apply post-settlement.
- No operator/attester direct bridge credit path in runtime: inbound deposits require
HubAcrossReceiverproof finalization beforeHubCustodyregistration. - Borrow/withdraw requires hub-side lock and reservation before spoke fill.
- Settlement batch replay is blocked by
batchIdreplay protection. - Intent finalization replay blocked via lock consumption + settled intent tracking.
- Spoke outbound fills require authenticated hub origin (
sourceChainId,hubDispatcher,hubFinalizer) before token transfer orintentFilledwrite. - Spoke double-fills blocked by
SpokeAcrossBorrowReceiver.intentFilled. - Borrow/withdraw routes are configured for public fills; stale locks are automatically unwound after expiry.
DEV_MODEverifier does not provide production cryptographic guarantees.- Local Across flow still uses mocked SpokePools; production must use real Across contracts and a production-grade light-client/ZK deposit proof backend.
- Detailed execution plan:
PRODUCTION_READINESS_PLAN.md - Detailed technical specification:
TECHNICAL_SPEC.md
- If your shell cannot write to default Corepack/Pnpm home directories, set:
export COREPACK_HOME="$PWD/.corepack"
export PNPM_HOME="$PWD/.pnpm-home"
export PATH="$PNPM_HOME:$PATH"- Local services depend on environment emitted by
contracts/deployments/local.env. - Internal service routes (
/internal/*) require signed HMAC headers usingINTERNAL_API_AUTH_SECRET.