02 Start
How it works
Every flow, step by step: create, share, claim, undo, expire. With the state machine and what each actor can and cannot do.
#The three pieces
A send is made of three things, and it helps to keep them apart.
- The escrow record. One entry in the Oopsend contract, stored under a key derived from the sender's address and a link id. It holds who sent, how much, which token, when it expires, and the address of the claim key.
- The claim key. A throw-away private key generated in the sender's browser. Only its address is stored on chain. The private half travels in the link, after the
#. - The link. A URL that carries the sender, the id, and the claim key. Whoever holds the whole link can claim, so it behaves like cash until it is claimed. The exact grammar is in Link format.
#1. Create
Sender browser generates a 32-byte secret S, claimKey = address(privateKey = S), id = random uint64
Sender wallet createLinkEth(id, claimKey, expiresIn) {value: amount}
createLinkToken(id, claimKey, token, amount, expiresIn) (after approve)
Contract stores Link{sender, claimKey, token, amount, feeBps, createdAt, expiresAt}
emits LinkCreated(linkKey, sender, token, amount, expiresAt, claimKey)
App shows the link: https://…/c/<sender>/<id>#<base58 S>
The contract checks, in order: the protocol is not paused, the amount is above zero, the amount is under the cap, the claim key is not the zero address, the expiry is between 10 minutes and 90 days, and no link already exists under that key. For tokens it also checks that the token is a contract and records what actually arrived, so fee-on-transfer tokens cannot make the escrow insolvent.
The fee is read from the contract settings at creation and stored in the link. A later fee change never touches an open link.
#2. Claim
Recipient opens the link the app reads S from the #fragment, locally
App derives the claim wallet from S, loads links(linkKey), shows amount, fee, expiry
Recipient picks a wallet any address, a brand-new one with no ETH works
Claim wallet signs EIP-712 Claim{ linkKey, recipient } (no gas, it is only a signature)
Anyone relays claim(sender, id, recipient, signature)
Contract checks the link exists, is not expired, and the signature recovers to claimKey
deletes the link, accrues the fee, pays the net amount to recipient
emits LinkClaimed(linkKey, recipient, net, fee)
Two properties matter here.
- The signature names the recipient. A relayer or a mempool watcher can submit the claim but cannot change who is paid, because a different recipient produces a different digest and the signature no longer matches. See The claim signature.
- The secret never touches the chain. The chain sees the claim key's address when the link is created and a signature when it is claimed. It never sees
S.
#3. Undo
Sender wallet undo(id)
Contract looks up keccak256(abi.encode(msg.sender, id)), so only the caller's own links can be found
deletes the link, pays the full amount back to the sender, emits LinkUndone
Undo is allowed at any time while the link is open, before or after expiry. It needs no secret and charges no fee. If nobody has claimed, undo always wins.
#4. Expire
Anyone expireRefund(sender, id)
Contract requires now >= expiresAt, deletes the link, pays the full amount back to the sender
emits LinkExpired
After the deadline claims revert with Expired. The refund is permissionless, so a keeper, the sender, or any curious wallet can trigger it, and the money always goes to the sender recorded in the link.
#State machine
createLink*
│
▼
┌───────────┐ claim (before expiry, valid signature) ┌─────────┐
│ OPEN │ ────────────────────────────────────────► │ CLAIMED │
└───────────┘ └─────────┘
│ │ undo (sender, any time) ┌─────────┐
│ └───────────────────────────────────────────► │ UNDONE │
│ expireRefund (anyone, after expiry) └─────────┘
└───────────────────────────────────────────────────► EXPIRED
Terminal states are not stored as flags. The link is deleted, so links(key).sender reads as the zero address. That is also what makes a double spend impossible: a second claim, undo or refund finds nothing and reverts with NoLink.
#Undo and claim race each other
If the link is claimable and the sender calls undo while the recipient's claim is in flight, whichever transaction is ordered first wins. The other one finds no link and reverts. The funds can never be paid twice. This is the one place where timing matters, and it is why the product is honest about the line: before the claim lands the send is reversible, after it lands it is final.
#Who can do what
| Actor | Can | Cannot |
|---|---|---|
| Sender | create, undo at any time while open | claim their own link's funds on someone else's behalf, change a link after creation |
| Link holder | sign a claim naming any recipient | claim after expiry, claim twice, change the fee |
| Relayer | submit a signed claim, pay gas | change the recipient, claim without the signature |
| Anyone | call expireRefund after expiry, call sweepFees | move funds anywhere except back to the sender or to the fee wallet |
| Admin | tune fee up to 1%, change caps, pause new links | touch escrowed funds, change an open link, upgrade the code |
Next: Quickstart runs all of this from a script.