Skip to content

pukoa420/payname

Repository files navigation

payname


Swiss crypto payroll powered by ENS subnames, Base, and passkey smart accounts.

Employers mint an ENS subname per employee (alice_smith.acme.payname.eth), store HR data as on-chain text records, and run gasless USDC batch payroll in a single transaction. Employees activate their account with Face ID or fingerprint — no wallet setup, no seed phrases.


What it does

For employers

  1. Create a company namespace — register your company subname (e.g. acme.payname.eth) as your on-chain payroll identity.
  2. Invite employees — fill in name, role, department, salary, and start date. The app mints an ENS subname for each employee and stores all HR data as ENS text records in the EmployeeRegistry smart contract.
  3. Run payroll — one click sends USDC to every employee simultaneously via a gasless batch transaction. No gas costs for the employer.
  4. Generate payslips — Swiss-compliant PDF payslips with gross/net breakdown, CHF equivalent, and deductions.

For employees

  1. Receive an invitation link from their employer (e.g. /register?subname=alice_smith.acme.payname.eth).
  2. Create a passkey wallet using Face ID, fingerprint, or PIN — no crypto knowledge required.
  3. Wallet is linked to their ENS subname on-chain. Future salary payments go directly to this address.
  4. View their portal — salary breakdown, payslip downloads, payment history, and ENS identity card.

How it works — step by step

Employer                          Blockchain (Ethereum Sepolia + Base Sepolia)
  |                                          |
  |-- registerCompany() ------------------>  EmployeeRegistry.registerCompany()
  |   + SubnameRegistrar.register()          ENS Registry: acme.payname.eth → employer
  |
  |-- InviteFlow (3 steps) ------------->   EmployeeRegistry.addEmployee()
  |   + mintEnsSubname()                     ENS Registry: alice.acme.payname.eth → employer
  |   + setEmployeeTextRecords()             ENS Resolver: setText(role, salary, email, …)
  |   → generates /register?subname=…
  |
Employee
  |-- visits /register link
  |-- creates passkey via JAW.ID
  |-- POST /api/set-wallet ---------->      EmployeeRegistry.relayClaimWallet()
                                             links wallet address to subname on-chain
  |
Employer
  |-- runPayroll() ------------------>      USDC.approve(BatchPayroll, total)
                                             BatchPayroll.disperseToken(recipients, amounts)
                                             (all gasless via JAW.ID smart account)

Tech stack

Layer Technology
Framework Next.js 14 (App Router)
Blockchain Base Sepolia (payroll) + Ethereum Sepolia (ENS)
Wallet / AA wagmi v2 + JAW.ID (ERC-4337 passkey smart accounts)
ENS @ensdomains/ensjs v3 — subname minting, text records
Token USDC on Base Sepolia
Payroll contract BatchPayroll.soldisperseToken sends to N addresses in one tx
Employee registry EmployeeRegistry.sol — stores all employee data on Ethereum Sepolia
PDF pdf-lib — Swiss payslip generation
Styling Tailwind CSS v3 (dark mode ready)
FX rate CoinGecko proxy at /api/rate, cached 5 min

Setup

cp .env.local.example .env.local
# Fill in the required environment variables (see below)
npm install
npm run dev

Environment variables

Variable Description
NEXT_PUBLIC_JAWID_APP_ID JAW.ID application ID for passkey smart accounts
NEXT_PUBLIC_WC_PROJECT_ID WalletConnect project ID
NEXT_PUBLIC_BATCH_PAYROLL_ADDRESS Deployed BatchPayroll contract on Base Sepolia
NEXT_PUBLIC_EMPLOYEE_REGISTRY_ADDRESS Deployed EmployeeRegistry contract on Ethereum Sepolia
NEXT_PUBLIC_SUBNAME_REGISTRAR_ADDRESS Deployed SubnameRegistrar contract on Ethereum Sepolia
NEXT_PUBLIC_PARENT_ENS Root ENS name (default: payroll.eth)

If NEXT_PUBLIC_EMPLOYEE_REGISTRY_ADDRESS is not set, the app runs in mock mode using an in-memory store — useful for local development without a deployed contract.

