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

ActorCanCannot
Sendercreate, undo at any time while openclaim their own link's funds on someone else's behalf, change a link after creation
Link holdersign a claim naming any recipientclaim after expiry, claim twice, change the fee
Relayersubmit a signed claim, pay gaschange the recipient, claim without the signature
Anyonecall expireRefund after expiry, call sweepFeesmove funds anywhere except back to the sender or to the fee wallet
Admintune fee up to 1%, change caps, pause new linkstouch escrowed funds, change an open link, upgrade the code

Next: Quickstart runs all of this from a script.