- Architecture Overview
- Derivation Specification
- Setup Instructions
- Running the Bridge
- Testing the Flow
- Known Limitations
The bridge uses deterministic index generation to ensure:
- Same destination address + nonce always produces the same deposit address
- Different destination addresses produce unpredictable, non-sequential indices
- Multiple deposit addresses per user (via nonce parameter)
Algorithm:
import { keccak256, toBytes, hexToNumber, type Hex } from 'viem';
export function calculateIndex(destinationAddress: string, nonce: number): number {
const combined = `${destinationAddress.toLowerCase()}-${nonce.toString()}`;
const hash = keccak256(toBytes(combined));
return hexToNumber(hash.slice(0, 8) as Hex); // take first 3 bytes (0x + 6 hex chars)
}What this does:
- Normalizes address to lowercase:
destinationAddress.toLowerCase() - Constructs input:
"<lowercase-address>-<nonce>"with dash separator - Encodes as UTF-8 bytes and hashes with keccak256
- Takes first 8 characters of hash (includes "0x" prefix) = first 3 bytes
- Converts to integer: 0 to 16,777,215 (2²⁴ - 1)
Why this pattern?
The concatenation pattern ${destinationAddress.toLowerCase()}-${nonce.toString()} is carefully designed for:
-
Case-insensitive determinism: Ethereum addresses can be written in different cases (lowercase, checksummed EIP-55, or mixed). Lowercasing ensures the same Ethereum address always produces the same index regardless of input format:
// Without lowercase: different cases → different hashes hash("0xAbC123...-0") → index 12345 hash("0xabc123...-0") → index 67890 // Different! // With lowercase: always consistent hash("0xabc123...-0") → index 12345 hash("0xabc123...-0") → index 12345 // Same ✓
-
Unambiguous concatenation: The dash separator prevents collision between different input combinations:
// Without separator: address="0xabc1", nonce=23 → "0xabc123" address="0xabc", nonce=123 → "0xabc123" // Collision! ✗ // With separator: address="0xabc1", nonce=23 → "0xabc1-23" address="0xabc", nonce=123 → "0xabc-123" // Unique ✓
-
Human-readable: The pattern
"0xabc...-0"is easy to understand and verify manually, unlike binary encodings or complex serialization formats.
Once the index is calculated, the deposit address is derived using BIP32:
m/44'/60'/0'/0/index
│ │ │ │ │ │
│ │ │ │ │ └─ Calculated index (0-16,777,215)
│ │ │ │ └──── Change (always 0)
│ │ │ └─────── Account (always 0)
│ │ └──────────── Ethereum (coin type 60)
│ └──────────────── Purpose (BIP44)
└──────────────────── Hardened root
Implementation:
import { mnemonicToAccount } from 'viem/accounts';
import type { Address, HDAccount } from 'viem';
function deriveDepositAddress(
mnemonic: string,
index: number
): { address: Address; account: HDAccount } {
// viem's mnemonicToAccount handles the full BIP32 derivation internally
// Path used: m/44'/60'/0'/0/{index}
const account = mnemonicToAccount(mnemonic, {
accountIndex: 0, // m/44'/60'/0'
changeIndex: 0, // m/44'/60'/0'/0
addressIndex: index // m/44'/60'/0'/0/{index}
});
return {
address: account.address,
account
};
}What happens internally:
- Mnemonic → Seed (using BIP39)
- Seed → Master key (BIP32)
- Derive child key at path
m/44'/60'/0'/0/{index} - Extract public key from child key
- Compute Ethereum address:
keccak256(publicKey)→ last 20 bytes
- Node.js 18+ and Yarn
- Supabase account (free tier works)
- Base Sepolia and Arbitrum Sepolia RPC URLs (Alchemy/Infura)
- Testnet ETH for gas fees
git clone <your-repo>
cd custodial-bridge
yarn installCreate .env.local files:
apps/bridge-app/.env.local (API & Frontend)
# Database
SUPABASE_URL=http://127.0.0.1:54321
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# Bridge Configuration
BRIDGE_MNEMONIC="your twelve word mnemonic phrase here"
BASE_RPC_URL=https://base-sepolia.g.alchemy.com/v2/YOUR-KEY
ARBITRUM_RPC_URL=https://arb-sepolia.g.alchemy.com/v2/YOUR-KEY
# Frontend Verification
# Derive from your mnemonic can be generated using script ./scripts/generate-xpub.ts
NEXT_PUBLIC_BRIDGE_XPUB="xpub6C..." apps/watcher/.env (Watcher Service)
SUPABASE_URL=your-supabase-url
SUPABASE_ANON_KEY=your-anon-key
BRIDGE_MNEMONIC=your-mnemonic
BASE_RPC_URL=https://sepolia.base.org # Base Sepolia testnet
ARBITRUM_RPC_URL=https://sepolia-rollup.arbitrum.io/rpc # Arbitrum Sepolia
REQUIRED_CONFIRMATIONS=7
POLL_INTERVAL_MS=10000# seconds between checks// scripts/generate-xpub.ts
import { HDKey } from '@scure/bip32';
import { mnemonicToSeed } from '@scure/bip39';
const mnemonic = "your twelve word mnemonic";
const seed = await mnemonicToSeed(mnemonic);
const masterKey = HDKey.fromMasterSeed(seed);
const accountKey = masterKey.derive("m/44'/60'/0'/0");
console.log("XPUB:", accountKey.publicExtendedKey);Add this xpub to NEXT_PUBLIC_BRIDGE_XPUB in frontend env.
# Run Supabase migrations
cd supabase
supabase db pushTerminal 1: Frontend + API
cd apps/bridge-app
yarn dev
# Runs on http://localhost:3000Terminal 2: Watcher
cd apps/watcher
yarn dev
# Polls every 10 secondsThis is an MVP implementation demonstrating core concepts. The following limitations are known and would need addressing for production use:
Issue: If the watcher crashes after claiming a deposit (status='processing') but before recording the payout transaction hash, the deposit becomes orphaned.
Why: The watcher only queries deposit_status IN ('confirmed', 'processing') but has no timeout mechanism to reset stale processing deposits.
Impact: Requires manual database intervention to recover stuck deposits.
Production Fix: Implement a timeout/lease mechanism:
- Add
processing_started_attimestamp - Reset deposits to 'confirmed' if processing for >10 minutes
- Add heartbeat updates during long-running operations
MIT