Commands

npm run dev      # Start dev server at http://localhost:3000
npm run build    # Production build
npm run lint     # ESLint

Technical reference — data objects

EmployeeRecord (TypeScript, lib/ens.ts)

The canonical employee data type used throughout the frontend.

type EmployeeRecord = {
  role: string          // e.g. "Engineer"
  salary: string        // USDC/month as a string integer, e.g. "5000"
  start_date: string    // ISO date, e.g. "2024-01-15"
  department: string    // e.g. "Engineering"
  avatar: string        // HTTP URL or ENS avatar URI
  display_name?: string // Full name, e.g. "Alice Smith"
  email?: string        // Work email
  wallet?: string       // Linked employee wallet address (undefined if not yet claimed)
}

EmployeeParams (Solidity struct, EmployeeRegistry.sol)

Input struct passed to addEmployee / updateEmployee. Field names are camelCase to match Solidity conventions. Maps 1-to-1 with EmployeeRecord.

struct EmployeeParams {
  string role;
  string salary;       // integer string, e.g. "6000"
  string startDate;
  string department;
  string displayName;
  string avatar;
  string email;
  address wallet;      // address(0) if not yet claimed
}

Employee (Solidity storage struct, EmployeeRegistry.sol)

The full on-chain representation. Adds an exists boolean used as a presence flag — Solidity mappings return zero values for missing keys, so this flag distinguishes an empty record from a non-existent one.

struct Employee {
  string role;
  string salary;
  string startDate;
  string department;
  string displayName;
  string avatar;
  string email;
  address wallet;
  bool exists;         // false → employee not registered
}

PayrollEmployee (TypeScript, lib/payroll.ts)

Assembled at payroll runtime by joining ENS subname resolution with text record lookup.

type PayrollEmployee = {
  subname: string       // full ENS name, e.g. "alice_smith.acme.payname.eth"
  address: string       // resolved wallet address (from EmployeeRegistry)
  amount: bigint        // USDC amount in 6-decimal units
  records: EmployeeRecord
}

How objects flow through the system

Creating an employee (InviteFlowlib/ens.ts)

InviteFlow.handleFinish()
  │
  ├─ mintSubname(parentName, label, employerAddress)
  │    ├─ [on-chain] employeeExists() — guard against duplicates
  │    └─ mintEnsSubname(parentName, label, ownerAddress)
  │         ├─ if parent node missing in ENS registry:
  │         │    SubnameRegistrar.register(labelhash, owner) → waitForReceipt
  │         ├─ ENSRegistry.setSubnodeRecord(parentNode, label, owner, resolver, 0)
  │         └─ waitForReceipt  ← ensures node exists before setText runs
  │
  └─ setEmployeeTextRecords(subname, records: EmployeeRecord)
       ├─ [on-chain] employeeExists()
       │    → addEmployee(company, subname, EmployeeParams)   [new]
       │    → updateEmployee(company, subname, EmployeeParams) [existing]
       └─ ENSResolver.multicall([
            setText(node, "name", displayName),
            setText(node, "email", email),
            setText(node, "description", "Engineer · Engineering"),
            setText(node, "org", company),
          ])

The waitForTransactionReceipt call after setSubnodeRecord is critical: eth_estimateGas for setText runs against the current chain state. If the ENS node does not exist yet (transaction still in mempool), the estimation reverts and the text record write is silently dropped.

Linking an employee wallet (/register/api/set-wallet)

Employee visits /register?subname=alice_smith.acme.payname.eth
  │
  ├─ getTextRecords(subname) → show name/role/dept on screen
  ├─ JawIdButton → JAW.ID passkey flow → returns ERC-4337 smart account address
  └─ POST /api/set-wallet { subname, wallet }
       └─ EmployeeRegistry.relayClaimWallet(company, subname, wallet)
            stores wallet on-chain; one-time — reverts if already claimed

Running payroll (lib/payroll.ts)

