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
| Part | Visible to | Why |
|---|---|---|
| host | everyone, and the server that serves the page | it is only the app that renders the link |
/c/<sender>/<id> | everyone, including the server logs | together they identify the record. They let anyone read its state, and they let nobody claim it |
#<secret> | only the browser | browsers 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.
#Parsing a link
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:
- Compute
linkKeyand readlinks(linkKey). Ifsenderis the zero address the link is closed: it was claimed, undone or refunded. - Compare the claim wallet's address with
link.claimKey. If they differ the secret is wrong or the link was altered. - 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. - Let the user choose a recipient, sign
Claim(linkKey, recipient), and submit or hand it to a relayer.
#Building a link
- Generate the secret and the claim key as above, and a random
uint64id. - Call
createLinkEthorcreateLinkTokenwith the claim key's address. - 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.
- Keep the id.
undo(id)needs it. If you lose it, read it back:LinkCreatedcarries theid, andsenderis 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
expiresAtbefore offering a claim. After it, the contract reverts withExpired, and the only useful action is to tell the sender they can undo or that anyone can callexpireRefund. - A link for a token carries no token field. Read the token from the stored record.