07 Contract
The claim signature
Why the claim key signs the link and the recipient instead of revealing a secret, with the EIP-712 type, the front-running analysis and the replay protections.
#The design question
A claim link needs a secret that proves "I was given this link". There are two common ways to turn that into an on-chain check.
Hash and reveal. Store hash(S) when the link is created. To claim, send S in the transaction and the contract checks hash(S) == stored. This is simple, but S appears in the transaction before it is mined. Anyone watching the mempool, or any node that sees the pending transaction, can copy S and submit their own claim to a different recipient with a higher fee. The honest claimer loses.
Sign and verify. Store the address of a throw-away key. To claim, the key's owner signs a message that includes the recipient, and the contract recovers the signer. The secret is never sent. A copied signature is useless for any other recipient, because the recipient is part of what was signed.
Oopsend uses the second. The throw-away private key is the link's secret, and the contract only ever stores its address.
#What is signed
The contract verifies an EIP-712 typed message.
Domain { name: "Oopsend", version: "1", chainId: block.chainid, verifyingContract: <Oopsend address> }
Type Claim(bytes32 linkKey, address recipient)
CLAIM_TYPEHASH is keccak256("Claim(bytes32 linkKey,address recipient)"). The digest is the standard one:
digest = keccak256( 0x1901 ‖ DOMAIN_SEPARATOR ‖ keccak256( abi.encode(CLAIM_TYPEHASH, linkKey, recipient) ) )
You do not have to build this by hand. The contract exposes it as claimDigest(linkKey, recipient), and ethers produces the same value with signTypedData or TypedDataEncoder.hash. Our end-to-end script checks that the on-chain digest and the off-chain digest are equal.
const domain = { name: 'Oopsend', version: '1', chainId: 4663, verifyingContract: OOPSEND };
const types = { Claim: [{ name: 'linkKey', type: 'bytes32' }, { name: 'recipient', type: 'address' }] };
const sig = await claimWallet.signTypedData(domain, types, { linkKey, recipient }); // 65 bytes: r ‖ s ‖ v
#What the contract checks
In claim(sender, id, recipient, sig), after the link exists and has not expired:
recipientis not the zero address.sigis exactly 65 bytes.sis in the lower half of the curve order andvis 27 or 28 (avof 0 or 1 is normalized to 27 or 28 first).ecrecover(digest, v, r, s)returns a non-zero address.- That address equals
link.claimKey.
Any failure is BadSignature.
#Front-running, concretely
Assume Bob is the intended recipient and Mallory is watching the mempool.
| Attack | Result |
|---|---|
| Mallory copies Bob's pending claim and replaces the recipient with herself | The signature was made over Bob's address. Over Mallory's address the digest differs, so recovery returns a different address: BadSignature |
| Mallory submits Bob's exact transaction with a higher tip | It still pays Bob. The signature fixed the recipient. She pays gas to deliver Bob's money |
| Mallory signs with a key of her own | The recovered address is not claimKey: BadSignature |
| Mallory uses a signature from another link | The linkKey differs, so the digest differs: BadSignature |
All four are tests, in Solidity and in the JavaScript engine, and they run live on the home page.
#Malleability
An ECDSA signature (r, s) has a twin (r, n - s) that also verifies for the same message. A contract that accepted both could be tricked by anything that treats signature bytes as an identifier. Oopsend rejects any s above n/2, so exactly one encoding of each signature is valid. A test flips s and expects BadSignature, then submits the original and expects success.
Replay of a used signature is not a concern for a different reason: a successful claim deletes the link, so the same signature a second time finds no link and reverts with NoLink.
#Replay across contracts and chains
The domain includes the chain id and the contract address.
- A signature made for one deployment is invalid on another deployment, because
verifyingContractdiffers. - A signature made for Robinhood Chain is invalid on any other chain, because
chainIddiffers. - If the chain ever forks and changes its chain id, the contract recomputes the domain separator when
block.chainidno longer matches the value cached at deployment. A signature from before the change stops working instead of being replayable.
#What the signature does not protect
- A leaked link. Whoever holds the whole link holds the claim key and can sign for any recipient they like. That is why the link is treated as cash and why the sender keeps the right to undo.
- The secret being exposed on the device. The secret is generated and used in the browser. Malware on that device can read it.
- Which transaction lands first. If the sender undoes while a claim is in flight, whichever is ordered first wins. See How it works.
Details of the URL and key derivation are in Link format.