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.
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.
Vite · React 18 · react-router · TypeScript
wagmi + viem · RainbowKit · SIWE adapter
Tailwind-flavoured CSS (no framework)
/ — bridge widget (mint & burn tabs)
/history — connected-address activity
/infrastructure — relayer fleet + validator topology
/legal — disclaimer (cookie-persisted)
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
LockAndMint — deposit-addr issuance, polling, mint tx
BurnAndUnlock — approve + requestBurn flow
BridgeStats — live TVL, daily-limit windows
BridgeWidget — tabbed shell, share state
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.
| Contract | Upgradeable | Notes |
|---|---|---|
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. |
processedPearlTxs mappingMAX_FEE_BPS = 100 (1%)MIN_THRESHOLD = 2 hard floorMAX_DEADLINE_WINDOW = 24h on every mint authorisationMINT_TYPEHASH includes threshold (audit H-2)upgradeDelay → execute (audit Round 5)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.
| Role | Holder (mainnet) | Powers |
|---|---|---|
DEFAULT_ADMIN_ROLE | Timelock (24h) ← Safe | Grant/revoke any role, schedule upgrades, set delays |
FEE_ROLE v0.3.1 | Hot key, separate from admin | setMintFee, setBurnFee, proposeFeeRecipient, cancelFeeRecipientProposal |
FEE_WITHDRAW_ROLE | Treasury hot key | withdrawFees to recipient |
DAILY_LIMITS_ROLE | Admin / timelock | Adjust dailyMintLimit / dailyBurnLimit |
DEPOSIT_BOUNDS_ROLE | Admin / timelock | Set minDepositAmount |
TVL_CAP_ROLE | Admin / timelock | Set TVL ceiling |
CONFIRMATIONS_ROLE | Admin / timelock | Set Pearl-side minPearlConfirmations |
THRESHOLD_ROLE | Admin / timelock | Adjust quorum threshold (≥ MIN_THRESHOLD) |
RELAYER_ROLE | N relayer keys (≥ 3 on mainnet) | Sign EIP-712 mint authorisations |
PAUSER_ROLE | Hot pauser EOA | pause() (no unpause) |
UNPAUSER_ROLE | Distinct from PAUSER (mutual) | unpause() |
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.
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.
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
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
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
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
http://localhost:8545 (Hardhat default)npx hardhat node against the v0.3.1 deploy scriptsMainnet 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).
initialize() gains a 5th positional _feeAdmin parameter (between _pauser and _relayers). On mainnet, this points to a hot key separate from the Timelock so routine fee tuning skips the 24h delay. DEFAULT_ADMIN_ROLE retains the ability to rotate or revoke FEE_ROLE via standard AccessControl.__gap[46] intact. UUPS-upgrade-safe.deploy.ts / deploy-localnet.ts / deploy-with-timelock.ts / deploy-ceremony.ts / localnet-test.ts all updated. deploy-ceremony.ts now requires ALLOW_DEPLOYER_FEE_ADMIN=1 if the operator deliberately sets FEE_ADMIN to the deployer (closes a silent-skip footgun flagged in the audit)._feeAdmin./tmp/feeadmin-audit-2026-05-16.md.