Overview
Splitsy is built for groups that need more than a calculator screenshot. A splitter can upload a receipt, review the extracted bill, assign payer wallets, and create an onchain record of each participant's share. Payers can connect their wallets later, see only their own debts, pay in full or partially, and bridge USDC into Arc when their balance lives on another supported testnet.
The application has two product surfaces. Bills are one-time debts linked to a receipt or expense.Recurring tabs are scheduled payment agreements, such as rent, subscriptions, shared services, or repeating household costs. Both flows are designed around explicit wallet approval and visible balances.
Scan and review
Upload a bill image, parse merchant totals and line items, convert non-USD totals to USD, and verify the split before anything is submitted.
Wallet-first payment
Browser wallets sign contract transactions. The app uses EIP-1193/EIP-6963 provider discovery and Viem wallet clients for Arc Testnet interactions.
Pay cross-chain with Gateway
Payers with USDC on Avalanche, Base, or Ethereum can pay directly from those chains to Arc Testnet. Gateway burns on the source chain, fetches an attestation, and mints on Arc — all in one two-step flow with no bridge UI.
Automated recurring settlement
Once a payer has approved a recurring tab, Splitsy checks due cycles automatically so users do not manually press a settlement button every cycle.
An agent that pays for you
Bills raised against you can be settled by your own funded agent, under ceilings you set. It spends only the USDC you send it, and every settlement is a public on-chain job a second agent has to sign off.
Using Splitsy
Connect or upload
Start in the Bills tab. Upload a receipt image or review the default bill fields. The scanner reads totals, tax, tip, line items, and confidence notes.
Review the bill
Confirm the merchant, currency, subtotal, tax, tip, total, and line items. Non-USD bills are quoted into USD for payment calculations.
Choose a split
Use equal split for a quick division or manual split when participants owe different amounts. Each payer needs a valid EVM wallet address and a positive amount.
Submit the bill
The splitter creates a bill in the BillSplitRegistry contract. The contract stores a metadata hash, participant addresses, and each participant's owed USDC amount.
Payers settle
Payers connect the matching wallet, approve the registry for the selected USDC amount, and call the payment flow. Payments can be partial as long as they do not exceed the remaining debt.
Splitter claims
Paid funds accumulate as claimable balance in the registry. The splitter can claim any amount up to the available paid balance.
Sign-In and Wallets
Splitsy lets you split a bill with anyone by their handle or email — even before they have ever opened the app. You sign in with X, Discord, Google, or a one-time email code, and each method gives you a ready-to-use USDC wallet on Arc Testnet, so a debtor never has to install a browser wallet, hold a seed phrase, or understand gas to pay what they owe. This section explains exactly what data is used, how the wallet is created, and why Splitsy makes the choices it does.
Four ways to sign in
Choose X, Discord, Google, or email. X and Discord use OAuth 2.0 and read only your public profile (id, username, name, avatar). Google returns your verified email, name, and picture. Email sends a 6-digit one-time code. No method lets Splitsy post on your behalf or read your messages.
Minimal, identify-only access
For X the scopes are tweet.read, users.read, offline.access; for Discord,identify — no write access, no email. Google and email sign-in identify you by your email address. You can revoke an OAuth provider anytime from its connected-apps settings.
A real wallet for your identity
On first sign-in, Splitsy creates a Circle developer-controlled wallet on Arc Testnet keyed to your provider identity. It is a genuine on-chain account with its own address — you can receive USDC to it, send from it, and view it on the block explorer.
A PIN before money moves
Sending USDC requires a wallet PIN you set yourself. Entering it unlocks sends for five minutes, then re-locks. The PIN is stored only as a salted scrypt hash; the raw PIN never leaves your device in readable form and is never stored.
How the identity flow works
Authorize with your provider
For X, Discord, and Google you're redirected to that provider's consent screen using OAuth 2.0 with PKCE. Splitsy's server holds the client secret; a signed state value and PKCE code verifier prevent request forgery and code interception. Email sign-in instead emails you a single-use 6-digit code.
Read the basic profile once
After you approve, Splitsy makes a single call to read your id/handle (or verified email), name, and avatar. No further data is requested from the provider.
Create or reuse your wallet
Splitsy provisions a Circle wallet keyed to your provider identity (idempotently — the same identity always maps to the same wallet), then stores your handle/email, avatar, and wallet address so friends can tag you.
Set a session
A signed, http-only session cookie keeps you logged in. It stores only your Splitsy user id — no tokens or profile data are exposed to the browser.
Discover what you owe
Any bill already tagged to your handle or email is linked to you on sign-in and appears under your unpaid bills, ready to pay from your wallet.
Why a developer-controlled wallet (and not a user-controlled one)
Circle offers two wallet models. A user-controlled walletis non-custodial but requires the user to authenticate to Circle directly — via Google, Apple, Facebook, email OTP, or a PIN — because the user holds a key share. Some of Splitsy's sign-in methods (like X and Discord) are not supported Circle logins at all, so they cannot unlock a user-controlled wallet; and for the ones that overlap, bridging the two would still force every debtor through a second, unrelated login (and a recovery-phrase burden) just to pay a dinner split — the exact friction Splitsy exists to remove.
A developer-controlled wallet is created and operated server-side, keyed to a reference id (your provider identity). That lets Splitsy give anyone a working USDC wallet the instant they sign in — under a single, uniform model across all four providers, with no extra login, no seed phrase, and no app to install. Because Splitsy runs on Arc Testnet with test USDC that has no monetary value, the custodial trade-off carries no financial risk while delivering the smoothest possible onboarding. A future mainnet deployment would revisit this and offer genuine self-custody for real funds.
| Property | Developer-controlled (Splitsy today) | User-controlled |
|---|---|---|
| Works from a handle/email alone | Yes — created server-side on sign-in | Only for Circle-supported logins (not X/Discord) |
| Onboarding steps for a newcomer | None beyond signing in | Second login plus recovery-phrase setup |
| Custody | Server-operated (testnet, valueless USDC) | User holds a key share |
| Network | Arc Testnet (EOA/SCA), USDC transfers | Arc Testnet |
Off-chain ledger for handle-tagged bills
The BillSplitRegistrycontract records debts by wallet address and needs every participant's address at creation time. A handle or email you tag may belong to someone who has not signed in yet and therefore has no address, so tagged bills are not written to the registry. Instead they live in an off-chain ledger: the bill and each debtor's share are stored keyed by provider + handle/email, and are linked to a real wallet the moment that person signs in. This is a deliberate second mode alongside the on-chain registry, chosen so you can split with anyone without knowing their address.
Direct settlement
To pay, your wallet sends USDC directly to the creditor's wallet on Arc — no escrow contract in the middle. Splitsy initiates the transfer, confirms it, and marks the debt paid. Paid bills move to the history at the foot of your Dashboard with an explorer link.
Send, receive, and history
Your wallet widget shows your live USDC balance, a copyable receive address, a PIN-gated send form, and a transaction history read from Circle — each with a link to the Arc block explorer.
Bill Splits
The one-time bill flow is anchored by BillSplitRegistry. The registry does not need to know the full receipt body; it stores a hash of bill metadata plus the participant list and amounts. This keeps the contract focused on debt accounting while leaving rich receipt display to the app.
| Action | Contract function | What happens |
|---|---|---|
| Create bill | createBill(bytes32,address[],uint256[]) | Registers participant debts and emits BillCreated. |
| Pay debt | payDebt(uint256,uint256) | Transfers USDC from payer to the registry and updates paid totals. |
| Claim funds | claim(uint256,uint256) | Allows only the splitter to withdraw paid, unclaimed funds. |
| Look up debts | billIdsForParticipant, getParticipant | Loads debts for the connected payer wallet. |
Amounts are represented with 6 decimals to match USDC. User-entered dollar values are converted into USDC base units before they are submitted to the contract.
Bill Verification
Every on-chain bill carries a verification badge in the payer's view. It answers two different questions, and the whole design hinges on keeping them separate:
1. Is this a genuine bill?
Are the merchant, total, and split shown to you exactly what the creator committed to Arc — with nothing changed since? This is about authenticity, and it is proven by cryptography.
2. Is the total correct?
Does the amount you're being charged actually match the receipt? This is about honesty, and it is checked by re-reading the receipt image itself.
What actually goes on-chain
Storing a full receipt on a blockchain would be expensive and public. Instead, only a 32-byte fingerprint is committed. When a bill is created, Splitsy computes a keccak256 hash over the bill's canonical fields and passes it to createBill(bytes32 metadataHash, address[] participants, uint256[] amounts). The contract emits BillCreated with that hash; it can never be edited afterward.
metadataHash = keccak256(
abi.encode(
merchant, // string, e.g. "ROYAL HANDI HUT"
currency, // string, e.g. "USD"
cents, // uint256, total in cents (e.g. 300 = $3.00)
labels.join("|"), // string, participant labels in order
receiptHash, // string, keccak256 of the receipt image ("" if none)
dueDate // uint256, optional "pay by" Unix seconds — appended
) // ONLY when the creator set a deadline
)The optional dueDate is a strictly additive commitment: a bill with no deadline encodes exactly as bills did before due dates existed, so every previously created bill still verifies byte-for-byte. When present, it anchors the deadline that payment reputation grades timeliness against — the creator cannot move it after the fact.
All-or-nothing bills
A bill with a due date can also be created all or nothing (escrowUntilFull). On a normal bill each payment is the creator's the moment it lands — they can claim it straight away. Tick the box and nothing is claimable until everypayer has settled. Use it when a partial amount is no good to you: six concert tickets, a group gift, a deposit. $160 does not buy six $40 tickets, and holding four people's money for a purchase that isn't happening helps nobody.
That is also why an all-or-nothing bill must have a due date; the registry rejects the pair at creation otherwise. Without a deadline there is no moment at which a short bill counts as failed, so in a contract with no owner, no pause and no sweep the money could never be released to anyone. With one, every route out is self-service: pay it off and the creator claims, or miss it and the payers withdraw.
A refund puts that payer's share back on the board rather than killing the bill, so a late payer — or an autopay agent calling payDebtFor — can still complete it afterwards, and the creator can still be paid in full. collectDebtalso still unlocks at the deadline on these bills, because pulling from debtors who granted a mandate is exactly what can carry a short bill over the line. Anything pulled that doesn't is refundable to the debtor it came from, so the mandate cannot be used to extract money from a failed bill.
The human-readable values behind that hash — the preimage— are published off-chain to Supabase so a payer's browser can recompute the hash and compare. The preimage is only a convenience transport: it is never trusted. The server that stores it first reads the real metadataHashback from Arc and refuses to save any preimage that doesn't hash to it, so a stored record is always genuine.
The receipt image is committed too
To make check 2 possible, the receipt itself is bound to the bill. In the creator's browser the photo is downscaled and re-encoded to a compact JPEG (~80 KB), then hashed with keccak256. That receiptHash is one of the fields inside metadataHash above, so the exact image is anchored on-chain. The image bytes themselves are uploaded to a public Supabase Storage bucket keyed by registry/billId. The publish route re-hashes the uploaded bytes and rejects anything that doesn't match the committed receiptHash, so the stored image is provably the committed one. Bills typed in by hand have no image and commit receiptHash = "".
What the payer's browser does
Verification runs entirely in the payer's browser and trusts only the chain — Supabase is just a delivery pipe. The two checks map directly to the two badge lines:
Recompute the fingerprint (authenticity)
Fetch the preimage, recompute keccak256 over its fields, and compare to the metadataHash read from Arc. A single altered character — merchant, a cent, a label, or the receipt hash — makes the fingerprints differ. Match ⇒ "Genuine bill on Arc." Mismatch ⇒ "Details don't match Arc — don't pay."
Re-hash the committed receipt (provenance)
Download the receipt image and re-hash its bytes. If the hash doesn't equal the committed receiptHash, the image is not the committed one and is neither shown nor trusted. If it matches, the payer is looking at the exact photo anchored on-chain.
Re-read the receipt and compare (honesty)
The browser independently OCRs that verified image, converting a non-USD total to USD with the same FX endpoint the creator used. It compares the receipt's own total to the committed total. Because the payer extracts the number themselves from a hash-locked image, a creator who committed a different figure is caught — no trust in the creator required.
What each badge state means
| Badge | Check 1 — genuine? | Check 2 — total vs receipt | What it means for you |
|---|---|---|---|
| Verified on Arc | Match | Receipt total matches | Safe to pay: authentic bill and the amount matches the receipt. |
| Warning — the total was changed | Match | Receipt reads a different amount | Real bill, but the charged total disagrees with the receipt. Ask the creator first. |
| Genuine, no receipt | Match | No receipt to check | Creator typed the total by hand; there is no bill image to cross-check against. |
| Genuine, couldn't re-read | Match | OCR/FX unavailable | Authentic bill; open the receipt and compare the total by eye. |
| This bill doesn't match Arc | Mismatch | Not evaluated | What you're shown is not what was committed. Do not pay. |
Payment Reputation
Every payer who settles an on-chain bill in full earns verifiable payment reputation using the ERC-8004registries Arc pre-deploys on testnet — no Splitsy contract is involved. The payer's wallet receives an identity NFT on the IdentityRegistry, and each completed payment is recorded as a scored feedback entry on the ReputationRegistry. When someone later tags that payer into a new bill, the creation form shows a badge: "Paid N bills in full on Arc · 97/100 timeliness".
| Registry | Arc Testnet address | Role |
|---|---|---|
IdentityRegistry | 0x8004A818BFB912233c491871b3d84c89A494BD9e | Mints one ERC-721 identity NFT per payer wallet via register(metadataURI); the tokenId is the payer's agent id. |
ReputationRegistry | 0x8004B663056A597Dffe9eCcC1965A193B7388713 | Stores each payment's score via giveFeedback(agentId, score, …, feedbackHash). |
How a score is earned
A payment settles on-chain
The anchor is always a BillSplitRegistry.DebtPaid event (or a recurringsettleTabcollection). Only payments that complete the payer's full share (paidTotal ≥ owedTotal) are scored.
The payer gets an identity NFT (first payment only)
Registration is lazy: on the wallet's first scored payment, register()mints its identity NFT. A Circle-wallet payer's own wallet signs (it just paid, so it holds gas); for browser-wallet payers a dedicated registrar wallet mints on their behalf, then transfers the NFT to the payer — every payer ends up owning their own identity.
Timeliness is graded against the committed due date
The score compares the payDebt block timestamp (never a server clock) to thedueDatethe creator committed into the bill's metadata hash — a deadline that cannot be moved after creation.
A validator wallet records the feedback
A dedicated Splitsy validator wallet calls giveFeedback with the score, a timing tag, and a feedbackHash binding the entry to the exact payment transaction it scores.
The scoring curve
| Situation | Tag | Score |
|---|---|---|
| Bill had no due date | paid_in_full | 100 |
| Paid by the due date + 2-day grace window | paid_on_time | 100 |
| Paid after the grace window | paid_late | 100 − 5 per whole day late, floored at 50 |
Paying is always positive — even a very late payment is evidence of good faith, so the floor is a passing 50, and a payment that is never made simply records nothing. The badge's aggregate is an amount-weighted average: each payment is weighted by the payer's USDC share, so a large bill paid late drags the average more than a small one. Weighting happens only at aggregation; every on-chain score stays simple and independently verifiable. Recurring tabs score too: each settled cycle a member is collected from earns one independent score, graded against that cycle's boundary.
Three wallets, by design
ERC-8004 forbids an agent's owner from scoring its own agent, so Splitsy separates roles across three distinct wallets:
| Wallet | Who it is | What it does |
|---|---|---|
| Payer | The wallet that paid the bill | Owns (or is bound to) the identity NFT being scored. Circle-wallet payers sign their own registration. |
| Registrar | Dedicated Splitsy Circle wallet | Mints identity NFTs for browser-wallet payers, who never hand Splitsy a wallet to sign with, then transfers each NFT to its payer. It holds those NFTs at mint time — which is exactly why it must not also score them. |
| Validator | A second dedicated Splitsy Circle wallet | Records every giveFeedback. Distinct from the registrar and from all payer wallets, so the no-self-scoring rule always holds. |
Verify a score yourself
Every feedback entry commits feedbackHash = keccak256("splitsy:bill:<billId>:<payTxHash>") (recurring cycles use splitsy:tab:<tabId>:cycle:<n>:<settleTxHash>), and its fileuri field carries the same payment hash as tx:<payTxHash>. That makes each score independently re-checkable against the payment it claims to describe, with nothing but a block explorer:
Find the feedback entry
On Arcscan, open the ReputationRegistry address above and locate the giveFeedback transaction (the badge's data mirrors feedback_txper entry). Read the decoded inputs: agent id, score, timing tag, bill tag, and feedbackHash.
Recompute the hash
Compute keccak256 of the UTF-8 string splitsy:bill:<billId>:<payTxHash> using the bill id from the tag and the payment hash from fileuri. It must equal the committed feedbackHash — one changed character breaks it.
Check the payment is real and complete
Open the payment transaction on Arcscan and confirm it emitted DebtPaid from the BillSplitRegistry with the same bill id, with the scored wallet as payer, and with paidTotal ≥ owedTotal.
Check the deadline it was graded against
Fetch the bill's published preimage (see Bill Verification) and recompute the metadata hash — the committed dueDateinside it is the deadline the timing score used, and the payment's block timestamp is the "paid at" moment. Apply the curve above and you reproduce the exact score.
Check the identity binding
On the IdentityRegistry, confirm the agent id from step 1 is the token minted in the payer's registration transaction — the mint's Transfer log carries the tokenId.
What the badge shows — and what it never does
- The badge appears while tagging payers into a new bill, looked up by handle, email, or address.
- It shows the count of bills paid in full, the amount-weighted timeliness average, and how many were late.
- Looking up a handle never reveals the wallet address behind it — the API returns only the aggregate.
- Looking up a handle never creates a wallet; wallets are only provisioned when a bill is actually created.
- "No payment history yet" covers both "person unknown" and "wallet known, no payments" — deliberately indistinguishable.
Optionally, each identity NFT's metadataURI points to an agent profile pinned to IPFS (name, agent type, wallet). Without IPFS configured, registration falls back to an inline data: URI — the reputation mechanics are identical either way.
Recurring Tabs
Recurring tabs are fixed-share payment schedules. A creator chooses a recipient, interval, maximum number of settlement cycles, member wallets, and each member's fixed USDC share. Members approve the tab contract as a constrained USDC spender. Funds remain in member wallets until settlement runs.
Factory deployment
RecurringTabFactory deploys one RecurringTab contract per tab. Each tab has immutable recipient, interval, max cycle count, member list, and fixed shares.
Scheduled settlement
Splitsy checks factory-created tabs on a schedule and calls settleTab() for tabs that have collectible balances.
Shortfall handling
If a member has insufficient allowance or balance, the contract emits shortfall events and collects from members who are ready. Late underpaid amounts can be collected later after approval or funding.
Claimable balance
Settlement increases tab-level claimable. The recipient can call claim() to withdraw collected funds.
The debtor view shows approved amount, wallet balance, paid total, total debt, cycles due, and progress. A paid tab uses a paid-bill stamp. The splitter view shows every member's share, due amount, remaining total, wallet balance, allowance, and collected total.
Circle and Arc
Splitsy uses Circle and Arc technology for USDC movement and settlement. Arc Testnet is the destination network for the app's contracts. Circle Gateway enables cross-chain USDC payments from any supported source chain directly to Arc Testnet in one flow.
Circle Gateway
Pay from any supported testnet (Avalanche Fuji, Base Sepolia, Ethereum Sepolia) and settle on Arc. Two-step flow: sign an EIP-712 burn intent on the source chain (gas-free), then execute the mint transaction on Arc. No bridge UI, no waiting — native USDC moves chain-to-chain in seconds.
CCTP
Circle's Cross-Chain Transfer Protocol burns USDC on the source chain and mints it on the destination chain. Gateway wraps CCTP with a permissionless API that returns an attestation, so the payer's wallet can execute the mint directly without holding for manual attestation fetching.
Browser wallets
Splitsy discovers wallets with EIP-6963 announcements, requests accounts through EIP-1193, prefers MetaMask when available, and uses the wallet provider for signing. Gateway payment requires the wallet to switch chains twice: once to sign the burn intent on the source chain, once to execute the mint on Arc.
Arc properties
Arc is EVM-compatible, uses USDC as its gas token in the Arc environment, and supports CCTP-based USDC bridging. Gateway's GatewayMinter contract on Arc (0x0022222A...) handles the final mint step after attestation.
Autopay Agents
When someone raises a bill against you, you can have it settled without opening the app. The thing that settles it is your own agent: a wallet on Arc that belongs to your Splitsy account, holds its own USDC balance, carries its own ERC-8004 identity NFT, and spends strictly under ceilings you set. It draws only on what you have sent it — Splitsy takes no allowance on your own wallet to make this work.
One agent per account
Its wallet is keyed to your account, not to a wallet, so the same agent and the same balance cover both your Splitsy wallet and any browser wallet you have linked. You fund it once.
Its balance is the hard ceiling
Funding is a plain USDC transfer to the agent — custody, not permission. An agent holding 5 USDC can never spend 6, whatever any rule or bug says, because it has nothing else to draw on.
Rules are checked before it spends
Per-bill ceiling, per-day ceiling, an allowed-creator list, a creator score floor, a verified-hash requirement, and a bill-contents review. Every one is a ceiling evaluated before payment, never a target.
Every settlement is an audited job
The payment is wrapped in an ERC-8183 job: your agent posts and escrows a fee, a Splitsy agent does the work, and a third agent is paid to check the debt really settled before that fee is released.
Funding your agent
The Fundbutton sits next to the agent's balance on the settlement-agents panel. Whichever route you use, it is an ordinary inbound USDC transfer on Arc Testnet — there is no special deposit contract, and you can verify the balance yourself on Arcscan.
| Route | What happens | What it needs |
|---|---|---|
| From a connected browser wallet | Your wallet signs a USDC transfer to the agent's address. Splitsy waits for the receipt and checks it succeeded. | A wallet connected on Arc Testnet with USDC. |
| From your Splitsy wallet | The same transfer, sent server-side from your Circle wallet. | Your wallet PIN unlocked — the same five-minute unlock a normal send uses. |
| From anywhere else | Send USDC to the agent's address from any wallet or faucet. Nothing in the app needs to know. | Just the address, shown on the card and linked to the explorer. |
Three things come out of that one balance on every settlement: your share of the bill, the job fee(0.01 USDC by default, escrowed and released to the agent that did the work), and the agent's own gas. Before it starts, the agent checks it holds the fee plus a 0.20 USDC gas headroom plus the share itself; short of that it skips with agent_unfunded and opens no job, so an underfunded agent costs you nothing. Top it up and the next bill settles.
Three agents on every job
The settlement itself is not a single hidden server call. It is an ERC-8183 job on the already-deployed AgenticCommerce contract on Arc Testnet, with three distinct wallets in three roles, so no agent ever grades its own work:
| Role | Who | What it does |
|---|---|---|
| Client | Your agent | Posts the job and escrows the fee out of your balance. |
| Provider | The Splitsy Settler | Prices the work, buys the bill review, settles the debt, and submits proof of what it did. |
| Evaluator | The Splitsy Auditor | Reads the registry on chain and releases the escrow only if the debt really is settled. |
Decide — and buy a second opinion
Your rules run first against the bill. If they say pay and the contents check is on, the Settler buys a review of the bill from the Auditor over x402. Any refusal stops here: no job is created and no transaction is sent, so a skip costs nothing.
createJob — your agent
Your agent opens the job naming the Settler as provider, the Auditor as evaluator, a description identifying the bill and debtor, and an expiry one hour out.
setBudget — the Settler
The provider prices its own work at the settlement fee. The client does not set the provider's price.
fund — your agent
The fee moves from your agent's balance into escrow. The bill money is never in the escrow — only the fee.
settle — the debt is paid
BillSplitRegistry.payDebtFor(billId, debtor, amount) is called by your agent, paying your share out of its own balance. This is the only step that moves bill money.
submit — the Settler
The Settler submits keccak256(settlementTxHash) as the deliverable, so anyone holding the settlement transaction can recompute it and check the job against it.
complete — the Auditor
The Auditor calls getParticipant on the registry itself and completes the job only when paid ≥ owed. Otherwise it does not complete, the job expires, and the Settler is not paid.
The bill review is bought, not asked for
"Check the bill's contents before paying" is not a free internal function call. The Auditor sells that verdict at $0.002 per review and the Settler buys it over x402 — the same HTTP 402 Payment Required protocol Scout uses — paying out of the fee income it earns from completed jobs. The review weighs the merchant, total and your share against each other; it is given headline figures only and never the receipt image, so it cannot tell who ordered what.
Every failure direction is a refusal: a 402, a timeout, an unparseable verdict, a missing key, or a failed x402 settlement. A Settler that cannot buy a review settles nothing.
Splitsy's paid endpoints
| Endpoint | Price | Seller | Buyer |
|---|---|---|---|
/api/ocr | $0.005 USDC | Splitsy | Scout, per receipt scan |
/api/fx | $0.001 USDC | Splitsy | Scout, only for non-USD receipts |
/api/agents/review | $0.002 USDC | The Splitsy Auditor | The Splitsy Settler, before every settlement |
All three are open to anyone who pays — that is what makes them a market rather than an internal call. Each is settled by Circle's batch facilitator against an offchain EIP-3009 authorization, so the buying agent spends no gasto pay, and both sides of every payment are recorded in Splitsy's x402 ledger.
Reading the decision log
Every bill your agent looked at leaves a row, including the ones it refused — the refusals are the point, because they are what shows a spending permission is still constrained. Each row carries the bill, the amount, the decision, and a reason:
| Reason | What happened |
|---|---|
agent_unfunded | The balance could not cover the share plus the fee plus gas headroom. No job was created. Top it up. |
over_bill_cap / over_daily_cap | Above your per-bill or per-day ceiling. |
untrusted_creator / low_creator_score | The creator is not on your allowed list, or their payment reputation is below your floor. |
hash_mismatch / unverifiable | The bill's details do not match what was committed on chain, or nothing was published to check against. |
review_unavailable | The paid review refused or could not be read. Fail-closed: nothing was paid. |
job_failed / tx_failed | A job transaction reverted, or the settlement transaction itself failed. |
nothing_owed / disabled | The share was already settled, or autopay is switched off. |
A settled row expands into its job trail: every transaction of the ceremony with its block number and hash, the job's live status read from the contract, and the x402 payments that gated it, each linking to Circle's own receipt. The status stored on the row is a display mirror; the contract is the source of truth.
| Job status | Means |
|---|---|
completed | The full ceremony ran; the Auditor verified the debt and released the escrow. |
settled_incomplete | Your debt is paid. Only the submit or complete step broke afterwards. |
settlement_unconfirmed | The settlement was broadcast but not confirmed in time; it may still mine. |
failed | The ceremony broke before the payment step. No money moved and none can. |
The last three are deliberately logged as a payment for the full amount whenever the money might have moved, and they count against your daily ceiling. Costing you headroom you were entitled to is recoverable; handing back a cap you had already spent is not.
What it costs to run
Six transactions per settled share
Not per bill — per share. A four-person bill where everyone autopays is four independent jobs. A skip costs zero, because the decision happens before the job is opened.
0.01 USDC fee, at risk of nothing else
The escrow only ever holds the fee. If a settlement fails the job simply expires an hour later, and at worst that fee is stranded — the bill money is never inside the escrow in the first place.
Two USDC approvals sit outside those six. They are lazy — sent only when the current allowance is short, and for 100× the amount being spent — so they amortise across roughly a hundred settlements instead of landing on each one.
Scout Agent
When you upload a receipt, Splitsy does not scan it directly. The upload is handed to Scout — an autonomous agent with its own wallet, its own ERC-8004 on-chain identity, and a daily spending budget. Scout decides whether your photo is worth scanning, then pays Splitsy's own scanning API in USDC, per call, over Arc. If the first read looks shaky it buys a second opinion out of its own budget.
This is a real machine-to-machine economy, not a metaphor: every scan is an HTTP request that gets answered with 402 Payment Required, a USDC payment authorization, and only then the parsed bill. You never pay for it and never see a prompt — the agent's spending is its own.
It judges before it spends
Scout checks the photo first. Under 8 KB, or under 200 px on either edge, and it refuses to pay at all — you get asked for a clearer picture instead. Nothing is spent on an unreadable image.
It pays per call over HTTP
Splitsy's /api/ocr and /api/fx are paywalled with the x402 protocol. Scout signs an offchain EIP-3009 authorization instead of sending a transaction — so it pays for the API and burns no gas doing it.
It buys a second opinion
Each parse carries a confidence score. Below 0.80, and with budget left, Scout pays a second time for a stricter re-read, then keeps whichever parse scored higher.
It has a hard budget
A daily cap (default $1.00USDC) is the agent's risk control. When the cap is reached Scout stops paying and returns its best-effort read, flagged as low confidence — it can never overspend.
What a single scan actually does
Assess the image — no spend yet
Scout reads the file size and pixel dimensions. Too small or too low-resolution and it declines with a reason, having paid nothing.
Request the scanner, get a 402
Scout calls /api/ocr. The endpoint answers 402 Payment Required with a PAYMENT-REQUIRED header quoting the terms: scheme exact, network eip155:5042002 (Arc Testnet), the USDC asset, and the amount in atomic units (5000 = $0.005).
Sign an authorization, not a transaction
Scout signs an offchain EIP-3009 authorization from its wallet and retries the same request with a payment-signature header. No transaction is broadcast at this point, so the agent spends no gas.
Circle verifies and settles
Splitsy's server hands the authorization to Circle's batch facilitator, which verifies it and settles the USDC. Only then does the endpoint run the scan and return the parsed bill, with a PAYMENT-RESPONSE header carrying the settlement reference.
Check confidence, maybe pay again
If confidence is under 0.80 and the daily cap allows it, Scout repeats the paid call with a stricter re-read instruction and keeps the better of the two parses.
Convert the currency if needed
If the receipt is not in USD, Scout pays /api/fx ($0.001) for a rate to quote the total in USD — the same figure your split is calculated from.
What Scout charges itself
| Paid endpoint | Price per call | When Scout calls it |
|---|---|---|
/api/ocr | $0.005 USDC | Once per scan; a second time if the first parse scores under 0.80 confidence. |
/api/fx | $0.001 USDC | Only when the receipt's currency is not USD. |
So a clean USD receipt costs the agent $0.005; a blurry euro receipt costs $0.011. Both sides of every payment are recorded — what Splitsy earned as the seller and what Scout spent as the buyer — and the dashboard's Scout's x402 ledgerpanel shows the running totals, the budget left for the day, and the last payments with a link to Circle's receipt for each. Scout is not the only agent that pays this way: see Autopay Agents for the review the Splitsy Settler buys before every settlement.
Scout's wallet and on-chain identity
Scout does not use a Circle wallet like yours. It holds a dedicated server-side account on Arc whose onlyjob is signing x402 payment authorizations — deliberately separate from every user wallet, and funded with a small amount of test USDC deposited into Circle's Gateway so its payments can be batched.
It is also registered as an agent on the same ERC-8004 IdentityRegistry that gives payers their reputation NFTs (0x8004A818BFB912233c491871b3d84c89A494BD9e), via register(metadataURI). That means the agent that scanned your receipt has a publicly checkable identity: the scan result shows "scanned by agent 0x… — see its onchain identity" and links straight to Arcscan.
Net-Settlement Treasury
Once you have joined a few bills, what you owe and what you are owed is scattered across all of them. The dashboard's Treasury tab collapses that into one net figure per person — and a single Settle net button that discharges every open position at once.
The open ledger
Every share you owe on bills others created, and every unpaid share owed to you on bills you created — read live from the registry on Arc, not from a cached balance.
One net position
Both directions with the same person are folded together. If Alex owes you $8 on one bill and you owe Alex $12 on another, you see a single −$4.00, sorted so your largest exposure is first.
One settlement
Settle net pays every debt and collects every claimable bill in one action. On a Splitsy wallet the whole thing is one atomic transaction.
Claimable, separately
Money already paid to you but not yet withdrawn is shown as its own figure — it is yours to collect and is included in the same batch.
What Settle net costs to run
Settling bill by bill means an approval plus a payment for every debt, and a claim for every bill you are collecting on — 2 × debts + claims transactions in total. What replaces that depends on which wallet signs:
| Signing wallet | Transactions to settle everything | Failure behaviour |
|---|---|---|
| Splitsy wallet (social sign-in) | 1 — every approval, payment and claim in one atomic batch | All-or-nothing: if any leg would fail, the entire batch reverts and nothing settles. |
| Connected browser wallet | 1 approval + 1 per debt + 1 per claim | Sequential: the progress modal shows exactly which step is running, and a later step failing leaves earlier ones settled. |
The asymmetry is not arbitrary. Splitsy's social wallets are Circle smart contract accounts, which can execute a batch of calls as one atomic transaction; a connected browser wallet is a plain externally-owned account, which cannot, so it still signs each leg. Either way a single USDC approval covers every payment instead of one approval per bill.
Settling from your Splitsy wallet
Unlock the wallet
Because this moves money, Settle net requires your wallet PIN to be unlocked — the same five-minute unlock used for a normal send. Locked, the button tells you to unlock first.
Amounts are re-read from chain
Every outstanding amount is read fresh from the registry at the moment you press the button. Nothing the browser sent is trusted, so a stale dashboard can never cause a wrong amount to be signed.
One batch is assembled and sent
One USDC approval for the summed total, one payment per debt, one claim per collectible bill — packed into a single atomic transaction against your own wallet account.
Reputation is scored as usual
Each debt settled in full earns payment reputation exactly as an individual payment would (see Payment Reputation). Batching changes the transaction count, not the consent rules or the scoring.
Reading the Treasury tab
- Owed to me — the sum of every unpaid share on bills you created.
- I owe — the sum of every unpaid share you hold on other people's bills.
- Net position — the difference. Positive means you are owed on balance; negative means you owe.
- Claimable now — money already paid into your bills that you have not yet withdrawn.
- Per person, both directions are shown alongside the net, labelled with their handle where Splitsy knows it and a shortened address where it does not.
- The tab shares the dashboard's wallet scope selector. With both a Splitsy wallet and a browser wallet connected you must pick which one settles, because each signs differently.
- On sample data (
?demo=1) the figures render but settling is disabled.
Architecture
Experience layer
The Splitsy web app handles receipt upload, bill review, split editing, wallet connection, debt payment, claim flows, recurring tab creation, approval management, tab selection, and event display.
Service layer
Receipt extraction, currency conversion, and recurring settlement automation are handled outside the payment interface so users only see the actions they need.
Contracts
BillSplitRegistry stores one-time debts. RecurringTabFactory creates recurring tab contracts. RecurringTab handles fixed-share scheduled collection and claimable balances.
Integration layer
Wallet, contract, and Circle bridge integrations are separated from the interface so payment flows remain consistent across one-time bills and recurring tabs.
Contracts
Contracts are intentionally narrow. They store the minimum accounting state needed for payment enforcement and emit events for app indexing, explorer review, and user-facing history.
| Contract | Purpose | Important events |
|---|---|---|
BillSplitRegistry | Creates bills, records participant debts, accepts partial or full payments, and lets splitters claim paid funds. | BillCreated, DebtPaid, FundsClaimed |
RecurringTabFactory | Deploys isolated recurring tab contracts and forwards scheduled settlement calls by tab id. | TabCreated |
RecurringTab | Calculates accrued member obligations, transfers available USDC, records shortfalls, and exposes claimable funds. | MemberSettled, SettlementShortfall, TabSettled, FundsClaimed |
The payment contracts build on a small set of shared, audited security primitives rather than external dependencies. Each is intentionally minimal and carries no owner, upgrade, or privileged path.
| Module | Type | Role |
|---|---|---|
ReentrancyGuard | Abstract base | Provides the nonReentrant modifier. Every fund-moving entrypoint (payDebt, claim, settleTab) inherits it, so a function cannot be re-entered while it executes. |
SafeERC20 | Library | Wraps transfer and transferFrom so a token that returns no data or false can never be mistaken for a successful transfer; any non-success reverts with SafeERC20FailedOperation. |
IERC20 | Interface | Minimal ERC-20 surface (allowance, balanceOf, transfer, transferFrom) the contracts use to read approvals and balances and to move USDC. |
Operations
Recurring settlement is designed to be automatic after user approval. Payers maintain enough USDC and allowance for the tab, while Splitsy periodically checks whether a cycle is due and collectible.
If a payer has insufficient balance or allowance, the contract records a shortfall and Splitsy can collect the unpaid portion later after the payer funds or re-approves their wallet. Recipients can claim collected funds when a claimable balance is available.
Autopay agents are the debtor-side equivalent and are funded by the user, not by the operator: an agent that runs out of USDC skips with agent_unfunded and creates nothing on chain, so restoring it is a top-up rather than an operator action. The Splitsy Settler and Auditor pay for their own transactions out of their own balances, and an unset settlement configuration reads as autopay off— never as "settle without the job".
Security
- Users explicitly approve USDC spend before contracts can pull funds.
- Recurring approval is constrained to the tab contract address and can be revoked by setting allowance to zero.
- Recurring settlement is protected by operational controls and is not exposed as a public user action.
- Every fund-moving entrypoint follows checks-effects-interactions and is guarded by the shared
ReentrancyGuard(nonReentrant) module. - All USDC movement routes through the
SafeERC20library, so a token that returns no data orfalsecan never be treated as a successful transfer. - Contracts hold no privileged owner and expose no upgrade, pause, sweep, or
selfdestructpath; funds can only ever leave to a bill's splitter or a tab's immutable recipient. - Sensitive operational credentials must never be exposed in browser code, screenshots, public docs, or client logs.
- Contracts use custom errors and explicit checks for invalid amounts, unknown bills, unauthorized claims, and duplicate recurring members.
- Receipt OCR data should be reviewed by the splitter before submission. The scanner is a convenience layer, not an accounting authority.
- Bridge flows depend on the connected wallet signing each step and on Circle attestation for CCTP minting.
- Payment reputation is consent-based and positive-only: a score can only be created by a payment the wallet itself made, and every entry is re-verifiable against the on-chain payment it commits to (see Payment Reputation).
- An autopay agent spends only the USDC you transferred to it — Splitsy holds no allowance on your own wallet for it — so its balance is a hard ceiling no rule, bug, or compromised server can exceed.
- Each settlement job uses three distinct wallets for client, provider, and evaluator, so the agent that is paid for a job is never the agent that decides it was done. The evaluator re-reads the registry on chain rather than trusting the provider's claim.
Configuration
Splitsy should be connected to the intended Arc Testnet contracts before users create bills or recurring tabs. Contract addresses, USDC token settings, bridge support, receipt scanning, and settlement automation are managed by the operator during deployment.
For users
Use a compatible browser wallet, switch to the supported Arc Testnet network, keep enough USDC for payments, and review every wallet prompt before signing.
For operators
Keep sensitive operational configuration outside public documentation. Publish only user-safe details such as supported network, supported asset, verified contract addresses, and contract source links.