PearlBridge / v0.3.1

Architecture Overview

Pearl L1 ↔ Ethereum bridge. Lock-and-mint model. Updated 2026-05-16 (FEE_ADMIN_ROLE separation).

1 · System Topology

One asset, two chains. PRL is locked on Pearl L1; an equal amount of WPRL (ERC-20, 8 decimals) is minted on Ethereum by a quorum of relayers. Burning WPRL releases native PRL back on Pearl. The relayer set is the trust boundary; the controller never accepts a single-signer instruction.

┌────────────────────────┐ ┌──────────────────────────┐ │ Pearl L1 │ │ Ethereum │ │ (native PRL chain) │ │ (BridgeController + │ │ │ │ WPRL ERC-20 token) │ └──────────┬─────────────┘ └────────────┬─────────────┘ │ deposits & unlocks │ mint, burn, fees │ │ ▼ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Relayer quorum (N-of-M EIP-712 signers) │ │ • Observes both chains • Signs mint authorisations │ │ • MIN_THRESHOLD = 2 • Federated key custody │ └─────────────────────────────────────────────────────────────┘ ▲ ▲ │ deposit address mapping │ SIWE auth, history, │ + history API │ deposit-addr issuance ▼ │ ┌─────────────────────────────────────────────────────────────┐ │ Frontend (Vite/React) │ │ LockAndMint • BurnAndUnlock • History • Infra │ └─────────────────────────────────────────────────────────────┘

2 · Frontend

Single-page Vite + React app. Wallet via wagmi/RainbowKit. SIWE for auth-gated endpoints (deposit-address issuance, history). All on-chain reads/writes go through wagmi against the live BridgeController address.

Stack

Vite · React 18 · react-router · TypeScript

wagmi + viem · RainbowKit · SIWE adapter

Tailwind-flavoured CSS (no framework)

Routes

/ — bridge widget (mint & burn tabs)

/history — connected-address activity

/infrastructure — relayer fleet + validator topology

/legal — disclaimer (cookie-persisted)

Key modules

src/lib/contracts.ts — ABIs + ADDRESSES per network

src/lib/config.ts — fee bps, RPC base URLs, env flags

src/lib/siweAdapter.ts — sign-in-with-ethereum

src/lib/destinationConfirm.ts — manual-address advanced mode

Components

LockAndMint — deposit-addr issuance, polling, mint tx

BurnAndUnlock — approve + requestBurn flow

BridgeStats — live TVL, daily-limit windows

BridgeWidget — tabbed shell, share state

3 · Smart contracts

Two contracts. WPearl is a non-upgradeable ERC-20 with an immutable controller binding. BridgeController is UUPS-upgradeable behind an ERC1967 proxy, with a propose→wait→execute upgrade path gated by a configurable on-chain delay.

ContractUpgradeableNotes
WPearl.sol No ERC-20, 8 decimals, MAX_SUPPLY = 2.1B. bridgeController is immutable — set via CREATE pre-compute at deploy time. Freeze/unfreeze on holders, REVOKE_FREEZE_ROLE_ADMIN as distinct admin (audit F-2).
BridgeController.sol UUPS (ERC1967Proxy) Holds bridge logic: requestBurn, executeMint, fee tuning, daily-rate-limit windows, TVL cap, pause. Initializer guarded by Initializable; admin handover via AccessControlDefaultAdminRules 2-step + delay.
TimelockController — OZ standard. Holds DEFAULT_ADMIN_ROLE on mainnet. Safe (multisig) is proposer + canceller; executor is address(0) (open). 24h delay.

Security properties (current)

4 · Role lattice

Granular AccessControl, with DEFAULT_ADMIN_ROLE on the Timelock as the only root authority. Hot keys exist only for narrow, recoverable surfaces (pause, mint quorum, fee tuning). v0.3.1 split fee-tuning out of DEFAULT_ADMIN_ROLE into a dedicated FEE_ROLE.

RoleHolder (mainnet)Powers
DEFAULT_ADMIN_ROLETimelock (24h) ← SafeGrant/revoke any role, schedule upgrades, set delays
FEE_ROLE v0.3.1Hot key, separate from adminsetMintFee, setBurnFee, proposeFeeRecipient, cancelFeeRecipientProposal
FEE_WITHDRAW_ROLETreasury hot keywithdrawFees to recipient
DAILY_LIMITS_ROLEAdmin / timelockAdjust dailyMintLimit / dailyBurnLimit
DEPOSIT_BOUNDS_ROLEAdmin / timelockSet minDepositAmount
TVL_CAP_ROLEAdmin / timelockSet TVL ceiling
CONFIRMATIONS_ROLEAdmin / timelockSet Pearl-side minPearlConfirmations
THRESHOLD_ROLEAdmin / timelockAdjust quorum threshold (≥ MIN_THRESHOLD)
RELAYER_ROLEN relayer keys (≥ 3 on mainnet)Sign EIP-712 mint authorisations
PAUSER_ROLEHot pauser EOApause() (no unpause)
UNPAUSER_ROLEDistinct from PAUSER (mutual)unpause()

