Design
Smart contract
Setoff.sol: one contract, no owner, no upgrades. It stores IOUs, holds deposits, verifies and applies cycles, resolves disputes by agreement, tracks credit lines, and applies opt-in USDC↔EURC swaps.
The IOU
solidity
struct IOU {
address debtor;
address creditor;
address token; // USDC or EURC, fixed at deployment
uint128 amount; // 6 decimals
uint64 deadline; // last timestamp it may settle
uint256 nonce; // makes otherwise identical IOUs distinct
bytes32 ref; // invoice reference, carried into events
}
// id = EIP-712 hash, domain ("Setoff", "1", chainId, contract)An IOU's id is its EIP-712 typed-data hash, so the same struct the debtor signs in their wallet is the key it's stored under. Signatures are checked with OpenZeppelin's SignatureChecker, which also accepts EIP-1271 smart-contract wallets.
Functions
| Function | Who | What it does |
|---|---|---|
submit(iou, signature) | anyone | Adds a debtor-authorised IOU to the pool. The debtor may submit with an empty signature. |
cancel(id) | debtor or creditor | Withdraws (debtor) or rejects (creditor) a pending IOU. |
deposit(token, amount) | anyone | Moves tokens in and credits your balance. |
withdraw(token, amount) | balance owner | Moves tokens out, up to your balance. |
settle(ids, amounts, parties, draws, fx) | anyone | Applies a cycle: pays each IOU the given amount (partial or full), applies credit draws, nets, then applies opt-in USDC↔EURC swaps. Reverts unless every net debtor is covered and the swaps balance per token. |
dispute(id) | debtor or creditor | Freezes an open IOU so no cycle can pay it. |
offer(id, remaining) | debtor or creditor | Proposes what is still owed on a disputed IOU. Matching offers reopen it at that amount; 0 cancels it. |
setFxPreference(sell, buy, minRate) | anyone | Opts in to having your leftover sell converted into buy in cycles, never below minRate (buy per sell, 1e6 = 1:1). 0 opts out. |
setCreditLine(borrower, token, limit) | lender | Lets a borrower overdraw up to limit, funded from the lender's deposit. 0 stops new draws. |
repay(lender, token, amount) | borrower | Repays credit from the borrower's balance. |
getIOU(id) | view | Returns the IOU (current amount), its status, the last cycle that paid it, and how much is paid. |
creditLine(lender, borrower, token) | view | Returns the limit and how much is drawn and not yet repaid. |
hashIOU(iou) | view | The EIP-712 id, for wallets and solvers. |
What settle checks
- The cycle has between 1 and 256 IOUs, with one pay amount for each.
- Each credit draw is within the line's remaining limit and the lender's deposit. Draws move money from lender to borrower before netting.
partiesis strictly ascending, which proves it has no duplicates.- Each IOU is open (not disputed) and unexpired, and each pay amount is more than 0 and at most what is still owed. Paying the remainder marks it settled. Listing an id twice can split a payment but can never overpay.
- Every debtor and creditor is found in
parties. Nets are accumulated per party per token. - Each negative net is covered by that party's deposit, otherwise the call reverts with
InsufficientDeposit(account, token, needed, available).
Events
solidity
event IOUSubmitted(bytes32 indexed id, address indexed debtor, address indexed creditor,
address token, uint128 amount, uint64 deadline, uint256 nonce, bytes32 ref);
event IOUCancelled(bytes32 indexed id, address indexed by);
event IOUSettled(bytes32 indexed id, uint64 indexed cycle, address indexed debtor,
address creditor, address token, uint128 amount, uint128 remaining, bytes32 ref);
event IOUDisputed(bytes32 indexed id, address indexed by);
event IOUOffer(bytes32 indexed id, address indexed by, uint128 amount);
event IOUResolved(bytes32 indexed id, uint128 remaining);
event CreditLineSet(address indexed lender, address indexed borrower, address indexed token, uint128 limit);
event CreditDrawn(uint64 indexed cycle, address indexed borrower, address indexed lender, address token, uint128 amount);
event CreditRepaid(address indexed borrower, address indexed lender, address indexed token, uint128 amount);
event NetPosition(uint64 indexed cycle, address indexed account, address indexed token, int256 net);
event CycleSettled(uint64 indexed cycle, address indexed solver, uint256 iouCount,
uint256[] gross, uint256[] netFunded);
event Deposited(address indexed account, address indexed token, uint256 amount);
event Withdrawn(address indexed account, address indexed token, uint256 amount);Guarantees
- Always fully backed. The contract always holds exactly the tokens its ledger says it owes, for each currency.
- Nothing is created or lost. In every cycle, what net debtors pay equals what net creditors receive.
- Never more than the bills. The money used in a cycle can never exceed the total of the bills it settles.
- Never overpaid. Across partial payments, disputes and credit draws, no bill is ever paid more than its amount.
- Bad input is rejected. Forged signatures, duplicate or expired bills, underfunded debtors and malformed cycles all revert.
These are enforced by the contract and checked by its test suite, including fuzz and stateful invariant tests, in the repository.
Security model
- No admin. There is no owner, pause, upgrade or sweep function. Nobody, including whoever deployed it, can move deposits.
- Your balance only drops for bills you authorised: your signature, or your own transaction.
- The solver is untrusted. It can choose which IOUs to include, how much of each to pay, and which credit to draw. It cannot add debts, pay more than is owed, exceed a credit line, or overdraw anyone.
- Disputes need both sides. Only matching offers from the debtor and creditor change a disputed bill.
- Credit risk stays with the lender who chose to grant it. Draws come only from that lender's own deposit.
- The token list is fixed at deployment (USDC, EURC), so there are no fee-on-transfer or malicious-token surprises.
Deployment
Current network: Arc Mainnet, contract 0x9C09F52cFB6f2B563FD0fC27cf585DE33dD820A6.