08 Contract

Link format

The grammar of an Oopsend link, what is public and what is secret, how the keys are derived, and how an app should parse one.

#Grammar

https://<host>/c/<sender>/<id>#<secret>

<sender>  the sender's address, 0x + 40 hex characters
<id>      the link id, a decimal uint64
<secret>  the claim key's 32-byte private key, base58 (Bitcoin alphabet)

Example (made up for illustration, the secret is not a real key):

https://oopsend.app/c/0x71241c2C5491e2c8429FF500FC098b179F1708eD/6588103539892714275#7uNAyKu3BWE4qhvpriivXyeqv6SmQnaysWAavXsfadcH

A base58 encoding of 32 random bytes is 43 or 44 characters long.

#Public and secret

PartVisible toWhy
hosteveryone, and the server that serves the pageit is only the app that renders the link
/c/<sender>/<id>everyone, including the server logstogether they identify the record. They let anyone read its state, and they let nobody claim it
#<secret>only the browserbrowsers never send the fragment in an HTTP request, so the app's backend cannot see it

The sender address and id also appear on chain in the LinkCreated event, so nothing about them is private. The secret is the only private part.

#Key derivation

secret      32 random bytes S, generated with the browser's CSPRNG
claim key   the secp256k1 key pair whose private key is S
claimKey    the Ethereum address of that key pair         ← the only thing stored on chain
linkKey     keccak256(abi.encode(sender, id))             ← where the record lives, and what the claim signs

With ethers v6:

const secret = ethers.randomBytes(32);
const claimWallet = new ethers.Wallet(ethers.hexlify(secret));
const claimKey = claimWallet.address;                       // pass this to createLink*
const linkKey = ethers.keccak256(
  ethers.AbiCoder.defaultAbiCoder().encode(['address', 'uint64'], [sender, id]));

linkKey from the contract's linkKey(sender, id) view is identical.

An app should reject anything that does not match, and never send the fragment anywhere.

function parseLink(href) {
  const u = new URL(href);
  const m = u.pathname.match(/\/c\/(0x[0-9a-fA-F]{40})\/(\d+)$/);
  if (!m || !u.hash) throw new Error('not an Oopsend link');
  const secret = base58decode(u.hash.slice(1));              // Bitcoin alphabet
  if (secret.length !== 32) throw new Error('bad secret');
  return { sender: ethers.getAddress(m[1]), id: BigInt(m[2]), secret };
}

Then:

  1. Compute linkKey and read links(linkKey). If sender is the zero address the link is closed: it was claimed, undone or refunded.
  2. Compare the claim wallet's address with link.claimKey. If they differ the secret is wrong or the link was altered.
  3. Show the amount, the fee (amount * feeBps / 10000), and the time left. The fee is the one stored in the link, not the current setting.
  4. Let the user choose a recipient, sign Claim(linkKey, recipient), and submit or hand it to a relayer.
  1. Generate the secret and the claim key as above, and a random uint64 id.
  2. Call createLinkEth or createLinkToken with the claim key's address.
  3. Only after the transaction is mined, show the link. Include the host of your app, so the recipient lands on a page that can parse it.
  4. Keep the id. undo(id) needs it. If you lose it, read it back: LinkCreated carries the id, and sender is indexed, so you can list your own links from chain events and undo from any device. What you cannot recover is the secret, so a link whose secret you lost can be undone but not re-shared.

#Notes for wallets and other apps

  • The format is not tied to one host. Any app that implements the parsing above can offer to open a link.
  • Keep the fragment out of everything that leaves the device: logs, error reports, analytics, "share this page" features.
  • Check expiresAt before offering a claim. After it, the contract reverts with Expired, and the only useful action is to tell the sender they can undo or that anyone can call expireRefund.
  • A link for a token carries no token field. Read the token from the stored record.