Flur Documentation#
Send ERC-20 tokens with a link, not a wallet address.
Flur is a non-custodial token-transfer application built for Robinhood Chain. Instead of requiring a sender to know the recipient's wallet address, tokens are escrowed on-chain against a one-time claim link. Anyone holding that link can connect their own wallet and claim the funds. If nobody claims it before it expires, the original sender can withdraw the tokens back.
There is no backend, no database, and no admin key. The entire product is a single immutable smart contract plus a static front end that talks to it directly.
Table of Contents#
- Overview
- How It Works
- System Architecture
- Tech Stack
- Network Information
- Getting Started
- Environment Variables
- Local Development
- Testing
- Deployment
- Hosting the Front End
- Security Model
- Known Limitations
- Production Readiness Checklist
- License
1. Overview#
Flur solves a simple problem: sending crypto usually requires knowing the recipient's wallet address ahead of time. Flur removes that requirement by letting a sender lock tokens into escrow and hand out a link instead. The recipient opens the link, connects any wallet they like, and the contract pays that wallet directly — the sender never needs to know who it will be in advance.
Key product properties:
- No custodian. The smart contract has no owner, no admin controls, and no upgrade mechanism. Once deployed, its behavior cannot be changed by anyone, including the Flur team.
- No backend. The web app is a fully static site. All reads and writes happen directly between the visitor's browser and the blockchain RPC endpoint.
- Refundable. If a drop isn't claimed before its expiry, only the original creator can reclaim the tokens.
- Link security. Each claim link is cryptographically bound to whoever opens it first via an EIP-712 signature, which prevents the link from being intercepted and redirected mid-flight.
2. How It Works#
Sending tokens
- The sender connects a wallet and chooses a token, an amount, and an expiry window.
- The browser locally generates a single-use "claim key" (an ephemeral keypair) and deposits the tokens into the escrow contract, associating them with the public half of that key.
- The sender receives a shareable link containing the drop ID and the private claim key (the key lives only in the URL fragment, which browsers never transmit to any server).
Receiving tokens
- The recipient opens the link and connects a wallet of their choice.
- Their browser uses the claim key from the link to sign an EIP-712 message that explicitly names the recipient's own wallet address.
- The contract verifies that signature and releases the tokens to that address.
If nothing happens
- Once the expiry time passes, only the original sender can trigger a refund and reclaim the tokens.
Because the signature is bound to a specific recipient address, a claim transaction cannot be observed in transit and redirected to someone else — this is the core security guarantee of the design (see Security Model).
3. System Architecture#
Flur has exactly three moving parts: a browser, an RPC endpoint, and one smart contract. There is deliberately no database, no indexing service, and no server-side API.
Browser (static Next.js app)
│
│ reads state via eth_call / getLogs
│ writes via eth_sendRawTransaction (through the user's wallet)
▼
FlurEscrow.sol (immutable contract on Robinhood Chain)
- createDrop
- claim (verified by EIP-712 signature)
- refund (creator only, after expiry)Why no backend?#
- Escrow state is financial state. A separate database copy could drift out of sync with the chain, and that mismatch would surface at the worst possible time.
- Without a server, there's no server-side secret to steal and no operator who could be pressured to block a claim.
- The front end can be hosted anywhere as static files. If the hosting goes down, funds remain fully accessible by calling the contract directly.
The smart contract#
FlurEscrow.sol is a single, unproxied contract relying only on well-audited OpenZeppelin building blocks (SafeERC20, ECDSA, EIP712, ReentrancyGuardTransient). Each drop is represented by a compact struct (creator, expiry, status, token, creation time, amount, recipient), tightly packed to minimize storage costs.
State machine:
createDrop
│
▼
┌── Active ──┐
│ │
claim refund
(valid sig, (creator only,
before expiry) after expiry)
│ │
▼ ▼
Claimed Refunded
(terminal) (terminal)Expiry is not stored as a separate state — a lapsed drop simply remains "Active" but the contract refuses to release funds for it. This avoids needing anyone to pay gas just to flip a status flag.
The contract measures the actual token balance received rather than trusting the requested amount, which protects against fee-on-transfer tokens leaving the escrow short of what it owes.
Claim keys#
Each drop gets a fresh, single-purpose keypair generated in the browser. The public address is registered on-chain; the private key travels only in the URL fragment (never sent to any server). To claim, the recipient's browser signs a structured message naming their own address, and the contract checks that signature against the registered claim address. Once a claim address is used, it can never be reused for another drop — this prevents replay attacks across drops.
Front end#
Built with Next.js App Router as a fully static export, with five routes:
| Route | Purpose |
|---|---|
/ | Landing page, explainer, token registry |
/docs | This documentation |
/create | Create a new drop (token, amount, expiry → claim link) |
/claim?id=N | Recipient-facing claim screen |
/dashboard | Creator's view of their own drops |
/drop?id=N | Drop detail and refund action |
All blockchain interaction happens client-side. The codebase is layered as components → hooks → lib, with the lib layer kept framework-free (no React imports) so it can be unit tested in isolation. The contract ABI used by the front end is auto-generated from the compiled Foundry artifact rather than hand-maintained, preventing drift between the deployed contract and the app.
Data sources (no indexer)#
| Screen | Source | Calls |
|---|---|---|
| Claim / drop detail | getDrop + claimAddressOf | 1 batched call |
| Dashboard | DropCreated event logs filtered by creator | 1 log query + 1 batched call |
| Balances | balanceOf | 1 call |
The one known trade-off of skipping an indexer: dashboard log queries need a bounded block range (set via an environment variable), otherwise a rate-limited public RPC will reject the query.
4. Tech Stack#
| Layer | Technology |
|---|---|
| Smart contracts | Solidity 0.8.30, Foundry, OpenZeppelin 5.5.0 |
| EVM target | Cancun (transient storage confirmed live on Robinhood Chain) |
| Frontend framework | Next.js 15 (App Router), React 19, TypeScript (strict mode) |
| Blockchain I/O | viem 2, wagmi 2, TanStack Query 5 |
| Styling | Tailwind CSS 3 |
| Testing | Foundry (unit, fuzz, invariant, fork tests), Vitest |
5. Network Information#
Flur runs on Robinhood Chain, an Arbitrum Orbit/Nitro rollup.
| Mainnet | Testnet | |
|---|---|---|
| Chain ID | 4663 | 46630 |
| RPC URL | https://rpc.mainnet.chain.robinhood.com | https://rpc.testnet.chain.robinhood.com |
| Gas token | ETH | ETH |
| Explorer | https://robinhoodchain.blockscout.com | https://explorer.testnet.chain.robinhood.com |
| Faucet | — | https://faucet.testnet.chain.robinhood.com |
FlurEscrow contract | 0x2172D0b2b82ADfFeF1424D0DdC8E5200Ac9b9a47 (deployed block 50688411) | 0xc2650640e309814781e60EA21eE2c22Dc2d43f3d (deployed block 110236157) |
Important: Since this is an Arbitrum-based rollup,
block.numberreflects an L1 block height, not chain time. All expiry logic in the contract usesblock.timestampinstead.
6. Getting Started#
Requirements: Foundry and Node.js 20 or newer.
git clone --recurse-submodules <repository-url> && cd Flur
npm install
forge buildIf the repository was cloned without submodules:
git submodule update --init --recursiveDependencies are pinned to exact submodule commits (forge-std, openzeppelin-contracts at tag v5.5.0) to guarantee reproducible builds.
7. Environment Variables#
Two separate .env files are used, and neither should ever contain a private key.
Root .env (contract tooling)#
Copy from .env.example:
| Variable | Purpose |
|---|---|
DEPLOY_NETWORK | testnet or mainnet. Verified against the live chain ID before any deploy. |
RPC_URL_MAINNET | Mainnet RPC endpoint |
RPC_URL_TESTNET | Testnet RPC endpoint |
BLOCKSCOUT_API_KEY | Any non-empty placeholder (Blockscout doesn't require a real key) |
apps/web/.env.local (frontend)#
Copy from apps/web/.env.example:
| Variable | Required | Purpose |
|---|---|---|
NEXT_PUBLIC_CHAIN_ID | Yes | 4663 or 46630 |
NEXT_PUBLIC_ESCROW_ADDRESS | Yes | Deployed FlurEscrow contract address |
NEXT_PUBLIC_ESCROW_DEPLOY_BLOCK | Recommended | Bounds the dashboard's log scan range |
NEXT_PUBLIC_RPC_URL | No | Defaults to the public endpoint |
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID | No | Enables mobile wallet connections |
NEXT_PUBLIC_APP_URL | No | Origin used when building claim links |
NEXT_PUBLIC_TESTNET_TOKENS | Testnet only | Format: symbol:name:decimals:address, comma-separated |
All frontend variables are public by design — there are no server-side secrets anywhere in the app. If misconfigured, the app shows a setup screen listing exactly what's missing rather than failing silently at runtime.
8. Local Development#
Because the app depends on a deployed escrow contract, there are two ways to run it locally. Both seed a set of sample drops in every possible state (available, claimed, expired) with ready-to-use claim links.
Option A — Fully offline (recommended)#
anvil --chain-id 46630DEPLOY_NETWORK=testnet forge script contracts/script/Deploy.s.sol:Deploy \
--rpc-url http://127.0.0.1:8545 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
--broadcastcd apps/web && node scripts/seed-local.mjs <escrow-address-from-above>The seed script deploys mock ERC-20 tokens and prints the environment values to paste into apps/web/.env.local. Then run:
npm run web:dev # http://localhost:3000The private key above is a well-known public Anvil development key — safe for local testing only, and must never be used on a real network.
Option B — Against a mainnet fork#
Closer to production behavior, using real tokens by impersonating a funded holder:
anvil --fork-url https://rpc.mainnet.chain.robinhood.comDeploy with DEPLOY_NETWORK=mainnet, run the same seed script (it auto-detects chain 4663 and switches to real tokens), and set NEXT_PUBLIC_CHAIN_ID=4663.
The public RPC endpoint is rate-limited and is not an archive node — a running fork typically stops resolving after roughly 15 minutes. Prefer Option A for extended browsing sessions.
9. Testing#
Smart contract tests#
forge test # unit, fuzz, and invariant suites (offline)
forge test --gas-report
FOUNDRY_PROFILE=ci forge test # 5,000 fuzz runs, deeper invariant checksCoverage includes standard flows, authorization boundaries, every state transition, expiry edge cases, adversarial ERC-20 behavior (fee-on-transfer, missing return values, silent no-ops, reverting metadata calls), reentrancy across all entry points, and five system-wide invariants checked over randomized call sequences.
Frontend tests#
npm run typecheck
npm run test:webEnd-to-end testing against a live fork#
This is the most meaningful test in the repository — it exercises the browser's actual signing logic against a real deployed contract using real tokens.
# 1. Fork mainnet
anvil --fork-url https://rpc.mainnet.chain.robinhood.com
# 2. Deploy against the fork
DEPLOY_NETWORK=mainnet forge script contracts/script/Deploy.s.sol:Deploy \
--rpc-url http://127.0.0.1:8545 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
--broadcast
# 3. Run the E2E suite
cd apps/web
E2E_RPC_URL=http://127.0.0.1:8545 E2E_ESCROW_ADDRESS=<deployed-address> npx vitest runA Foundry-based fork suite also exercises the escrow against real USDG, WETH, and tokenized AAPL:
RPC_URL_MAINNET=https://rpc.mainnet.chain.robinhood.com forge test --match-contract Fork -vvBoth fork suites are skipped automatically when their required environment variables aren't set, so the default npm test run stays fully offline.
The public RPC prunes historical state, so a fork pinned to a fixed block will stop working within days. To reproduce a fixed-block run, use an archive RPC provider and set
FORK_BLOCK.
10. Deployment#
Testnet deployment#
Never pass a raw private key on the command line for a real network deployment. Use an encrypted keystore or a hardware wallet.
# One-time: import a deployer key into an encrypted local keystore
cast wallet import flur-deployer --interactive
# Fund it from the testnet faucet, then:
DEPLOY_NETWORK=testnet forge script contracts/script/Deploy.s.sol:Deploy \
--rpc-url https://rpc.testnet.chain.robinhood.com \
--account flur-deployer \
--broadcast --verify \
--verifier blockscout \
--verifier-url https://explorer.testnet.chain.robinhood.com/apiThe --verify flags are required — Foundry has no built-in registry entry for this Orbit-based chain, so verification fails without them.
The deployment script refuses to run if the configured DEPLOY_NETWORK doesn't match the chain ID reported by the RPC, if the network name isn't recognized, or if the variable is unset. After deploying, it automatically re-reads the contract to confirm the constants and the EIP-712 domain match expectations.
Once deployed, update NEXT_PUBLIC_ESCROW_ADDRESS and NEXT_PUBLIC_ESCROW_DEPLOY_BLOCK in apps/web/.env.local.
Contract verification#
forge verify-contract <address> contracts/src/FlurEscrow.sol:FlurEscrow \
--chain-id 46630 \
--verifier blockscout \
--verifier-url https://explorer.testnet.chain.robinhood.com/apiFor mainnet, swap in --chain-id 4663 and the mainnet Blockscout URL. Running the deploy script with --verify performs this automatically.
Mainnet deployment#
Do not deploy to mainnet until every item in the Production Readiness Checklist is complete.
DEPLOY_NETWORK=mainnet forge script contracts/script/Deploy.s.sol:Deploy \
--rpc-url https://rpc.mainnet.chain.robinhood.com \
--account flur-deployer \
--broadcast --verify \
--verifier blockscout \
--verifier-url https://robinhoodchain.blockscout.com/api11. Hosting the Front End#
npm run web:build # outputs a static site to apps/web/out
npm run web:serve # preview at http://localhost:4321The build output is a fully static site with no Node server, no serverless functions, and no API routes — every interaction happens directly in the visitor's browser. Upload the output folder to any static host: Cloudflare Pages, Netlify, GitHub Pages, S3, or IPFS.
Deploying to Vercel#
A vercel.json at the repository root already defines the build configuration:
| Setting | Value |
|---|---|
| Root Directory | Repository root (not apps/web) |
| Framework Preset | Other |
| Build Command | npm run web:build |
| Output Directory | apps/web/out |
Every NEXT_PUBLIC_* variable must be set directly in the Vercel dashboard, since .env.local is git-ignored and isn't included when Vercel clones the repository. These values are compiled into the app at build time, so changing them requires triggering a new deployment manually.
Two routing details worth knowing:
- Drop IDs are query parameters (
/claim?id=12#k=...), not path segments, since a static export can't pre-generate an unbounded number of dynamic routes. - Security headers depend on the host. A static export has no server to apply them at runtime, so they cannot live in the Next.js config. Cloudflare Pages and Netlify read
apps/web/public/_headers; Vercel ignores that file entirely and readsvercel.jsonat the repository root instead. Both files carry the same rules — change one and you must change the other.
12. Security Model#
Full technical rationale lives in the repository's docs/SECURITY.md. Summary of the key guarantees:
- No admin control. No owner, no pause switch, no upgrade path, no
delegatecall. Once deployed, the contract's behavior is permanently fixed. - Claims are bound to a recipient. Because the EIP-712 signature names the receiving address explicitly, a pending claim transaction cannot be intercepted and redirected to a different wallet.
- A claim link functions as a bearer instrument. Whoever holds the complete link before the intended recipient can claim the drop — this is communicated clearly in the UI.
- Received amounts are measured, not assumed.
createDroprecords the actual balance change, so tokens with transfer fees can't leave the contract owing more than it holds. - No reliance on token metadata. The contract never reads a token's
symbol,name, ordecimals, since symbols on this chain are not guaranteed to be unique (multiple distinct contracts have been observed sharing the same symbol). - No secrets exist anywhere. No server, no database, no API keys.
.envfiles are git-ignored and never committed.
Trust model#
| Party | Can they take your tokens? |
|---|---|
| Flur (the operator) | No — no admin key exists; at most the website could go offline, but funds stay accessible directly on-chain |
| The escrow contract | Only in the sense that an undiscovered bug would be unrecoverable, since it's immutable |
| Whoever holds the claim link | Yes — this is the intended product behavior |
| The chain sequencer | Can affect ordering only; cannot forge a valid claim signature |
| The token contract itself | A malicious token could misbehave with its own balances; mitigated by front-end curation |
Reentrancy protection#
Every external call in the contract is a token transfer, and tokens can execute arbitrary code. Protection is layered:
- State is updated to a terminal status before any transfer occurs (checks-effects-interactions).
ReentrancyGuardTransientguards all three entry points as defense in depth.- The contract never accepts native ETH, eliminating an entire class of callback surface.
Invariant testing#
Five system-wide properties are fuzz-tested across randomized sequences of create/claim/refund/time-warp actions, including that the escrow always holds enough value to cover every active drop, and that a used claim address can never be reused.
13. Known Limitations#
Being transparent about what Flur intentionally does not do:
- Recipients pay their own gas. There is no relayer or sponsored-transaction flow — the recipient needs a small amount of ETH on Robinhood Chain to claim.
- Rebasing tokens are not supported. The contract records a fixed amount at creation time; a token whose balance changes independently would break that accounting. None are currently listed in the token registry.
- A leaked link cannot be revoked before it expires. If a claim link is exposed, the only remedies are for the intended recipient to claim first, or to wait for expiry and issue a refund.
- The dashboard requires a bounded block range. Without setting the deploy-block environment variable, log queries scan from genesis, which a rate-limited public RPC will reject.
- Public RPC endpoints are rate-limited and not suitable for production-scale traffic — a dedicated RPC provider is recommended before going live.
14. Production Readiness Checklist#
Items already completed:
- Contract compiles cleanly with zero warnings
- Formatting and linting pass (
forge fmt --check,forge build) - Full test suite passes (unit, fuzz, invariant)
- Invariant campaign confirmed non-vacuous (all paths genuinely exercised)
- Adversarial ERC-20 behavior covered by tests
- Reentrancy tested on all three entry points
- Expiry boundaries tested on both sides
- Fork tests pass against real mainnet tokens
- Browser signing verified against a live deployed contract
- Frontend production build succeeds
- Deployment network guards verified to reject mismatched configurations
- No secrets committed; every variable documented in
.env.example - Deployed and verified on testnet
- Every contract entry point exercised on a live public network
- Mainnet token registry re-verified directly on-chain
- Static export in place with security headers relocated appropriately
- App defaults to mainnet and refuses to run when misconfigured
- Deployed on mainnet (contract confirmed byte-identical to the local build)
Items still open before mainnet is considered production-ready:
- Verify the contract source on the mainnet block explorer (currently blocked by an explorer-side access restriction; manual verification pending)
- Obtain a WalletConnect project ID — required for mobile wallets to connect at all, which matters heavily given the link-sharing use case
- Provision a dedicated RPC endpoint for production traffic instead of the shared public one
- Manual end-to-end pass with a real browser wallet (create and claim)
- Manual pass on a real mobile device, the primary recipient path
- Confirm the hosting provider actually serves the security headers file
- Independent third-party security review before the contract holds meaningful value
15. License#
MIT.
This documentation was compiled from the project repository for internal reference and onboarding. For the latest source of truth, always refer to the repository itself.