Skip to content

Repository files navigation

Refunite Network Onboarding

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.

Table of Contents

Architecture Overview

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];
Loading

Key Features

  • 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.

Core Technologies

  • 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.

Onboarding Flow

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.

EIP-712 Signatures

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:

  1. The frontend constructs the EIP-712 typed data (NetworkInvite).
  2. The leader signs this typed data using their connected wallet.
  3. The signature, along with the typed data, is sent to a Server Action.
  4. The Server Action verifies the signature against the provided data and the inviter's address using viem utility functions.
  5. 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
Loading

Server Actions

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:

  1. src/app/actions/invite.ts: Handles invite creation, verification, and retrieval

    • createInvite: Creates a new invite with EIP-712 signed data
    • verifyInvite: Checks if an invite code is valid and unused
    • getInviteByCode: Retrieves invite data by code
  2. 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.

Getting Started

Prerequisites

Installation

  1. Clone the repository:

  2. Install dependencies:

    pnpm install
  3. Set up environment variables: Copy the .env.example file to .env.local and fill in the required values.

    cp .env.example .env.local
  4. Update the .env.local file 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
    

Running the Development Server

pnpm dev

Open http://localhost:3000 in your browser.

Environment Variables

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.

Database

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 database

Run 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.

Beneficiaries

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 StartSession once (POST /api/session), which sets an HttpOnly cookie for 24 hours (src/lib/session.ts, signed with SESSION_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/beneficiaries adds one (AddBeneficiary); GET /api/beneficiaries/list lists 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.

Disbursements

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.

  1. Create (POST /api/disbursements, leader-signed CreateDisbursement): 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 in leader_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).

  2. List (GET /api/disbursements/mine, with a Stellar session: the beneficiary signs StartStellarSession once via POST /api/session/stellar, which sets its own 24-hour cookie; empty for accounts that are not beneficiaries).

  3. Redeem (POST /api/disbursements/redeem, beneficiary-signed RedeemDisbursement): 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 a createAccount if the account does not exist yet.

  4. Cancel (POST /api/disbursements/cancel, leader-signed CancelDisbursement): the leader who created a still-pending disbursement 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).

API access

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 Relayer

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:

  1. Checks the inviter currently wears the leader hat.
  2. Verifies the EIP-712 signature and reserves the invite (src/lib/onboarding/reservations.ts).
  3. Sends Hats.mintHat(leaderHat, recipient) from the relayer and waits for the receipt.
  4. Sends HSG.claimSignerFor(leaderHat, recipient) to add the recipient as a Safe signer.
  5. Confirms the reservation, or rolls it back if a transaction fails.

Setting up a relayer for a chain:

  1. Create a new wallet and set its private key as RELAYER_PRIVATE_KEY (server-only; mark it sensitive in Vercel).
  2. Fund it with native gas (CELO on Celo, ETH on Sepolia).
  3. From the top hat, give the wallet an admin hat of the leader hat, e.g. Hats.transferHat of the level-1 hat from the old relayer, or Hats.mintHat of an unused admin hat.

The public /info page shows the relayer address and balance, and the Stellar treasury (balance, unpaid disbursements, low-balance warning).

Test setup on Sepolia

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.ts
  • DEPLOYER_PRIVATE_KEY: a wallet with some Sepolia ETH. It receives the top hat.
  • RELAYER_ADDRESS: the address of RELAYER_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.

BigInt Serialization/Deserialization

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.

Native mobile app development

We use Capacitor to wrap the NextJS frontend into an Android app

  • pnpm capacitor:sync
  • pnpm android:open which will open the repo in Android Studio

Troubleshooting

Common issues and solutions:

  1. 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
  2. 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)
  3. Database Access Issues

    • Check DATABASE_URL is set for the environment
    • Run pnpm db:migrate if tables are missing

Contributing

Contributions to the Refunite Network are welcome! To contribute:

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-feature)
  3. Make your changes (following the code style of the project)
  4. Commit your changes (git commit -m 'Add some amazing feature')
  5. Push to the branch (git push origin feature/amazing-feature)
  6. Open a Pull Request

Please make sure to update tests as appropriate and follow the existing code style.

License

This project is licensed under the MIT License.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages