work / tether

Tether

A lend-and-track app: every object gets a scannable tag, and lending is a scan between two phones.

Years
2026
Status
paused

What it is

Tether tracks who has your things. Each object gets a QR tag. A scan offers borrow, return or pass-on, a pass-on gives the owner ten minutes to veto it, and the chain of custody stays visible from the owner's phone.

Read more

The API is Bun with zero npm dependencies: Bun.serve, bun:sqlite, argon2id through Bun.password, Google ID tokens verified with WebCrypto, compiled to a single executable. A transfer is one compare-and-swap statement, UPDATE devices SET holder_id = ? WHERE id = ? AND holder_id = <the holder you saw>, and it only wins if exactly one row changed.

Who has it, how many hops, the journey: all of them are folds over an append-only custody log, with an idempotency key absorbing retries. In the recorded load test, twenty concurrent claims on one object produced one transfer, three “already yours”, sixteen 409s and exactly one custody event. A rerun on 12 October 2026 split the losers four and fifteen; the one transfer and the one event held.

Stack

  • TypeScript
  • Bun
  • bun:sqlite
  • Expo
  • React Native

Counted

  • 0API npm dependencies
    apps/api/package.json
  • 1 transfer20 concurrent claims
    1 custody event; README run and a 2026-10-12 rerun
  • 21commits
    git rev-list

How it works

The system as a machine on a bench. Hover or use the arrow keys to drive it.

CBMPTETHERAATAKADAB00
The key: whoever holds it, holds the deviceCards: each names the holder its claimant sawPaper tape: the custody log, which only growsTether's claim is one compare-and-swap: the holder changes only if it is still the holder the claimant saw. In the README's load test, 20 concurrent claims on one device gave 1 transfer, 3 already yours, 16 conflicts (409) and exactly 1 custody event; a replayed scan is absorbed by the idempotency key, and a pass-on waits out a 10-minute owner veto.

The path through it

  1. ScanPhone A scans the tag in the Expo app.
  2. ClaimA ~90-line router hands the request to one compare-and-swap UPDATE.
  3. Win or 409changes === 1 wins; anyone holding a stale view gets a 409.
  4. LogOne append to custody_events; a unique idempotency key absorbs retries.
  5. Show the chainHolder, hops and journey are folds over the log.