Filament Web3 Airdrop Platform
Token Distribution & Delegate Voting on an L2 Rollup
Role: Senior full-stack engineer in a team of 3–5: the Node server routes, the integration with the team's L2 rollup API, MetaMask signing of rollup transactions through a WASM serializer, the Ethereum wallet flows, and the campaign and voting web client. The rollup itself was built by teammates.
What it is. A B2B Web3 platform for token-distribution (airdrop) campaigns: a campaigner drafts a campaign and its eligibility criteria, delegates vote on the criteria and on the distribution, and tokens are distributed. Campaign state lives on an L2 rollup that teammates built and connected to Ethereum.
Server routes. Node server routes aggregate an external blockchain-analytics API for the campaign dashboard in parallel with Promise.allSettled, so one failing upstream call empties only its widget. Clerk sessions are exchanged for PostgreSQL (Supabase) access on the server and in the browser.
Rollup integration and signing. The web app queries the rollup's REST API for campaigns, votes and accounts, submits transactions to the sequencer and polls the ledger for their status. Transactions are signed in MetaMask: the client fetches the account nonce, serializes the call with the rollup's own serializer compiled to WebAssembly, signs it with EIP-191 and submits it — one wire format, no TypeScript port to drift. 64-bit values travel as BigInt via json-bigint, and a new wallet's first transaction works because the account lookup's 404 maps to nonce 0.
Wallet and UI. Ethereum (Sepolia) wallet flows with ethers.js sit behind one transaction adapter for Ethereum and rollup transactions, under the campaign wizard, voting and campaign pages of the web client.
Key Features
- Node server routes aggregating a blockchain-analytics API with per-widget graceful degradation (Promise.allSettled)
- Integration with the team's L2 rollup REST API: campaign, vote and account queries, sequencer submission, ledger status
- MetaMask signing of rollup transactions via the rollup's serializer compiled to WASM (EIP-191), lazy-loaded on first sign
- BigInt-safe payloads (json-bigint) for 64-bit ids, nonces and vote weights; nonce 0 for wallets new to the rollup
- Ethereum (Sepolia) wallet flows with ethers.js: connect, account and chain changes, network switching, delegate-registry reads
- Campaign wizard, delegate voting and campaign pages; Clerk auth with PostgreSQL (Supabase)
Tech Stack
Backend & APIs
Blockchain
Frontend & Tooling
Challenges & Solutions
Signing Rollup Transactions With MetaMask
The rollup encodes transactions in its own binary format, but users only have MetaMask. Re-implementing that serialization in TypeScript would drift from the rollup's types with every protocol change.
Used the rollup's own serializer compiled to WebAssembly and published as an npm package with generated types. The client fetches the nonce, serializes the call in WASM, signs it with EIP-191 personal_sign and submits the signed transaction to the sequencer. The WASM module is lazy-loaded only when a user signs.
Silent Precision Loss Between the Rollup and JavaScript
Campaign ids, nonces and vote weights are 64-bit integers on the rollup side. JSON.stringify and JavaScript numbers round anything above 2^53 without an error.
Serialized call payloads with json-bigint and passed ids and chain ids as BigInt end to end, so large values reach the signer exactly as the rollup expects them.
The First Transaction From a New Wallet
A brand-new Ethereum address has no account on the rollup yet, so the account lookup returned 404 and the client could not build the user's first transaction.
Added a per-call error hook to the shared fetch wrapper that maps that 404 to nonce 0. Nonce plus chain id in the signed payload keep replay protection intact.
Dashboard Aggregation That Degrades Gracefully
The analytics dashboard needed several upstream calls per campaign to an external blockchain-analytics API; one slow or failing call used to break the whole page.
A Node route loads the organisation's campaigns, then fetches aggregations, interactions and daily change for each in parallel with Promise.allSettled at both levels. A failure nulls only that widget, and the route also returns status counts for the header.