Rohit Swami
India Resume ↗

Writing · ClearOwe · 11 min read

A ledger its own server can't read

ClearOwe keeps track of money between people. It works offline, follows you to a new phone and shares a balance by link, while the server in the middle learns almost nothing. Integer money, a parser for how people really talk about debts, sync that doesn't trust clocks, and a key that lives after the # in a URL.

Money between people is private in a way that money with a bank isn't. Who lent whom the deposit for a flat, which friend still owes for the trip, the loan from a parent that nobody mentions out loud. ClearOwe keeps that ledger: who owes you, whom you owe, and who has your things.

I wanted it to do three things that pull against each other. It should work with no signal, because the moment you split a bill is often in a basement restaurant. It should follow you to a new phone. And the other person should be able to see what they owe without installing anything. On top of that, I didn't want to be able to read any of it. Each of those is easy on its own. Together they took some care, and so did a few things that look simpler than they are, starting with the numbers themselves.

1Money is an integer

Every amount in ClearOwe is a whole number of the currency's smallest unit, paise for rupees and cents for dollars, stored next to its ISO 4217 code. Floating point only appears while a number is being formatted for the screen or while interest accrues, and the result is rounded back to whole units straight away. Not every currency has two decimal places, so the exceptions are fixed in the code rather than read from the phone's settings: the yen and the won have none, the Kuwaiti dinar and the Omani rial have three. Every device agrees on what an amount means.

Balances follow the same discipline. A balance is per person and per currency, and it is never netted. If Sam owes you ₹500 and you owe Priya ₹200, ClearOwe says exactly that, not "₹300", and someone who owes you ₹500 and $20 owes you both. Every record is written from your side of the table, as money you gave or money you got, and the screens say which way it goes in words, "Sam owes you", rather than with a minus sign that people misread.

Splitting a bill is where whole units get interesting. ₹1,000 doesn't divide into three, so one share has to carry an extra paisa, and something has to decide whose. ClearOwe always puts you first in the list of shares, so the leftover units land on you: your friends never pay the extra paisa. Splits by percentage or by shares use the largest-remainder method, so the parts always add up to the bill exactly.

rounded share by share –ClearOwe's split –

Fig. 1 Each bar is a share, in paise. Rounding each share on its own loses or invents paise; splitting in whole units makes the shares differ by at most one paisa, and puts any extra on you.

2Two seconds, offline

The first principle in ClearOwe's product notes is "two-second capture": amount, person, done, signal or not. So alongside the form there's a line you can type the way you'd say it, in English or in Romanized Hindi, and a small parser turns it into a record for you to confirm. It runs on the phone, with no model and no server, which means it has to be honest about what it understands.

It recognises a fixed set of sentence shapes and nothing more. "2k" and "1.5 lakh" are numbers the way people say them, "rahul ko 1.5 lakh diye" means "I gave Rahul 1.5 lakh", and "sam borrowed my drill" is a thing, not money. Anything outside the shapes it knows falls back to the normal form, with the amount filled in if it found one, and it never guesses a person who wasn't named. A parser that's right about a few sentences and says so is more useful than one that's confidently wrong about many.

Fig. 2 Five lines from the parser's own examples, and the records it makes of them. Every word it uses is coloured by what it became; the record is shown for you to confirm, never saved silently.

3The phone is the database

ClearOwe is local-first. The source of truth is a SQLite database on the phone. Every screen reads it through live queries and every action writes to it first, so nothing ever waits on the network: recording a debt takes the same two seconds in a lift as on Wi-Fi. The file is encrypted with SQLCipher, under a random key that lives in the iOS Keychain or the Android Keystore. All of the arithmetic in this article, money, balances, interest, loans, splits, recurrence, lives in a folder of pure functions that never import anything from React Native or Expo, and every one of them is unit tested.

Every row that syncs carries four extra columns: when it was created, when it last changed, when it was deleted, and whether it has changed since the last sync. Deletes are soft. A row that has simply vanished can't tell your other phone that it's gone, so a deletion is just another edit, one that sets deleted_at.

