PAYRAIL
Docs
payrail turns a payout spreadsheet into one auditable settlement transaction on Arc. This page covers the batch format, the approval flow and the contract you interact with.
Overview
payrail has two parts: a browser console that validates and hashes a payout file, and PayoutDistributor, a contract that enforces maker/checker approval and pays every recipient in a single transaction.
The contract never custodies funds. It spends an ERC-20 allowance from the treasury address, so pausing or retiring a distributor can never lock up your balance.
| Item | Value |
|---|---|
| Network | Arc (chain id 5042) |
| Gas token | USDC |
| Payout assets | USDC, EURC |
| Distributor | not configured |
| Recipients per transaction | 500 |
Quickstart
- Get testnet USDC from the Circle faucet — it also funds gas, because USDC is the gas token on Arc.
- Open the payout console and connect a wallet on Arc.
- Approve the distributor as a spender for the token you are paying in.
- Drop your CSV in, review the parsed rows, the total and the payload hash.
- Submit the batch, approve it from a second signer, then execute it.
- Export the reconciliation CSV for your ledger.
Funding the treasury
Batches are paid out of the treasury balance on Arc, so USDC has to be on Arc before a batch can execute. The bridge page walks through Circle Gateway, which moves native USDC rather than minting a wrapped asset:
- approve Circle's Gateway wallet on the source chain,
- deposit into your own Gateway balance — the deposit stays yours,
- sign an EIP-712 burn intent naming the Arc recipient,
- submit the returned attestation to the Gateway minter on Arc.
The fee is the source chain's gas fee plus 0.5 basis points of the amount, taken from the Gateway balance, so the console deposits amount plus fee. Circle lists which chains are active per network; Arc is domain 26 and is currently listed on testnet only, and the bridge page says so rather than letting you sign an intent that would be refused.
CSV format
Three columns: recipient address, amount in token units, and an optional reference. A header row is detected and skipped.
address,amount,reference
0x9f2C4b47f34de2cE7DF9DF7C6c9A15E4d0Ea11ab,1250.00,INV-1042
0x71DE8b7c2a4DF5b7D18f0C7A3C02A5F0F0b39C04,3400.50,INV-1043
0xbb10C2B18c9A5b1ee7a21B0F2A4F53a4Cfa877F1,820.25,PAYROLL-JUNValidation runs before anything is signed. A batch is rejected when:
- an address is malformed,
- the same recipient appears twice,
- an amount is zero, negative or not a number,
- an amount has more decimals than the token (6 for USDC and EURC),
- the file has more than 500 rows.
References are kept off-chain in the exported file — they are not written to the chain, so invoice numbers stay private while amounts remain verifiable.
Batch lifecycle
A batch id is keccak256(label), so the same label can only ever be used for one batch. The payload hash commits the token, recipients and amounts.
payloadHash = keccak256(abi.encode(token, recipients[], amounts[]))
batchId = keccak256("payroll-2026-07")
submitBatch(batchId, token, total, recipientCount, payloadHash) // operator
approveBatch(batchId) // second signer
executeBatch(batchId, recipients[], amounts[]) // operatorexecuteBatch re-hashes the arrays you pass and compares them to the committed hash. If a single recipient or amount changed after approval, the call reverts. Status moves None → Pending → Approved → Executed, and a pending or approved batch can be cancelled by an operator.
Roles
| Role | Can do | Notes |
|---|---|---|
| OPERATOR_ROLE | submitBatch, cancelBatch, executeBatch | The maker. Cannot approve. |
| APPROVER_ROLE | approveBatch | The checker. Must differ from the submitter. |
| PAUSER_ROLE | pause, unpause | Freezes submissions, approvals and execution. |
| DEFAULT_ADMIN_ROLE | grant/revoke roles, setTreasury | Keep this on a Safe. |
Self-approval is rejected on-chain with SelfApprovalForbidden, so the two-signer rule does not depend on your internal process.
Contract interface
function submitBatch(bytes32 batchId, address token, uint256 total, uint32 recipientCount, bytes32 payloadHash) external;
function approveBatch(bytes32 batchId) external;
function cancelBatch(bytes32 batchId) external;
function executeBatch(bytes32 batchId, address[] calldata recipients, uint256[] calldata amounts) external;
function getBatch(bytes32 batchId) external view returns (Batch memory);
function hashPayload(address token, address[] calldata recipients, uint256[] calldata amounts) external pure returns (bytes32);
function treasury() external view returns (address);
event BatchSubmitted(bytes32 indexed batchId, address indexed token, uint256 total, uint32 recipientCount, bytes32 payloadHash, address indexed submittedBy);
event BatchApproved(bytes32 indexed batchId, address indexed approvedBy);
event BatchExecuted(bytes32 indexed batchId, address indexed token, uint256 total, uint32 recipientCount);
event PayoutSent(bytes32 indexed batchId, address indexed recipient, uint256 amount);Integrating from a backend needs no payrail service: build the payload, call the three functions with your own signer, and index PayoutSent for accounting.
Arc decimals
On Arc, USDC is the native gas asset and is also exposed as an ERC-20. The native balance uses 18 decimals; the ERC-20 view uses 6. payrail does all accounting, transfers and display in the 6-decimal ERC-20 view, and only touches the 18-decimal view for gas.
| Token | Address | Decimals |
|---|---|---|
| USDC | 0x3600000000000000000000000000000000000000 | 6 |
| EURC | 0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a | 6 |
The two views are never summed — they are the same funds seen through two interfaces.
Reconciliation
Export CSV in the console writes one row per recipient with the batch label, batch id, payload hash, status, token, amount, reference and settlement transaction hash — enough to match a bank-style statement line by line.
batch_label,batch_id,payload_hash,status,token,token_address,recipient,amount,reference,tx_hashFor an independent record, every payout is also an on-chain PayoutSent event, visible on the explorer.
Self-hosting
Deploy your own distributor and point the app at it:
# contracts/
forge test
PAYOUT_ADMIN=0xYourSafe PAYOUT_TREASURY=0xYourTreasury \
forge script script/Deploy.s.sol:Deploy \
--rpc-url https://rpc.testnet.arc.network --broadcast --account deployer
# web app
NEXT_PUBLIC_PAYOUT_DISTRIBUTOR=0xYourDistributor npm run buildUse a Foundry keystore (--account) rather than a plaintext private key, and grant operator and approver roles to different signers.
Errors
| Revert | Meaning |
|---|---|
| BatchAlreadyExists | That label was already used. Pick a new batch label. |
| BatchNotApproved | Execution attempted before a second signer approved. |
| SelfApprovalForbidden | The submitter tried to approve their own batch. |
| PayloadMismatch | Recipients or amounts differ from what was committed. |
| TooManyRecipients | More than 500 rows in one call. |
| ERC20InsufficientAllowance | Treasury has not approved the distributor for the total. |
| EnforcedPause | The distributor is paused. |