A Next.js application facilitating a secure and streamlined onboarding process for new members into the Refunite network. It leverages EIP-712 signed typed data for enhanced security and user experience during critical operations like inviting new users and adding leaders.
- Refunite Network Onboarding
The application is built with Next.js, utilizing its App Router for routing and React Server Components for efficient rendering. Server Actions are employed for handling backend logic directly within React components, eliminating the need for traditional API routes for internal operations. Neon (Postgres, accessed through Drizzle ORM) stores invite and onboarding data, and a server-side relayer wallet sends the onboarding transactions (minting Hats and adding Safe signers).
graph TD
A[User Browser] --> B{Next.js Frontend};
B --> C[Next.js Server Actions];
C --> D{EIP-712 Signature Utils};
C --> E[Neon Postgres];
C --> F[Relayer wallet];
G[Silk Wallet/Metamask] <--> A;
F --> H[Blockchain Interaction];
- Role Management: Community leaders receive on-chain credentials through the Hats Protocol to attest to their roles.
- Decentralized Trust Network: No central control over the state; managed entirely by leaders themselves.
- Scalability: Designed to support up to 100,000 leaders, grouped by geographic or other predefined subsets.
- Secure Onboarding: Utilizes EIP-712 typed data signatures for inviting and adding new leaders, ensuring clarity and security for signers.
- Next.js: React framework for building the user interface and handling server-side logic with Server Actions.
- EIP-712: Standard for typed structured data signing, enhancing security and UX for wallet interactions.
- Viem: TypeScript interface for Ethereum, used for wallet interactions and cryptographic operations.
- Neon + Drizzle: Serverless Postgres, with a typed schema and committed migrations.
- Relayer wallet: Server-held key that pays gas for onboarding transactions.
- Hats Protocol: For on-chain role management and attestations.
- Silk Wallet / MetaMask: User wallets for interacting with the application and signing transactions/messages.
- Tailwind CSS & shadcn/ui: For styling and UI components.
The onboarding process involves either an existing leader inviting a new user or directly adding a new leader. Both flows leverage EIP-712 signed typed data for secure interactions.
To enhance security and provide a better user experience, the application uses EIP-712 for signing messages. This standard allows for structured, human-readable data to be presented to the user when they are asked to sign a message with their wallet (e.g., Silk Wallet or MetaMask).
The core components of an EIP-712 signature in this application are:
- Domain Separator: Defines the context of the signature (e.g., application name, version, chain ID, verifying contract).
- Typed Data: The actual message being signed, structured with clear field names and types.
When a leader initiates an invite or adds another leader:
- The frontend constructs the EIP-712 typed data (
NetworkInvite). - The leader signs this typed data using their connected wallet.
- The signature, along with the typed data, is sent to a Server Action.
- The Server Action verifies the signature against the provided data and the inviter's address using
viemutility functions. - If valid, the action proceeds (e.g., stores the invite or has the relayer mint a Hat).
Typed Data Format (NetworkInvite)
const types = {
NetworkInvite: [
{ name: "content", type: "string" },
{ name: "inviterAddress", type: "address" },
{ name: "nonce", type: "string" },
{ name: "createdAt", type: "uint256" },
],
};
// Example message structure
const message = {
content: "I authorize this invite to be created for the RelayID Network.",
inviterAddress: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826",
nonce: "a1b2c3d4e5f67890",
createdAt: 1678886400n, // Unix timestamp as BigInt
};This structured data is what the user sees and approves in their wallet, ensuring they understand what they are authorizing.
sequenceDiagram
participant UserFrontend as User (Frontend)
participant Wallet as User's Wallet
participant ServerAction as Next.js Server Action
participant DB as Neon Postgres
participant Relayer as Relayer wallet
participant Blockchain
alt Invite Flow / Add Leader Flow
UserFrontend->>Wallet: Request EIP-712 Signature (for NetworkInvite)
Wallet-->>UserFrontend: Provides Signature
UserFrontend->>ServerAction: Send recipient, typedData, signature
ServerAction->>ServerAction: Verify EIP-712 Signature against inviterAddress
alt Signature Valid
ServerAction->>DB: (If invite link) Mark invite as used
ServerAction->>Blockchain: Check inviter wears the leader hat
ServerAction->>Relayer: mintHat(leaderHat, recipient)
Relayer->>Blockchain: Mint Hat Transaction
ServerAction->>Relayer: claimSignerFor(leaderHat, recipient)
Relayer->>Blockchain: Add Safe signer via HSG
Blockchain-->>ServerAction: Transaction receipts
ServerAction-->>UserFrontend: Success (mintHatTxHash, claimSignerTxHash)
else Signature Invalid
ServerAction-->>UserFrontend: Error (Invalid Signature)
end
end
This project utilizes Next.js Server Actions to handle backend logic and data mutations. Server Actions are functions that run on the server but can be called directly from React Server Components or Client Components.
Key Server Actions in this project:
-
src/app/actions/invite.ts: Handles invite creation, verification, and retrievalcreateInvite: Creates a new invite with EIP-712 signed dataverifyInvite: Checks if an invite code is valid and unusedgetInviteByCode: Retrieves invite data by code
-
src/app/actions/onboard.ts: Handles onboarding through the relayer (src/lib/relayer)addLeaderViaSignedTypedData: Processes verified signatures to add new leaders via the Hats Protocol
These actions provide a streamlined way to handle server-side operations without creating separate API routes.
- Node.js (v18 or later)
- pnpm
- A Neon Postgres database (for onboarding data)
- A relayer wallet that wears an admin hat of the leader hat (see Onboarding Relayer)
-
Clone the repository:
-
Install dependencies:
pnpm install
-
Set up environment variables: Copy the
.env.examplefile to.env.localand fill in the required values.cp .env.example .env.local
-
Update the
.env.localfile with your own values:# Database (Neon Postgres connection string) DATABASE_URL=postgresql://... # Relayer RELAYER_PRIVATE_KEY=your_relayer_private_key NEXT_PUBLIC_HSG_CONTRACT_ADDRESS=your_hsg_address # Hats Protocol NEXT_PUBLIC_HATS_TREE_ID=your_hats_tree_id NEXT_PUBLIC_HATS_LEADER_ID=your_leader_hat_id NEXT_PUBLIC_HATS_LEADER_SAFE_ACCOUNT=your_leader_safe_address # Chain NEXT_PUBLIC_DEFAULT_CHAIN=sepolia # or celo NEXT_PUBLIC_CHAIN_ID=11155111 # or 42220
pnpm devOpen http://localhost:3000 in your browser.
See .env.example for the full list, split into required and optional. The main ones:
| Variable | Description | Public? |
|---|---|---|
RELAYER_PRIVATE_KEY |
Relayer wallet private key | No |
NEXT_PUBLIC_HSG_CONTRACT_ADDRESS |
Hats Signer Gate for the leaders' Safe | Yes |
NEXT_PUBLIC_HATS_TREE_ID |
Hats Protocol tree ID | Yes |
NEXT_PUBLIC_HATS_LEADER_ID |
Leader hat ID in Hats Protocol | Yes |
NEXT_PUBLIC_HATS_LEADER_SAFE_ACCOUNT |
Safe account address for leaders | Yes |
NEXT_PUBLIC_CHAIN_ID |
Blockchain network chain ID | Yes |
DATABASE_URL |
Neon Postgres connection string | No |
Important: NEXT_PUBLIC_ variables are exposed to the browser. Do not store sensitive secrets with this prefix.
Data lives in Postgres (Neon), accessed through Drizzle ORM:
- Schema:
src/lib/db/schema.ts - Client:
src/lib/db/index.ts(Neon serverless HTTP driver) - Queries:
src/lib/database/service.ts(DB.*) - Migrations:
drizzle/, generated from the schema and committed
Tables: invitations, reservations, completions, security_events and audit_log.
pnpm db:generate # after changing schema.ts: write a new migration into drizzle/
pnpm db:migrate # apply pending migrations to DATABASE_URL
pnpm db:studio # browse the databaseRun pnpm db:migrate against each environment's database before deploying code that needs a new migration. The database tests (test/lib/database.test.ts) run the migrations against an in-memory Postgres (PGlite), so they need no database.
Leaders can register beneficiaries at /beneficiaries. A beneficiary belongs to the leader who added them: only that leader can see them (and, later, disburse to them).
- Adding is signed by the leader (EIP-712) and checked by
verifyLeaderAction(src/lib/signed-actions): recent signature, signer wears the Community Leader hat, and each nonce is single-use (leader_action_nonces). - Listing needs no per-request signature: the wallet signs
StartSessiononce (POST /api/session), which sets an HttpOnly cookie for 24 hours (src/lib/session.ts, signed withSESSION_SECRET). The session only proves the address; the leader hat is checked on every request, and anything that changes state still needs its own signature. Logging out ends it (DELETE /api/session). POST /api/beneficiariesadds one (AddBeneficiary);GET /api/beneficiaries/listlists the signed-in leader's own.- A beneficiary is a Stellar account address (
G…). It does not need to exist on the network yet: the first payment to a new account creates it.
A leader can disburse XLM to their own beneficiaries (from /beneficiaries); the beneficiary redeems it in the mobile app, signing with their Stellar key, and the XLM is sent to their Stellar account (see docs/mobile-redeem.md). The web app does not link to redemption; its /redeem page (signing with Freighter) is kept, unlinked, for testing.
-
Create (
POST /api/disbursements, leader-signedCreateDisbursement): inside one transaction holding an advisory lock, the server checks the beneficiary is the leader's, the amount (max 1 XLM), the leader's rolling 24h total (max 10 XLM), their allowance (100 XLM to start + admin credits inleader_allowance_credits, minus everything disbursed), and that the treasury covers every unpaid disbursement (keeping a 5 XLM reserve). Limits are env settings (DISBURSE_*). If the beneficiary's account does not exist yet, the amount must be at least 1 XLM (the network's minimum to create an account). -
List (
GET /api/disbursements/mine, with a Stellar session: the beneficiary signsStartStellarSessiononce viaPOST /api/session/stellar, which sets its own 24-hour cookie; empty for accounts that are not beneficiaries). -
Redeem (
POST /api/disbursements/redeem, beneficiary-signedRedeemDisbursement): the row is claimed (pending → redeeming) so it is paid at most once, then XLM is sent from the treasury (WALLET_SOURCE_PRIVATE_KEY): a payment, or acreateAccountif the account does not exist yet. -
Cancel (
POST /api/disbursements/cancel, leader-signedCancelDisbursement): the leader who created a still-pendingdisbursement can withdraw it; the amount returns to their allowance and daily limit.
Statuses: pending, redeeming, redeemed (with tx_hash), needs_review (a payment was submitted but not confirmed; never retried automatically) and cancelled. Failures before any funds move return the disbursement to pending with last_error.
The payment's transaction hash is recorded before it is submitted, so every payment that might have landed can be looked up. The 5-minute cron (/api/system/cleanup) runs reconcileDisbursements on rows stuck in redeeming / needs_review for over 5 minutes: success → redeemed; failed, never submitted, or not found while under an hour old (transactions expire 60 seconds after signing) → back to pending; not found and older (the RPC only keeps recent history) → left in needs_review for a person to check on a Stellar explorer.
Beneficiary actions are signed with the beneficiary's Stellar key (SEP-53 message signing; the text comes from src/lib/stellar/signed-actions.ts and names the network). They are checked like leader actions (fresh, single-use nonce), but the signer must be a registered beneficiary instead of a hat wearer (verifyBeneficiaryAction in src/lib/signed-actions).
| Route | Who can call it |
|---|---|
/api/health, /api/metrics |
anyone (public, aggregate data) |
POST /api/invites |
a current leader, with a fresh EIP-712 signature |
POST /api/invites/verify |
anyone holding the invite code |
POST /api/onboarding/* |
an inviter's EIP-712 signature; the relayer checks they are a leader |
POST /api/beneficiaries, /api/disbursements, /api/disbursements/cancel |
a leader's per-action signature |
POST /api/disbursements/redeem |
the beneficiary's per-action Stellar signature |
GET /api/beneficiaries/list |
a session (POST /api/session) |
GET /api/disbursements/mine |
a Stellar session (POST /api/session/stellar) |
POST /api/users/delete |
a session for the address being deleted |
POST /api/messages/feedback |
anyone; validated, size-limited and escaped for Slack (not rate limited) |
POST /api/reservations/verify, POST /api/system/cleanup |
Authorization: Bearer <RELAYID_APP_API_TOKEN> |
GET /api/system/cleanup (cron) |
Authorization: Bearer <CRON_SECRET>, sent by Vercel |
Onboarding used to run through an OpenZeppelin Defender Action. Defender shut down on 2026-07-01, so the app now sends the transactions itself from a relayer wallet (src/lib/relayer).
For each onboarding, addLeaderViaSignedTypedData in src/app/actions/onboard.ts:
- Checks the inviter currently wears the leader hat.
- Verifies the EIP-712 signature and reserves the invite (
src/lib/onboarding/reservations.ts). - Sends
Hats.mintHat(leaderHat, recipient)from the relayer and waits for the receipt. - Sends
HSG.claimSignerFor(leaderHat, recipient)to add the recipient as a Safe signer. - Confirms the reservation, or rolls it back if a transaction fails.
Setting up a relayer for a chain:
- Create a new wallet and set its private key as
RELAYER_PRIVATE_KEY(server-only; mark it sensitive in Vercel). - Fund it with native gas (CELO on Celo, ETH on Sepolia).
- From the top hat, give the wallet an admin hat of the leader hat, e.g.
Hats.transferHatof the level-1 hat from the old relayer, orHats.mintHatof an unused admin hat.
The public /info page shows the relayer address and balance, and the Stellar treasury (balance, unpaid disbursements, low-balance warning).
scripts/setup-test-hats.ts creates a Hats tree you control, shaped like production, plus a new Safe and Hats Signer Gate (v2), so you can test onboarding without access to an existing top hat:
It mirrors the production tree (Celo tree 22):
X "RelayID" top hat (deployer)
└─ X.1 "Network" unworn
└─ X.1.1 "Community Leader Admin" worn by RELAYER_ADDRESS; HSG owner hat
└─ X.1.1.1 "Community Leader" HSG signer hat; deployer is its eligibility module
DEPLOYER_PRIVATE_KEY=0x... RELAYER_ADDRESS=0x... FIRST_LEADER=0x... node scripts/setup-test-hats.tsDEPLOYER_PRIVATE_KEY: a wallet with some Sepolia ETH. It receives the top hat.RELAYER_ADDRESS: the address ofRELAYER_PRIVATE_KEY. Fund it with Sepolia ETH as well.FIRST_LEADER(optional): your app wallet. It gets the leader hat and becomes a Safe signer, so it can send the first invite.RPC_URL(optional): defaults to a public Sepolia RPC.
The script prints the NEXT_PUBLIC_* values to put in .env. It needs Node 22.18 or later, which runs TypeScript directly.
JavaScript cannot natively serialize BigInt values to JSON. The utility functions in src/lib/utils/serialize.ts handle this conversion for storage and retrieval:
// When storing data with BigInt values
const serializableData = serializeBigInts(dataWithBigInts);
// When retrieving stored data
const dataWithBigInts = deserializeBigInts(retrievedData);These utilities are used automatically in the server actions when handling typed data.
We use Capacitor to wrap the NextJS frontend into an Android app
pnpm capacitor:syncpnpm android:openwhich will open the repo in Android Studio
Common issues and solutions:
-
Invalid EIP-712 Signature
- Ensure the wallet is connected to the correct network (check CHAIN_ID)
- Verify the inviter has proper permissions to create invites
-
Relayer Transaction Errors
- Check the relayer wallet has sufficient funds for gas (see
/info) - Verify the relayer wallet wears an admin hat of the leader hat (
Hats.isAdminOfHat)
- Check the relayer wallet has sufficient funds for gas (see
-
Database Access Issues
- Check
DATABASE_URLis set for the environment - Run
pnpm db:migrateif tables are missing
- Check
Contributions to the Refunite Network are welcome! To contribute:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Make your changes (following the code style of the project)
- Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Please make sure to update tests as appropriate and follow the existing code style.
This project is licensed under the MIT License.