4Sync the server can't open

Backup is optional. Turning it on creates a recovery code: 20 random bytes, 160 bits, written in Crockford's base32 as eight groups of four characters. The alphabet leaves out I, L, O and U, so nothing in it can be misread, and when a code is typed back in, an I or an L is read as a 1 and an O as a 0. The encryption key is derived from the code on the phone and never leaves your devices; the server keeps only a small sealed check value, so a new phone can tell a mistyped code from a right one without the server learning either.

Every row is sealed with AES-256-GCM before it's uploaded: a fresh 12-byte nonce, the ciphertext, and a 16-byte authentication tag. This is what the server stores:

records(user_id, kind, id, updated_at, deleted, payload)   -- payload: sealed bytes

That's everything it can see: that a row of some kind changed at some time. There's one more detail in the sealing that's easy to leave out and dangerous to. Each row's kind and id are bound into the encryption as additional authenticated data. They aren't secret, the server can see them, but the tag covers them, so a sealed row only opens as the row it was written for. Without that, anyone who can write to the database could swap two rows' payloads, and each would decrypt perfectly as somebody else's record.

the phone shows –

Fig. 3 Someone with write access to the database swaps two payloads. They can't read either one, and they don't need to: without the binding, each decrypts cleanly under the other's id. With it, the authentication tag fails and the phone refuses both.

The honest cost of all this is the recovery code. Lose it and the backup can't be read by anyone, me included, so "start fresh" deletes the old backup and seals a new one from what's on the phone. There is no "forgot password" for data I was never able to read.

5Don't let clocks decide what's new

Incremental sync looks simple. Each phone remembers when it last synced and asks for everything that has changed since. The question hiding in that sentence is: since when, by whose clock?

If the answer is the phone's own clock, two ordinary things break it. Phone clocks drift, and a phone that runs two minutes slow stamps every change two minutes in the past. And a local-first app writes while it's offline, then uploads an hour later, with timestamps from an hour ago. Either way a change can reach the server stamped earlier than another phone's last sync, and that phone will never ask for it. Nothing fails and nothing is logged. The two phones just quietly disagree, for good.

B's clock is slow by

reached phone A –never will –

Fig. 4 A simulation of one hour. Phone B makes ten changes and is offline from minute 22 to 38; the faint tick behind each change is the time B's clock wrote on it. Phone A syncs every six minutes. Asking by the phones' own timestamps loses changes even with perfect clocks, because of the offline ones; asking by the server's stamp loses none.

So the server keeps its own stamp, and two small triggers on the records table do all of the work. A new row gets stamped from the database's clock. An update is ignored outright if it's older than what's stored, which is last-write-wins between two edits of the same row, and otherwise it's stamped too:

-- Last write wins on the device's updated_at; an older edit arriving late is ignored.
create or replace function private.records_update() returns trigger
language plpgsql set search_path = '' as $$
begin
  if new.updated_at < old.updated_at then
    return null;                                   -- skip the write entirely
  end if;
  new.user_id := old.user_id;
  new.server_updated_at := clock_timestamp();      -- the server's clock, never the phone's
  return new;
end;
$$;

Phones pull by server_updated_at, never by their own clocks, in batches of 400, and each pull starts five seconds before the last stamp it saw, because a transaction that stamped its rows earlier can commit later and would otherwise slip past the cursor. Coming down, a newer unsent edit on the phone wins over an older one from the server. Clocks still decide which of two edits to one row wins. They no longer decide whether a phone hears about a change at all.

The other person usually doesn't have ClearOwe, and shouldn't need it. So a balance can be shared as a link: clearowe.com/s/, a random 128-bit id, then a # and a random 256-bit key.

The # is the whole trick. Everything after it is the URL's fragment, and browsers don't send the fragment to the server, not in the request line and not in the Referer header. The page loads, fetches the sealed statement by its id, and opens it in the browser with WebCrypto, using a key that never crossed the network. The server keeps a statement it can't read, exactly like the backup.