Why FEE_ROLE is separated

Before v0.3.1, tuning fees required a DEFAULT_ADMIN_ROLE call — which on mainnet means a 24h timelock cycle. That's the right friction for upgrades and role grants, but wrong for routine fee adjustments in response to fast-moving market conditions. v0.3.1 grants FEE_ROLE to a separate hot key at initialize() time so fee tuning can ship in one tx, while everything else stays behind the timelock. The Timelock still retains the ability to revoke and re-grant FEE_ROLE through standard AccessControl — a compromised fee key is rotated out by timelocked proposal, never directly by another hot key.

5 · Relay (off-chain backend)

TypeScript service. Tails both chains, produces EIP-712 attestations for confirmed Pearl deposits, and submits unlock transactions to Pearl on observed burns. Database is SQLite-backed for crash recovery.

Inbound (Pearl → ETH)

watcher.ts polls Pearl RPC pool (3 wg-mgmt validators)

Resolves deposit-address → recipient mapping

Anomaly check runs before state transition (audit S2-C-1)

Federated attesters in ATTESTER_SOURCES sign mint authorisations

User submits the relayer-signed payload to executeMint

Outbound (ETH → Pearl)

unlock.ts tails BurnInitiated events

Verifies burn confirmation depth on ETH

Broadcasts native PRL send from lock address to user's Pearl address

Kill-switch PEARL_UNLOCK_ENABLED=false defers unlocks safely

API surface (relay :4000)

POST /api/siwe/nonce — issue SIWE nonce

POST /api/siwe/verify — verify + issue session

POST /api/deposit-address — auth-gated, per-user mapping

GET /api/history?address — connected-address activity

POST /api/mint-authorisation — relayer-signed payload

Key custody

N-of-M attester quorum behind a Timelock

Pearl lock-address signing keys held by the relay only — never logged or shipped over any channel

Production custody is decoupled from operator tooling

Idempotency on broadcaster calls is enforced by proposal-bound order identifiers

6 · End-to-end flows

Lock & Mint (Pearl → ETH WPRL)

user frontend relay Pearl L1 ETH BridgeController │ │ │ │ │ │── connect wallet ────▶│ │ │ │ │── SIWE sign-in ──────▶│── nonce, verify ─▶│ │ │ │ │◀── session token ─│ │ │ │── request deposit ───▶│── POST addr ─────▶│ │ │ │ │◀── addr (per-user)│ │ │ │─── send PRL ──────────────────────────────────────────────────▶│ │ │ │ │◀── poll deposit ──│ │ │ │ │ (>= minConfs) │ │ │ │ │── 3 attesters sign▶ │ │ │◀── signed payload ┤ │ │ │── submit mint ────────────────────────────────────────────────────────────────────────▶│ │ │ │ │ executeMint(sigs) │ │ │ │ │ → mint(user, net) │ │◀────────────────────────────── WPRL credited ────────────────────────────────────────│

Burn & Unlock (ETH WPRL → Pearl)

user frontend ETH BridgeController relay Pearl L1 │ │ │ │ │ │── approve ─────▶│ │ │ │ │── requestBurn ─────────────────────────▶│ │ │ │ │ burnFrom(user, gross)│ │ │ │ emit BurnInitiated │ │ │ │── event ─────────────▶│ │ │ │ │── send PRL ──────▶│ │ │ │ │ │ │ │◀── confirmed ─────│ │◀──── frontend polls /api/history ──────────────────────────────│ │

7 · Live deployments

Mainnet (Ethereum)

BridgeController (proxy)
0xA6571B73489d4eBFA269a107208665dF7C80Aef5
WPRL (ERC-20)
0x07696DcaB55E62cfef953666b29Fe1970518cB00
Pearl lock address
prl1p5f450a5540efskxv050tgscelscuztut6zfaqssq8vnlnw53wvdsmw4yvs
Admin (DEFAULT_ADMIN_ROLE)
Timelock (24h) — proposer is Safe multisig
Mint / burn fee
50 bps mint, 0 bps burn (RC3)
Status
RC3 live since 2026-05-18 (prior RC2.6 at 0x3682/0x8171 deprecated)

DevNet (Hardhat localnet, chainId 31337)

RPC
http://localhost:8545 (Hardhat default)
BridgeController (proxy)
0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
BridgeController impl
0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
WPRL
0x5FbDB2315678afecb367f032d93F642f64180aa3
Relayers
Hardhat #2/#3/#4 — threshold 2-of-3
Fees
50 / 50 bps (matches mainnet posture)
Bring-up
npx hardhat node against the v0.3.1 deploy scripts

Mainnet workstream BridgeController deployed bytecode is currently 24,689 B (above the 24,576 B EIP-170 ceiling). DevNet sets allowUnlimitedContractSize=true on the Hardhat network to land tests, but a mainnet redeploy of v0.3.1 requires shrinking the bytecode first — recommended path is the custom-errors refactor (~−500 B).

8 · v0.3.1 — what changed (2026-05-16)