runPayroll(parentName, smartAccount)
  │
  ├─ getAllSubnames(parentName)           → EmployeeRegistry.getEmployees()
  ├─ for each subname in parallel:
  │    ├─ resolveSubname(subname)        → EmployeeRegistry.companyOwner()
  │    └─ getTextRecords(subname)        → EmployeeRegistry.getEmployee()
  │
  ├─ buildBatchPayload(employees)
  │    ├─ USDC.approve(BatchPayroll, totalAmount)     ← calldata
  │    └─ BatchPayroll.disperseToken(addrs[], amts[]) ← calldata
  │
  ├─ sendGaslessTransaction(USDC_ADDRESS, approveCalldata)    via JAW.ID
  └─ sendGaslessTransaction(BATCH_PAYROLL_ADDRESS, disperseCalldata) via JAW.ID

Data mode switching (lib/ens.ts)

All ENS functions check isOnChainMode() at the top:

isOnChainMode() = Boolean(EMPLOYEE_REGISTRY_ADDRESS && EMPLOYEE_REGISTRY_ADDRESS !== '0x')

true  → all reads/writes go to the EmployeeRegistry contract on Ethereum Sepolia
false → all reads/writes use in-memory Maps (mock mode, resets on page refresh)

Mock mode pre-seeds two demo employees (alice_smith.acme.payroll.eth, bob_jones.acme.payroll.eth) so the UI is functional without a deployed contract.


Smart contracts

EmployeeRegistry.sol (Ethereum Sepolia)

Central registry mapping company ENS names to employee records.

Function Caller Description
registerCompany(company, displayName) public Registers a company; caller becomes owner
addEmployee(company, subname, params) company owner Stores a new employee record
updateEmployee(company, subname, params) company owner Updates an existing record
removeEmployee(company, subname) company owner Deletes a record
claimWallet(company, subname) anyone Employee links msg.sender as their wallet
relayClaimWallet(company, subname, wallet) platform relayer Gasless wallet claim on behalf of employee
getEmployee(company, subname) view Returns all fields as a tuple
getEmployees(company) view Returns all subname strings for a company
employeeExists(company, subname) view Existence check

BatchPayroll.sol (Base Sepolia)

Stateless disperser. Employer approves USDC spend, then calls disperseToken which executes transferFrom for each recipient in one transaction.

SubnameRegistrar.sol (Ethereum Sepolia)

Permissionless subname minter. The payname.eth owner grants it setApprovalForAll in the ENS Registry, allowing anyone to call register(labelhash, owner) to claim a subname under payname.eth.


Swiss payroll logic

Salaries are stored in USDC. At payslip generation time:

  1. USDC salary is fetched from the employee's ENS text record.
  2. Live USDC → CHF rate is fetched from CoinGecko via /api/rate (cached 5 min).
  3. A flat 35% deduction is applied as a placeholder for Swiss statutory contributions:
    • Federal income tax (~11.5%)
    • Cantonal/municipal tax (~12%)
    • AHV/IV/EO social contributions (~6.35%)
    • ALV unemployment insurance (~1.1%)
  4. The payslip PDF (generated by /api/payslip) includes gross USDC, gross CHF, deductions, and net CHF.

For production, integrate with a Swiss payroll API (e.g. Abacus, Sage) to calculate exact per-employee deductions.


Routes

Path Type Description
/ public Landing page
/employer employer Dashboard overview
/employer/invite employer Add new employee (3-step InviteFlow)
/employer/employees employer Employee list with salary totals
/employer/payroll employer Run batch payroll
/employer/documents employer Payslip history
/employer/link-wallet employer Manually link an employee wallet
/employer/setup employer Company setup wizard
/employee employee Employee login / subname lookup
/employee/[subname] employee Personal portal (salary, payslips, ENS identity)
/register employee Passkey wallet creation and wallet linking
/api/rate API CoinGecko USDC/CHF rate proxy
/api/payslip API PDF payslip generator
/api/set-wallet API Relay wallet claim to EmployeeRegistry
/api/resolve API CCIP-Read wildcard resolver stub
/slides/pitch.html static Pitch deck (served from public/slides/)

About

ENS-powered payroll infrastructure for modern Swiss companies.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages