Skip to content

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#

  1. Overview
  2. How It Works
  3. System Architecture
  4. Tech Stack
  5. Network Information
  6. Getting Started
  7. Environment Variables
  8. Local Development
  9. Testing
  10. Deployment
  11. Hosting the Front End
  12. Security Model
  13. Known Limitations
  14. Production Readiness Checklist
  15. 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

  1. The sender connects a wallet and chooses a token, an amount, and an expiry window.
  2. 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.
  3. 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

  1. The recipient opens the link and connects a wallet of their choice.
  2. Their browser uses the claim key from the link to sign an EIP-712 message that explicitly names the recipient's own wallet address.
  3. The contract verifies that signature and releases the tokens to that address.

If nothing happens

  1. 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:

RoutePurpose
/Landing page, explainer, token registry
/docsThis documentation
/createCreate a new drop (token, amount, expiry → claim link)
/claim?id=NRecipient-facing claim screen
/dashboardCreator's view of their own drops
/drop?id=NDrop 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)#

ScreenSourceCalls
Claim / drop detailgetDrop + claimAddressOf1 batched call
DashboardDropCreated event logs filtered by creator1 log query + 1 batched call
BalancesbalanceOf1 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#

LayerTechnology
Smart contractsSolidity 0.8.30, Foundry, OpenZeppelin 5.5.0
EVM targetCancun (transient storage confirmed live on Robinhood Chain)
Frontend frameworkNext.js 15 (App Router), React 19, TypeScript (strict mode)
Blockchain I/Oviem 2, wagmi 2, TanStack Query 5
StylingTailwind CSS 3
TestingFoundry (unit, fuzz, invariant, fork tests), Vitest

5. Network Information#

Flur runs on Robinhood Chain, an Arbitrum Orbit/Nitro rollup.

MainnetTestnet
Chain ID466346630
RPC URLhttps://rpc.mainnet.chain.robinhood.comhttps://rpc.testnet.chain.robinhood.com
Gas tokenETHETH
Explorerhttps://robinhoodchain.blockscout.comhttps://explorer.testnet.chain.robinhood.com
Faucethttps://faucet.testnet.chain.robinhood.com
FlurEscrow contract0x2172D0b2b82ADfFeF1424D0DdC8E5200Ac9b9a47 (deployed block 50688411)0xc2650640e309814781e60EA21eE2c22Dc2d43f3d (deployed block 110236157)

Important: Since this is an Arbitrum-based rollup, block.number reflects an L1 block height, not chain time. All expiry logic in the contract uses block.timestamp instead.


6. Getting Started#

Requirements: Foundry and Node.js 20 or newer.

git clone --recurse-submodules <repository-url> && cd Flur
npm install
forge build

If the repository was cloned without submodules:

git submodule update --init --recursive

Dependencies 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:

VariablePurpose
DEPLOY_NETWORKtestnet or mainnet. Verified against the live chain ID before any deploy.
RPC_URL_MAINNETMainnet RPC endpoint
RPC_URL_TESTNETTestnet RPC endpoint
BLOCKSCOUT_API_KEYAny non-empty placeholder (Blockscout doesn't require a real key)

apps/web/.env.local (frontend)#

Copy from apps/web/.env.example:

VariableRequiredPurpose
NEXT_PUBLIC_CHAIN_IDYes4663 or 46630
NEXT_PUBLIC_ESCROW_ADDRESSYesDeployed FlurEscrow contract address
NEXT_PUBLIC_ESCROW_DEPLOY_BLOCKRecommendedBounds the dashboard's log scan range
NEXT_PUBLIC_RPC_URLNoDefaults to the public endpoint
NEXT_PUBLIC_WALLETCONNECT_PROJECT_IDNoEnables mobile wallet connections
NEXT_PUBLIC_APP_URLNoOrigin used when building claim links
NEXT_PUBLIC_TESTNET_TOKENSTestnet onlyFormat: 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.

anvil --chain-id 46630
DEPLOY_NETWORK=testnet forge script contracts/script/Deploy.s.sol:Deploy \
  --rpc-url http://127.0.0.1:8545 \
  --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
  --broadcast
cd 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:3000

The 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.com

Deploy 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 checks

Coverage 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:web

End-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 run

A 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 -vv

Both 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/api

The --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/api

For 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/api

11. Hosting the Front End#

npm run web:build     # outputs a static site to apps/web/out
npm run web:serve     # preview at http://localhost:4321

The 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:

SettingValue
Root DirectoryRepository root (not apps/web)
Framework PresetOther
Build Commandnpm run web:build
Output Directoryapps/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 reads vercel.json at 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. createDrop records 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, or decimals, 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. .env files are git-ignored and never committed.

Trust model#

PartyCan 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 contractOnly in the sense that an undiscovered bug would be unrecoverable, since it's immutable
Whoever holds the claim linkYes — this is the intended product behavior
The chain sequencerCan affect ordering only; cannot forge a valid claim signature
The token contract itselfA 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:

  1. State is updated to a terminal status before any transfer occurs (checks-effects-interactions).
  2. ReentrancyGuardTransient guards all three entry points as defense in depth.
  3. 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.