Development of Crypto Payroll Systems
The key problem with crypto payroll isn't technology, it's operational complexity: different employees want different tokens in different networks, tax law requires fixing the fiat equivalent at payment time, compliance requires KYC, and the CFO wants predictable spending in stable terms. The system must solve all these simultaneously — and do it automatically.
Self-development makes sense when: team > 20 people, there are non-standard requirements for tokens/networks, integration with HR system or accounting is needed, or ready solutions (Request Finance, Superfluid, Deel Crypto) don't cover your specifics.
System architecture
HR System / Manual Input
↓
Payroll Engine (calculation, conversion)
↓
Approval Workflow (multi-sig authorization)
↓
Disbursement Module (batch payouts)
↓
Accounting Module (tax accounting, reports)
Data model
interface Employee {
id: string
legalName: string
taxId?: string // for reporting
walletAddresses: {
chain: string
address: string
verified: boolean // confirmed via sign message
}[]
paymentPreferences: PaymentPreference[]
kycStatus: 'pending' | 'approved' | 'rejected'
}
interface PaymentPreference {
percentage: number // % of salary in this token
token: string // contract address
chain: string
minAmount?: Decimal // minimum for payout (else accumulate)
}
interface PayrollRun {
id: string
periodStart: Date
periodEnd: Date
paymentDate: Date
currency: 'USD' | 'EUR' | string // base currency for calculation
employees: PayrollEntry[]
status: 'draft' | 'approved' | 'processing' | 'completed' | 'failed'
approvals: Approval[]]
}
interface PayrollEntry {
employeeId: string
grossAmountFiat: Decimal // in base currency
deductions: Deduction[]
netAmountFiat: Decimal
payments: CryptoPayment[] // breakdown by tokens/networks
exchangeRates: ExchangeRateSnapshot[] // fixed rates
}
Payment calculation and rate conversion
The painful question — which exchange rate to use. Three options:
Spot rate at payment time — simplest. Employer takes rate at transaction send. Employee bears volatility risk.
Fixed rate N days before payment — reduces volatility, requires advance planning, may diverge from market at payment time.
TWA (Time-Weighted Average) over accounting period — standard in traditional FX calculations, most fair, but harder to explain to employees.
class ExchangeRateService {
// Rate sources with fallback
private sources = [
new ChainlinkPriceFeed(), // on-chain, manipulation-resistant
new CoinGeckoAPI(), // CoinGecko with API key
new BinanceAPI(), // CEX spot price
]
async getRate(
fromCurrency: string,
toCurrency: string,
method: 'spot' | 'twap_7d' | 'twap_30d' = 'spot'
): Promise<{ rate: Decimal; source: string; timestamp: Date }> {
if (method === 'spot') {
for (const source of this.sources) {
try {
const rate = await source.getSpotRate(fromCurrency, toCurrency)
return { rate, source: source.name, timestamp: new Date() }
} catch (e) {
console.warn(`${source.name} failed:`, e)
}
}
throw new Error(`Cannot get spot rate for ${fromCurrency}/${toCurrency}`)
}
// TWAP: averaging historical rates
const days = method === 'twap_7d' ? 7 : 30
const historicalRates = await this.getHistoricalRates(fromCurrency, toCurrency, days)
const avgRate = historicalRates.reduce((sum, r) => sum.plus(r), new Decimal(0))
.div(historicalRates.length)
return { rate: avgRate, source: 'twap', timestamp: new Date() }
}
async snapshotForPayroll(run: PayrollRun): Promise<ExchangeRateSnapshot[]> {
// Fix all needed rates and save to DB for audit
const tokens = new Set(
run.employees.flatMap(e => e.payments.map(p => p.token))
)
const snapshots = await Promise.all(
[...tokens].map(async (token) => {
const rate = await this.getRate(run.currency, token, 'spot')
return { token, ...rate, payrollRunId: run.id }
})
)
await this.db.insertRateSnapshots(snapshots)
return snapshots
}
}
Batch payouts: gas optimization
On Ethereum mainnet, paying each employee separately is expensive. Batch payouts via multisend contract reduce cost by 3-5x:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract PayrollDispatcher is Ownable {
event PaymentDispatched(
bytes32 indexed payrollRunId,
address indexed recipient,
address indexed token,
uint256 amount
);
struct Payment {
address recipient;
address token; // address(0) for native ETH/BNB
uint256 amount;
}
function dispatchPayroll(
bytes32 payrollRunId,
Payment[] calldata payments
) external onlyOwner {
for (uint256 i = 0; i < payments.length; i++) {
Payment calldata p = payments[i];
if (p.token == address(0)) {
(bool success,) = p.recipient.call{value: p.amount}("");
require(success, "ETH transfer failed");
} else {
require(
IERC20(p.token).transferFrom(msg.sender, p.recipient, p.amount),
"Token transfer failed"
);
}
emit PaymentDispatched(payrollRunId, p.recipient, p.token, p.amount);
}
}
receive() external payable {}
}
For stablecoins (USDC, USDT), use transferFrom — funds stay on multisig wallet, contract only directs payouts. Important for security: contract doesn't hold funds.
Multi-chain disbursement
If employees get paid in different networks, need separate disbursement module per chain. Parallel execution with status aggregation:
class MultiChainDisbursementService {
private dispatchers: Map<string, ChainDispatcher>
async executePayrollRun(run: PayrollRun): Promise<DisbursementResult> {
// Group payments by chain
const byChain = groupBy(
run.employees.flatMap(e => e.payments),
p => p.chain
)
// Launch disbursement on each chain in parallel
const results = await Promise.allSettled(
Object.entries(byChain).map(([chain, payments]) =>
this.dispatchers.get(chain)!.dispatch(run.id, payments)
)
)
// Handle partial failures
const failures = results.filter(r => r.status === 'rejected')
if (failures.length > 0) {
await this.handlePartialFailure(run.id, failures)
}
return this.aggregateResults(results)
}
}
Multi-sig authorization
For AML-required funds, one signature isn't enough. Standard scheme: CFO + CEO + Finance Director, 2-of-3.
Gnosis Safe (now Safe{Wallet}) is the standard for multisig operations. Integration via Safe SDK:
import Safe, { EthersAdapter } from '@safe-global/protocol-kit'
import SafeApiKit from '@safe-global/api-kit'
class PayrollApprovalService {
async proposePayrollTransaction(
safeAddress: string,
payrollData: PayrollRun,
payments: BatchPayment[]
): Promise<string> {
const safeSDK = await Safe.create({ ethAdapter, safeAddress })
const apiKit = new SafeApiKit({ txServiceUrl: 'https://safe-transaction-mainnet.safe.global' })
// Encode PayrollDispatcher.dispatchPayroll() call
const data = payrollDispatcher.interface.encodeFunctionData(
'dispatchPayroll',
[payrollData.id, payments]
)
const safeTransaction = await safeSDK.createTransaction({
transactions: [{ to: PAYROLL_DISPATCHER_ADDRESS, data, value: '0' }]
})
const safeTxHash = await safeSDK.getTransactionHash(safeTransaction)
const senderSignature = await safeSDK.signTransactionHash(safeTxHash)
await apiKit.proposeTransaction({
safeAddress,
safeTransactionData: safeTransaction.data,
safeTxHash,
senderAddress: await signer.getAddress(),
senderSignature: senderSignature.data,
})
return safeTxHash
}
}
Tax accounting and compliance
Each payout must be recorded with:
- Fiat equivalent at payment time (for tax)
- Rate source (for audit)
- On-chain transaction ID
- Period paid for
CREATE TABLE payroll_transactions (
id BIGSERIAL PRIMARY KEY,
payroll_run_id UUID NOT NULL REFERENCES payroll_runs(id),
employee_id UUID NOT NULL REFERENCES employees(id),
payment_date DATE NOT NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
-- Crypto details
token_address VARCHAR(42) NOT NULL,
token_symbol VARCHAR(20) NOT NULL,
chain VARCHAR(50) NOT NULL,
crypto_amount NUMERIC(36, 18) NOT NULL,
tx_hash VARCHAR(66),
-- Fiat equivalent for tax
fiat_currency VARCHAR(3) NOT NULL,
fiat_amount NUMERIC(20, 2) NOT NULL,
exchange_rate NUMERIC(20, 8) NOT NULL,
rate_source VARCHAR(100) NOT NULL,
rate_timestamp TIMESTAMPTZ NOT NULL,
-- Status
status VARCHAR(20) NOT NULL DEFAULT 'pending',
confirmed_at TIMESTAMPTZ,
block_number BIGINT
);
Export for accounting: CSV breakdown by employee and period, compatible with 1C or international standards (IAS 19 for employee benefits).
Streaming payments: Superfluid integration
For DAOs and companies with real-time salary flow — integration with Superfluid Protocol. Instead of periodic payouts — continuous token stream per second:
import { Framework } from '@superfluid-finance/sdk-core'
async function createSalaryStream(
employeeAddress: string,
tokenAddress: string,
monthlyAmountWei: bigint
): Promise<void> {
const sf = await Framework.create({ chainId: 137, provider })
const superToken = await sf.loadSuperToken(tokenAddress)
// flowRate = wei/second
const flowRate = monthlyAmountWei / BigInt(30 * 24 * 3600)
const createFlowOp = superToken.createFlow({
sender: companyAddress,
receiver: employeeAddress,
flowRate: flowRate.toString(),
})
await createFlowOp.exec(signer)
}
Streaming payments eliminate periodicity problems and reduce operational load, but require maintaining liquidity (sufficient buffer) and complicate tax accounting — income accrues continuously.
Full system with multi-chain support, Safe integration, tax module, and HR integration — 3 to 6 weeks development depending on number of supported networks and compliance requirements.







