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.
- Create a company namespace — register your company subname (e.g.
acme.payname.eth) as your on-chain payroll identity. - 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
EmployeeRegistrysmart contract. - Run payroll — one click sends USDC to every employee simultaneously via a gasless batch transaction. No gas costs for the employer.
- Generate payslips — Swiss-compliant PDF payslips with gross/net breakdown, CHF equivalent, and deductions.
- Receive an invitation link from their employer (e.g.
/register?subname=alice_smith.acme.payname.eth). - Create a passkey wallet using Face ID, fingerprint, or PIN — no crypto knowledge required.
- Wallet is linked to their ENS subname on-chain. Future salary payments go directly to this address.
- View their portal — salary breakdown, payslip downloads, payment history, and ENS identity card.
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)
| 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.sol — disperseToken sends to N addresses in one tx |
| Employee registry | EmployeeRegistry.sol — stores all employee data on Ethereum Sepolia |
pdf-lib — Swiss payslip generation |
|
| Styling | Tailwind CSS v3 (dark mode ready) |
| FX rate | CoinGecko proxy at /api/rate, cached 5 min |
cp .env.local.example .env.local
# Fill in the required environment variables (see below)
npm install
npm run dev| 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.
npm run dev # Start dev server at http://localhost:3000
npm run build # Production build
npm run lint # ESLintThe 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)
}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
}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
}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
}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.
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
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
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.
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 |
Stateless disperser. Employer approves USDC spend, then calls disperseToken which executes transferFrom for each recipient in one transaction.
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.
Salaries are stored in USDC. At payslip generation time:
- USDC salary is fetched from the employee's ENS text record.
- Live USDC → CHF rate is fetched from CoinGecko via
/api/rate(cached 5 min). - 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%)
- 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.
| 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/) |
