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.

ItemValue
NetworkArc (chain id 5042)
Gas tokenUSDC
Payout assetsUSDC, EURC
Distributornot configured
Recipients per transaction500

Quickstart

  1. Get testnet USDC from the Circle faucet — it also funds gas, because USDC is the gas token on Arc.
  2. Open the payout console and connect a wallet on Arc.
  3. Approve the distributor as a spender for the token you are paying in.
  4. Drop your CSV in, review the parsed rows, the total and the payload hash.
  5. Submit the batch, approve it from a second signer, then execute it.
  6. 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:

  1. approve Circle's Gateway wallet on the source chain,
  2. deposit into your own Gateway balance — the deposit stays yours,
  3. sign an EIP-712 burn intent naming the Arc recipient,
  4. 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-JUN

Validation 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[])                   // operator

executeBatch 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

RoleCan doNotes
OPERATOR_ROLEsubmitBatch, cancelBatch, executeBatchThe maker. Cannot approve.
APPROVER_ROLEapproveBatchThe checker. Must differ from the submitter.
PAUSER_ROLEpause, unpauseFreezes submissions, approvals and execution.
DEFAULT_ADMIN_ROLEgrant/revoke roles, setTreasuryKeep 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.

TokenAddressDecimals
USDC0x36000000000000000000000000000000000000006
EURC0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a6

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_hash

For 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 build

Use a Foundry keystore (--account) rather than a plaintext private key, and grant operator and approver roles to different signers.

Errors

RevertMeaning
BatchAlreadyExistsThat label was already used. Pick a new batch label.
BatchNotApprovedExecution attempted before a second signer approved.
SelfApprovalForbiddenThe submitter tried to approve their own batch.
PayloadMismatchRecipients or amounts differ from what was committed.
TooManyRecipientsMore than 500 rows in one call.
ERC20InsufficientAllowanceTreasury has not approved the distributor for the total.
EnforcedPauseThe distributor is paused.