No account is needed to share, so whoever made a link proves it's theirs another way: the link has an owner secret that only the owner's phones hold, and the server keeps just its SHA-256. Republishing, reading claims and turning the link off all have to present it. The statement is uploaded again whenever what it shows changes, once things go quiet, and skipped when its hash says nothing changed.

The same key seals the other direction. When the other person taps "I've paid", the claim is sealed in their browser and stored until the owner's phone collects it, opens it, checks it and deletes it from the server. A link can't be used to spam its owner, because the database refuses a sixth claim within an hour, or a twenty-first waiting:

if (select count(*) from public.share_claims
      where share_id = link_id and created_at > now() - interval '1 hour') >= 5
   or (select count(*) from public.share_claims where share_id = link_id) >= 20 then
  raise exception 'too many claims';
end if;

If the owner allowed it, the database sends their phone a push itself, through Postgres's pg_net, carrying words the owner chose in advance and never a name or an amount. Link previews say only "A balance shared with you". The page is noindex, sends no referrer, and its content security policy lets it talk to nothing but the backend. Its pay buttons are ordinary UPI, PayPal, Venmo, Cash App and Revolut links built from the owner's own details, each shown only for currencies it takes, so ClearOwe never touches the money either.

7Loans the way people describe them

Bank loans and EMIs are the other half of the ledger, and the problem there is that people rarely have all the numbers. They know the instalment and how many are left; or the loan amount and the rate; seldom all four. So any three of loan amount, yearly rate, tenure and EMI give the fourth. Three of the four have closed forms. The rate doesn't, so ClearOwe finds it by bisection, between nothing and 100% a month: the instalment rises steadily with the rate, so a hundred halvings of that interval pin the rate down far more finely than any lender quotes it.

The inputs are checked the way a careful friend would check them. EMIs that don't even add up to the loan can't be a loan. A rate above 60% a year is almost always a number typed in the wrong box, since even credit cards stop around 42%. And when all four numbers are given, the EMI may differ by up to 2% from the one the others imply, because lenders round differently, but not by more.

yearly rate –

Fig. 6 Bisection, one halving at a time, on four made-up loans. Each row tries the middle of the interval, works out the EMI that rate would need, and keeps the half the real EMI must lie in. The axis is logarithmic, so every halving looks the same size.

Loans between people work differently, and the app follows how they're actually agreed. Interest is held in basis points per period, so India's "₹2 per ₹100 per month" is simply 200 a month, shown in the words people use. It can be simple, compound or a fixed one-off amount, and by default repayments go to interest first, because informal lenders almost always take it that way.

8Reminders without a server

Nothing about reminders needs a server either. Every notification, about a person, a loan, an EMI or a thing due back, is planned on the phone from the database, and the whole schedule is rebuilt after any change. iOS keeps at most 64 pending notifications per app, so the planner keeps the soonest ones under that limit, with one slot for the optional evening nudge, and the rest are planned at the next rebuild. Push is used only for events that come from outside the phone, which today means "I've paid" on a shared link. The home-screen widget gets a snapshot that's already in words, with amounts turned into dots when "hide amounts" or the app lock is on, and the widget's background task redraws from that snapshot without ever opening the encrypted database.

9What's left on the server

The admin screen shows counts: accounts, sign-ups per day, backups, share links, purchases. Look someone up by email and you see when they joined, how they sign in and when they last synced. That is the complete list, and not because of a policy. Everything else is sealed under keys the server never had, so there's nothing else it could show.

None of this is new cryptography. AES-GCM, URL fragments and server-side timestamps are all old ideas, and integer money is older than computers. The work was in the plumbing: deciding exactly what the server has to know to do its job, and then making sure it learns nothing else.

I'm Rohit Swami. I build the unglamorous machinery real products run on: data pipelines, real-time services, open-source tools, and products of my own. More about me, or write to me.

The figures on this page are simulations written for it. They run in your browser, and the numbers in them are illustrative unless the text says otherwise.