Merge 7-add-docs into main
e99639d parent: 13964fb modified
README.md +12 -0 | @@ -19,6 +19,15 @@ forge fmt # format | ||
| 19 | 19 | anvil # local node |
| 20 | 20 | ``` |
| 21 | 21 | |
| 22 | +## Privacy | |
| 23 | + | |
| 24 | +Besides the plain `deposit(uint256)`, LOVE can be locked behind a secret | |
| 25 | +instead of a recipient address — `deposit(bytes32 secretHash, uint256)` mints | |
| 26 | +into an escrowed envelope, and `claim(depositId, secret, loveAmount)` releases | |
| 27 | +it to whoever proves the secret. Full workflow, the off-chain requirements | |
| 28 | +that make it actually private, and the front-running window it opens: see | |
| 29 | +[docs/PRIVACY.md](docs/PRIVACY.md). | |
| 30 | + | |
| 22 | 31 | ## How wETH is chosen |
| 23 | 32 | |
| 24 | 33 | LOVE is pegged to wETH, and that wETH is not a constructor argument. It is read |
| @@ -49,6 +58,9 @@ no liquidity — and register it first. Nothing is stealable, since it is real | ||
| 49 | 58 | WETH9 and fully redeemable, but check `WETH()` against the chain's canonical |
| 50 | 59 | address before treating a deployment as the real one. |
| 51 | 60 | |
| 61 | +Full design rationale, the allowlist's contents, and how to verify determinism | |
| 62 | +yourself: see [docs/MULTICHAIN.md](docs/MULTICHAIN.md). | |
| 63 | + | |
| 52 | 64 | ## Deploy |
| 53 | 65 | |
| 54 | 66 | Deployment uses CREATE2, so the token has the same address on every EVM network. |
| @@ -19,6 +19,15 @@ forge fmt # format | |||
| 19 | anvil # local node | 19 | anvil # local node |
| 20 | ``` | 20 | ``` |
| 21 | 21 | ||
| 22 | +## Privacy | ||
| 23 | + | ||
| 24 | +Besides the plain `deposit(uint256)`, LOVE can be locked behind a secret | ||
| 25 | +instead of a recipient address — `deposit(bytes32 secretHash, uint256)` mints | ||
| 26 | +into an escrowed envelope, and `claim(depositId, secret, loveAmount)` releases | ||
| 27 | +it to whoever proves the secret. Full workflow, the off-chain requirements | ||
| 28 | +that make it actually private, and the front-running window it opens: see | ||
| 29 | +[docs/PRIVACY.md](docs/PRIVACY.md). | ||
| 30 | + | ||
| 22 | ## How wETH is chosen | 31 | ## How wETH is chosen |
| 23 | 32 | ||
| 24 | LOVE is pegged to wETH, and that wETH is not a constructor argument. It is read | 33 | LOVE is pegged to wETH, and that wETH is not a constructor argument. It is read |
| @@ -49,6 +58,9 @@ no liquidity — and register it first. Nothing is stealable, since it is real | |||
| 49 | WETH9 and fully redeemable, but check `WETH()` against the chain's canonical | 58 | WETH9 and fully redeemable, but check `WETH()` against the chain's canonical |
| 50 | address before treating a deployment as the real one. | 59 | address before treating a deployment as the real one. |
| 51 | 60 | ||
| 61 | +Full design rationale, the allowlist's contents, and how to verify determinism | ||
| 62 | +yourself: see [docs/MULTICHAIN.md](docs/MULTICHAIN.md). | ||
| 63 | + | ||
| 52 | ## Deploy | 64 | ## Deploy |
| 53 | 65 | ||
| 54 | Deployment uses CREATE2, so the token has the same address on every EVM network. | 66 | Deployment uses CREATE2, so the token has the same address on every EVM network. |
added
docs/MULTICHAIN.md +87 -0 | new file mode 100644 | ||
| @@ -0,0 +1,87 @@ | ||
| 1 | +# Multichain Deployment | |
| 2 | + | |
| 3 | +How CREATE2 and a bytecode-hash allowlist give LOVE the same address on every EVM chain, how to deploy it, and how to verify that determinism yourself. | |
| 4 | + | |
| 5 | +## Contents | |
| 6 | + | |
| 7 | +- [Multichain Deployment](#multichain-deployment) | |
| 8 | + - [Contents](#contents) | |
| 9 | + - [Overview](#overview) | |
| 10 | + - [The two-contract setup](#the-two-contract-setup) | |
| 11 | + - [Deploying](#deploying) | |
| 12 | + - [Verifying determinism](#verifying-determinism) | |
| 13 | + - [The allowlist](#the-allowlist) | |
| 14 | + - [What this doesn't guarantee](#what-this-doesnt-guarantee) | |
| 15 | + - [Further reading](#further-reading) | |
| 16 | + | |
| 17 | +## Overview | |
| 18 | + | |
| 19 | +[`Love`](../src/Love.sol) and [`WETHRegistry`](../src/WETHRegistry.sol) both deploy through the canonical, ownerless [deterministic deployment proxy](https://github.com/Arachnid/deterministic-deployment-proxy) — the keyless [`CREATE2`](https://eips.ethereum.org/EIPS/eip-1014) forwarder already present on 100+ EVM chains — and neither takes constructor arguments. Identical creation code plus the same salt means the same address, on every chain, with no factory and no per-chain trusted deployer. | |
| 20 | + | |
| 21 | +The part that makes this non-trivial: `Love` still needs to know which [wETH](https://weth.io/) to peg to, and that address genuinely differs per chain. Baking it into the constructor would break the "identical creation code" property that makes the address deterministic in the first place. `WETHRegistry` exists to resolve that without reintroducing a trusted party. | |
| 22 | + | |
| 23 | +## The two-contract setup | |
| 24 | + | |
| 25 | +`WETHRegistry` deploys first, with a fixed salt (`keccak256("LOVE.WETHRegistry")`), landing at the same address on every chain: `0x9Cf17430fEdEC487518416D1Cdc849b1eE9CDbA3`, hardcoded into `Love` as `REGISTRY`. | |
| 26 | + | |
| 27 | +`register(candidate)` is permissionless, payable exactly `1 wei`, and accepts a candidate only if it passes two gates: | |
| 28 | + | |
| 29 | +1. **`EXTCODEHASH` against a compile-time allowlist** — the real defence. Matching means the candidate is provably one of a handful of implementations whose source has actually been reviewed, which is what catches a hidden mint, an upgrade hook, or a backdoor that no behavioral test could see. | |
| 30 | +2. **A one-wei deposit/withdraw round trip** — a cheap sanity check that the candidate actually wraps ether, catching the "wrong address, not wETH at all" mistake. | |
| 31 | + | |
| 32 | +Registration is write-once: no setter, no owner, no way to revoke an entry, so a `Love` deployed against a given registry can never have the token under its peg swapped later. | |
| 33 | + | |
| 34 | +`Love`'s constructor then reads `WETH` from the registry rather than taking it as an argument. That's the piece that keeps `Love`'s own creation code byte-identical everywhere — and so what gives `Love` one address on every chain instead of one per wETH deployment. | |
| 35 | + | |
| 36 | +## Deploying | |
| 37 | + | |
| 38 | +Bringing up a chain is registry, then registration, then token; [`script/Love.s.sol`](../script/Love.s.sol)'s `run()` does all three and skips whichever has already happened: | |
| 39 | + | |
| 40 | +```shell | |
| 41 | +forge script script/Love.s.sol:LoveScript --sig 'predict()' # print the address without broadcasting | |
| 42 | + | |
| 43 | +WETH=0x4200000000000000000000000000000000000006 \ | |
| 44 | +forge script script/Love.s.sol:LoveScript \ | |
| 45 | + --rpc-url base_sepolia \ | |
| 46 | + --account "$DEPLOYER_ACCOUNT" \ | |
| 47 | + --broadcast \ | |
| 48 | + --verify | |
| 49 | +``` | |
| 50 | + | |
| 51 | +`WETH` defaults to the OP Stack predeploy and is ignored once a wETH is already registered on that chain; `SALT` overrides `Love`'s default salt (`keccak256("LOVE")`) — useful to mine a vanity address or to route around a chain where the registry has been squatted (see below). | |
| 52 | + | |
| 53 | +## Verifying determinism | |
| 54 | + | |
| 55 | +[`script/multichain-check.sh`](../script/multichain-check.sh) checks the actual claim rather than trusting it: it starts two local [Anvil](https://book.getfoundry.sh/anvil/) nodes with different chain IDs, gives them *different* wETH implementations at *different* addresses, brings both chains up, and exits non-zero if the two `Love` addresses differ. | |
| 56 | + | |
| 57 | +```shell | |
| 58 | +./script/multichain-check.sh | |
| 59 | +``` | |
| 60 | + | |
| 61 | +Override `PORT_A`, `PORT_B`, `CHAIN_ID_A`, `CHAIN_ID_B`, or `SALT` as needed. | |
| 62 | + | |
| 63 | +## The allowlist | |
| 64 | + | |
| 65 | +`isKnownImplementation` in `WETHRegistry` is compile-time, which is what makes it ownerless and append-only by construction: extending it means publishing a new registry, and every existing `Love` deployment is left exactly as it was — no key can revoke an entry and strand a live one. | |
| 66 | + | |
| 67 | +It currently covers 5 implementations across 14 chains, 10 exact `EXTCODEHASH` values (including solc metadata, grouped in the source by which chains share a hash): | |
| 68 | + | |
| 69 | +- OP Stack legacy WETH9 — Optimism, Base, Mode, Zora, World Chain, Lisk | |
| 70 | +- Canonical WETH9 — Ethereum, Linea, Polygon zkEVM | |
| 71 | +- OP Stack WETH — Unichain, Soneium, Ink | |
| 72 | +- Taiko | |
| 73 | +- Scroll | |
| 74 | + | |
| 75 | +Chains where wETH sits behind an upgradeable proxy (Gnosis, Blast, Arbitrum One, Fraxtal) are deliberately excluded: allowlisting a proxy's codehash would commit to a forwarder rather than to behavior, reinstating exactly the upgradeable-backdoor risk this design exists to avoid. [`script/weth-codehashes.sh`](../script/weth-codehashes.sh) re-derives the full list from live chain state across 27 surveyed chains — see [`docs/notes/weth-codehashes.md`](notes/weth-codehashes.md) for the survey itself. | |
| 76 | + | |
| 77 | +## What this doesn't guarantee | |
| 78 | + | |
| 79 | +The allowlist proves a candidate *is* a reviewed wETH implementation — it does not prove it is *the* wETH a chain's ecosystem actually uses. Anyone can deploy a fresh copy of WETH9 — identical bytecode, no liquidity — and register it first. Nothing is stealable, since it's real WETH9 and fully redeemable, but always check `WETH()` against the chain's canonical wETH address before treating a `Love` deployment as the real one. Write-once means a squatted registry can't be fixed in place; the answer is a fresh `SALT`. | |
| 80 | + | |
| 81 | +## Further reading | |
| 82 | + | |
| 83 | +- [`docs/notes/weth.md`](notes/weth.md) — the design rationale, including approaches considered and rejected | |
| 84 | +- [`docs/notes/weth-codehashes.md`](notes/weth-codehashes.md) — the 27-chain bytecode survey behind the allowlist | |
| 85 | +- [EIP-1014: Skinny CREATE2](https://eips.ethereum.org/EIPS/eip-1014) | |
| 86 | +- [Deterministic Deployment Proxy](https://github.com/Arachnid/deterministic-deployment-proxy) | |
| 87 | +- [Canonical WETH9 source](https://github.com/gnosis/canonical-weth) | |
| new file mode 100644 | |||
| @@ -0,0 +1,87 @@ | |||
| 1 | +# Multichain Deployment | ||
| 2 | + | ||
| 3 | +How CREATE2 and a bytecode-hash allowlist give LOVE the same address on every EVM chain, how to deploy it, and how to verify that determinism yourself. | ||
| 4 | + | ||
| 5 | +## Contents | ||
| 6 | + | ||
| 7 | +- [Multichain Deployment](#multichain-deployment) | ||
| 8 | + - [Contents](#contents) | ||
| 9 | + - [Overview](#overview) | ||
| 10 | + - [The two-contract setup](#the-two-contract-setup) | ||
| 11 | + - [Deploying](#deploying) | ||
| 12 | + - [Verifying determinism](#verifying-determinism) | ||
| 13 | + - [The allowlist](#the-allowlist) | ||
| 14 | + - [What this doesn't guarantee](#what-this-doesnt-guarantee) | ||
| 15 | + - [Further reading](#further-reading) | ||
| 16 | + | ||
| 17 | +## Overview | ||
| 18 | + | ||
| 19 | +[`Love`](../src/Love.sol) and [`WETHRegistry`](../src/WETHRegistry.sol) both deploy through the canonical, ownerless [deterministic deployment proxy](https://github.com/Arachnid/deterministic-deployment-proxy) — the keyless [`CREATE2`](https://eips.ethereum.org/EIPS/eip-1014) forwarder already present on 100+ EVM chains — and neither takes constructor arguments. Identical creation code plus the same salt means the same address, on every chain, with no factory and no per-chain trusted deployer. | ||
| 20 | + | ||
| 21 | +The part that makes this non-trivial: `Love` still needs to know which [wETH](https://weth.io/) to peg to, and that address genuinely differs per chain. Baking it into the constructor would break the "identical creation code" property that makes the address deterministic in the first place. `WETHRegistry` exists to resolve that without reintroducing a trusted party. | ||
| 22 | + | ||
| 23 | +## The two-contract setup | ||
| 24 | + | ||
| 25 | +`WETHRegistry` deploys first, with a fixed salt (`keccak256("LOVE.WETHRegistry")`), landing at the same address on every chain: `0x9Cf17430fEdEC487518416D1Cdc849b1eE9CDbA3`, hardcoded into `Love` as `REGISTRY`. | ||
| 26 | + | ||
| 27 | +`register(candidate)` is permissionless, payable exactly `1 wei`, and accepts a candidate only if it passes two gates: | ||
| 28 | + | ||
| 29 | +1. **`EXTCODEHASH` against a compile-time allowlist** — the real defence. Matching means the candidate is provably one of a handful of implementations whose source has actually been reviewed, which is what catches a hidden mint, an upgrade hook, or a backdoor that no behavioral test could see. | ||
| 30 | +2. **A one-wei deposit/withdraw round trip** — a cheap sanity check that the candidate actually wraps ether, catching the "wrong address, not wETH at all" mistake. | ||
| 31 | + | ||
| 32 | +Registration is write-once: no setter, no owner, no way to revoke an entry, so a `Love` deployed against a given registry can never have the token under its peg swapped later. | ||
| 33 | + | ||
| 34 | +`Love`'s constructor then reads `WETH` from the registry rather than taking it as an argument. That's the piece that keeps `Love`'s own creation code byte-identical everywhere — and so what gives `Love` one address on every chain instead of one per wETH deployment. | ||
| 35 | + | ||
| 36 | +## Deploying | ||
| 37 | + | ||
| 38 | +Bringing up a chain is registry, then registration, then token; [`script/Love.s.sol`](../script/Love.s.sol)'s `run()` does all three and skips whichever has already happened: | ||
| 39 | + | ||
| 40 | +```shell | ||
| 41 | +forge script script/Love.s.sol:LoveScript --sig 'predict()' # print the address without broadcasting | ||
| 42 | + | ||
| 43 | +WETH=0x4200000000000000000000000000000000000006 \ | ||
| 44 | +forge script script/Love.s.sol:LoveScript \ | ||
| 45 | + --rpc-url base_sepolia \ | ||
| 46 | + --account "$DEPLOYER_ACCOUNT" \ | ||
| 47 | + --broadcast \ | ||
| 48 | + --verify | ||
| 49 | +``` | ||
| 50 | + | ||
| 51 | +`WETH` defaults to the OP Stack predeploy and is ignored once a wETH is already registered on that chain; `SALT` overrides `Love`'s default salt (`keccak256("LOVE")`) — useful to mine a vanity address or to route around a chain where the registry has been squatted (see below). | ||
| 52 | + | ||
| 53 | +## Verifying determinism | ||
| 54 | + | ||
| 55 | +[`script/multichain-check.sh`](../script/multichain-check.sh) checks the actual claim rather than trusting it: it starts two local [Anvil](https://book.getfoundry.sh/anvil/) nodes with different chain IDs, gives them *different* wETH implementations at *different* addresses, brings both chains up, and exits non-zero if the two `Love` addresses differ. | ||
| 56 | + | ||
| 57 | +```shell | ||
| 58 | +./script/multichain-check.sh | ||
| 59 | +``` | ||
| 60 | + | ||
| 61 | +Override `PORT_A`, `PORT_B`, `CHAIN_ID_A`, `CHAIN_ID_B`, or `SALT` as needed. | ||
| 62 | + | ||
| 63 | +## The allowlist | ||
| 64 | + | ||
| 65 | +`isKnownImplementation` in `WETHRegistry` is compile-time, which is what makes it ownerless and append-only by construction: extending it means publishing a new registry, and every existing `Love` deployment is left exactly as it was — no key can revoke an entry and strand a live one. | ||
| 66 | + | ||
| 67 | +It currently covers 5 implementations across 14 chains, 10 exact `EXTCODEHASH` values (including solc metadata, grouped in the source by which chains share a hash): | ||
| 68 | + | ||
| 69 | +- OP Stack legacy WETH9 — Optimism, Base, Mode, Zora, World Chain, Lisk | ||
| 70 | +- Canonical WETH9 — Ethereum, Linea, Polygon zkEVM | ||
| 71 | +- OP Stack WETH — Unichain, Soneium, Ink | ||
| 72 | +- Taiko | ||
| 73 | +- Scroll | ||
| 74 | + | ||
| 75 | +Chains where wETH sits behind an upgradeable proxy (Gnosis, Blast, Arbitrum One, Fraxtal) are deliberately excluded: allowlisting a proxy's codehash would commit to a forwarder rather than to behavior, reinstating exactly the upgradeable-backdoor risk this design exists to avoid. [`script/weth-codehashes.sh`](../script/weth-codehashes.sh) re-derives the full list from live chain state across 27 surveyed chains — see [`docs/notes/weth-codehashes.md`](notes/weth-codehashes.md) for the survey itself. | ||
| 76 | + | ||
| 77 | +## What this doesn't guarantee | ||
| 78 | + | ||
| 79 | +The allowlist proves a candidate *is* a reviewed wETH implementation — it does not prove it is *the* wETH a chain's ecosystem actually uses. Anyone can deploy a fresh copy of WETH9 — identical bytecode, no liquidity — and register it first. Nothing is stealable, since it's real WETH9 and fully redeemable, but always check `WETH()` against the chain's canonical wETH address before treating a `Love` deployment as the real one. Write-once means a squatted registry can't be fixed in place; the answer is a fresh `SALT`. | ||
| 80 | + | ||
| 81 | +## Further reading | ||
| 82 | + | ||
| 83 | +- [`docs/notes/weth.md`](notes/weth.md) — the design rationale, including approaches considered and rejected | ||
| 84 | +- [`docs/notes/weth-codehashes.md`](notes/weth-codehashes.md) — the 27-chain bytecode survey behind the allowlist | ||
| 85 | +- [EIP-1014: Skinny CREATE2](https://eips.ethereum.org/EIPS/eip-1014) | ||
| 86 | +- [Deterministic Deployment Proxy](https://github.com/Arachnid/deterministic-deployment-proxy) | ||
| 87 | +- [Canonical WETH9 source](https://github.com/gnosis/canonical-weth) | ||
added
docs/PRIVACY.md +137 -0 | new file mode 100644 | ||
| @@ -0,0 +1,137 @@ | ||
| 1 | +# Privacy | |
| 2 | + | |
| 3 | +How to send LOVE by secret instead of by address, what the depositor and recipient each do, and which parts of that flow the contract cannot enforce. | |
| 4 | + | |
| 5 | +## Contents | |
| 6 | + | |
| 7 | +- [Privacy](#privacy) | |
| 8 | + - [Contents](#contents) | |
| 9 | + - [Overview](#overview) | |
| 10 | + - [The workflow](#the-workflow) | |
| 11 | + - [Code snippets](#code-snippets) | |
| 12 | + - [What's happening off-chain](#whats-happening-off-chain) | |
| 13 | + - [Bearer semantics and the front-running window](#bearer-semantics-and-the-front-running-window) | |
| 14 | + - [Reference](#reference) | |
| 15 | + - [Further reading](#further-reading) | |
| 16 | + | |
| 17 | +## Overview | |
| 18 | + | |
| 19 | +[`Love`](../src/Love.sol) normally works like any [ERC-20](https://eips.ethereum.org/EIPS/eip-20): `deposit(uint256 wethAmount)` locks wETH and mints LOVE straight to the caller. A second, overloaded entrypoint, `deposit(bytes32 secretHash, uint256 wethAmount)`, mints that same LOVE into an escrowed envelope instead — unlockable by whoever can later produce the secret behind `secretHash`, not by whoever the depositor names on-chain. `claim` releases it. | |
| 20 | + | |
| 21 | +That on-chain pair is deliberately minimal: it stores a hash and moves tokens. Whether using it actually keeps a transfer private depends entirely on what the depositor and recipient do around it, off-chain, before and after those two calls. This document walks through that whole flow, on-chain and off. | |
| 22 | + | |
| 23 | +## The workflow | |
| 24 | + | |
| 25 | +1. **The depositor generates a secret — off-chain.** A random `bytes32`, produced locally with a cryptographically secure generator ([`crypto.getRandomValues`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) in a browser, or a wallet's native RNG), never sent anywhere. | |
| 26 | +2. **The depositor opens an envelope — on-chain.** `deposit(keccak256(abi.encodePacked(secret)), wethAmount)` pulls the wETH, mints `wethAmount * RATE` LOVE, and locks it under a fresh `depositId`. Only the hash goes on-chain; the secret itself never does. | |
| 27 | +3. **The depositor packages the claim code — off-chain.** `depositId` and `secret` together, typically as a URL fragment (`#depositId=...&secret=...`) or a QR code — a fragment specifically, since fragments never reach a server, unlike a query string. | |
| 28 | +4. **The depositor shares it — off-chain.** Over whatever out-of-band channel they already trust: an end-to-end encrypted chat, a QR code shown in person, a printed card. Never a channel with a persistent public log. | |
| 29 | +5. **The recipient stores it — off-chain.** As a bearer instrument: whoever holds the code can claim, and there is no recovery if it's lost. | |
| 30 | +6. **The recipient claims — on-chain.** `claim(depositId, secret, loveAmount)` proves knowledge of the secret and moves `loveAmount` LOVE from escrow to whoever called it. No address is checked, so this can be anyone who has the code — including, deliberately, someone other than the original depositor's counterpart, which is what makes the transfer unlinkable on-chain. | |
| 31 | +7. **The recipient redeems for wETH — on-chain, optional, and later.** The claimed LOVE is now an ordinary balance. `withdraw(loveAmount)`, entirely unrelated to any of the above, burns it for wETH whenever the recipient chooses. | |
| 32 | + | |
| 33 | +## Code snippets | |
| 34 | + | |
| 35 | +The off-chain steps above, in [TypeScript](https://www.typescriptlang.org/) using [ethers](https://docs.ethers.org/v6/) v6. `love` is a `Contract` instance for [`Love`](../src/Love.sol); `weth` one for the registered wETH. | |
| 36 | + | |
| 37 | +**Generating a secret.** Client-side, with the browser's CSPRNG — never a library seeded from anything predictable, and never sent to a server. | |
| 38 | + | |
| 39 | +```ts | |
| 40 | +function generateSecret(): `0x${string}` { | |
| 41 | + const bytes = new Uint8Array(32); | |
| 42 | + crypto.getRandomValues(bytes); | |
| 43 | + return ethers.hexlify(bytes) as `0x${string}`; | |
| 44 | +} | |
| 45 | +``` | |
| 46 | + | |
| 47 | +**Opening an envelope.** Hash the secret, approve the wETH, then call the secret-locked `deposit` overload — ethers needs the full signature to pick it over the plain `deposit(uint256)`. | |
| 48 | + | |
| 49 | +```ts | |
| 50 | +async function openEnvelope(wethAmount: bigint) { | |
| 51 | + const secret = generateSecret(); | |
| 52 | + const secretHash = ethers.keccak256(secret); | |
| 53 | + | |
| 54 | + await (await weth.approve(love.target, wethAmount)).wait(); | |
| 55 | + | |
| 56 | + const tx = await love.getFunction("deposit(bytes32,uint256)")(secretHash, wethAmount); | |
| 57 | + const receipt = await tx.wait(); | |
| 58 | + | |
| 59 | + const deposited = receipt.logs | |
| 60 | + .map((log: any) => love.interface.parseLog(log)) | |
| 61 | + .find((parsed: any) => parsed?.name === "Deposit" && parsed.args.length === 5); | |
| 62 | + | |
| 63 | + const depositId: bigint = deposited!.args.depositId; | |
| 64 | + return { depositId, secret }; | |
| 65 | +} | |
| 66 | +``` | |
| 67 | + | |
| 68 | +**Packaging the claim link.** A URL *fragment*, so a link-preview bot hitting the claim page server-side never sees the secret. | |
| 69 | + | |
| 70 | +```ts | |
| 71 | +function buildClaimUrl(depositId: bigint, secret: `0x${string}`): string { | |
| 72 | + const url = new URL("https://app.example/claim"); | |
| 73 | + url.hash = `depositId=${depositId}&secret=${secret}`; | |
| 74 | + return url.toString(); | |
| 75 | +} | |
| 76 | +``` | |
| 77 | + | |
| 78 | +**Parsing it back out**, on the claim page — reading `location.hash` client-side only, never anything the server saw. | |
| 79 | + | |
| 80 | +```ts | |
| 81 | +function parseClaimUrl(url: string): { depositId: bigint; secret: `0x${string}` } { | |
| 82 | + const params = new URLSearchParams(new URL(url).hash.slice(1)); | |
| 83 | + return { | |
| 84 | + depositId: BigInt(params.get("depositId")!), | |
| 85 | + secret: params.get("secret") as `0x${string}`, | |
| 86 | + }; | |
| 87 | +} | |
| 88 | +``` | |
| 89 | + | |
| 90 | +**Claiming.** | |
| 91 | + | |
| 92 | +```ts | |
| 93 | +async function claimEnvelope(depositId: bigint, secret: `0x${string}`, loveAmount: bigint) { | |
| 94 | + const tx = await love.claim(depositId, secret, loveAmount); | |
| 95 | + await tx.wait(); | |
| 96 | +} | |
| 97 | +``` | |
| 98 | + | |
| 99 | +## What's happening off-chain | |
| 100 | + | |
| 101 | +None of the steps above marked off-chain are visible to, or enforced by, the contract. They are the actual privacy mechanism; the contract just custodies funds against a hash. Six things have to go right: | |
| 102 | + | |
| 103 | +- **Generation.** The secret must never exist on a server — not for "generating a shareable link," not for anything. A secret a backend could reconstruct or log defeats the design before the first transaction. | |
| 104 | +- **Encoding.** Packing `depositId` and `secret` into a URL *fragment* rather than a query parameter means a link-preview bot, a server access log, or an analytics pixel on the claim page can't capture the secret just from the page being visited. | |
| 105 | +- **Distribution.** Privacy comes from the channel's own properties — end-to-end encryption, no third-party log — not from anything on-chain. Posting a claim link in a public Discord or a public GitHub issue undoes the whole design before anyone even claims. | |
| 106 | +- **Storage and recovery.** An unclaimed code is a bearer note. There's no on-chain notion of "identity," only "knowledge of the secret," so a lost code means permanently locked funds — the wallet holding it should treat it with the same care as a seed phrase fragment. | |
| 107 | +- **Timing discipline.** A `Deposit` event followed within seconds by a `Claim` is a timing correlation an observer can use to link depositor and recipient even without any secret ever leaking. Instant-claim flows undermine the "untraceable if not claimed at the same time" property the design is going for. | |
| 108 | +- **Operational hygiene.** No indexer should build a queryable depositId-to-claimant lookup, and no front-end should let a server "preview" a claim URL — both leak exactly the correlation this design exists to avoid. | |
| 109 | + | |
| 110 | +The full detail behind each of these — including recommendations for wallets and front-ends building on top of LOVE — is in [`docs/notes/add-privacy-to-love.md`](notes/add-privacy-to-love.md). | |
| 111 | + | |
| 112 | +## Bearer semantics and the front-running window | |
| 113 | + | |
| 114 | +`claim` has no recipient check on purpose: paying out to whoever proves the secret first is what makes the envelope a bearer instrument rather than an address-bound transfer. That same property opens a front-running window. `remaining` supports partial claims, so if a second, smaller claim is expected later, the *first* `claim`'s calldata already reveals `secret` publicly — in the mempool before confirmation, and permanently once mined. A bot watching for that calldata can submit its own `claim` for whatever is `remaining` before the intended recipient's second call lands. | |
| 115 | + | |
| 116 | +Two mitigations, both off-chain, both covered in the note above: | |
| 117 | + | |
| 118 | +- Submit `claim` through a private transaction relay (e.g. [Flashbots Protect](https://docs.flashbots.net/flashbots-protect/overview)) whenever a further partial claim is expected against the same envelope. | |
| 119 | +- Prefer a single full claim for high-value envelopes, reserving multi-claim convenience for lower-stakes amounts where the exposure matters less. | |
| 120 | + | |
| 121 | +## Reference | |
| 122 | + | |
| 123 | +| Function | Effect | | |
| 124 | +|---|---| | |
| 125 | +| `deposit(uint256 wethAmount)` | Plain peg deposit — mints LOVE straight to the caller. | | |
| 126 | +| `deposit(bytes32 secretHash, uint256 wethAmount) returns (uint256 depositId)` | Opens an envelope: mints LOVE into escrow, locked behind `secretHash`. | | |
| 127 | +| `claim(uint256 depositId, bytes32 secret, uint256 loveAmount)` | Releases up to `loveAmount` LOVE from an envelope to whoever proves `secret`. | | |
| 128 | +| `withdraw(uint256 loveAmount)` | Burns the caller's own LOVE for wETH. Unrelated to envelopes; unchanged by this feature. | | |
| 129 | + | |
| 130 | +Events: `Deposit` (both overloads), `Claim`, `Withdraw`. Errors specific to envelopes: `UnknownDeposit`, `WrongSecret`, `InsufficientRemaining`. | |
| 131 | + | |
| 132 | +## Further reading | |
| 133 | + | |
| 134 | +- [`docs/notes/add-privacy-to-love.md`](notes/add-privacy-to-love.md) — the off-chain processes this document summarizes, in full | |
| 135 | +- [MDN: Crypto.getRandomValues()](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) | |
| 136 | +- [Flashbots Protect — private transaction relay](https://docs.flashbots.net/flashbots-protect/overview) | |
| 137 | +- [URL fragment identifier — MDN](https://developer.mozilla.org/en-US/docs/Web/URI/Fragment) | |
| new file mode 100644 | |||
| @@ -0,0 +1,137 @@ | |||
| 1 | +# Privacy | ||
| 2 | + | ||
| 3 | +How to send LOVE by secret instead of by address, what the depositor and recipient each do, and which parts of that flow the contract cannot enforce. | ||
| 4 | + | ||
| 5 | +## Contents | ||
| 6 | + | ||
| 7 | +- [Privacy](#privacy) | ||
| 8 | + - [Contents](#contents) | ||
| 9 | + - [Overview](#overview) | ||
| 10 | + - [The workflow](#the-workflow) | ||
| 11 | + - [Code snippets](#code-snippets) | ||
| 12 | + - [What's happening off-chain](#whats-happening-off-chain) | ||
| 13 | + - [Bearer semantics and the front-running window](#bearer-semantics-and-the-front-running-window) | ||
| 14 | + - [Reference](#reference) | ||
| 15 | + - [Further reading](#further-reading) | ||
| 16 | + | ||
| 17 | +## Overview | ||
| 18 | + | ||
| 19 | +[`Love`](../src/Love.sol) normally works like any [ERC-20](https://eips.ethereum.org/EIPS/eip-20): `deposit(uint256 wethAmount)` locks wETH and mints LOVE straight to the caller. A second, overloaded entrypoint, `deposit(bytes32 secretHash, uint256 wethAmount)`, mints that same LOVE into an escrowed envelope instead — unlockable by whoever can later produce the secret behind `secretHash`, not by whoever the depositor names on-chain. `claim` releases it. | ||
| 20 | + | ||
| 21 | +That on-chain pair is deliberately minimal: it stores a hash and moves tokens. Whether using it actually keeps a transfer private depends entirely on what the depositor and recipient do around it, off-chain, before and after those two calls. This document walks through that whole flow, on-chain and off. | ||
| 22 | + | ||
| 23 | +## The workflow | ||
| 24 | + | ||
| 25 | +1. **The depositor generates a secret — off-chain.** A random `bytes32`, produced locally with a cryptographically secure generator ([`crypto.getRandomValues`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) in a browser, or a wallet's native RNG), never sent anywhere. | ||
| 26 | +2. **The depositor opens an envelope — on-chain.** `deposit(keccak256(abi.encodePacked(secret)), wethAmount)` pulls the wETH, mints `wethAmount * RATE` LOVE, and locks it under a fresh `depositId`. Only the hash goes on-chain; the secret itself never does. | ||
| 27 | +3. **The depositor packages the claim code — off-chain.** `depositId` and `secret` together, typically as a URL fragment (`#depositId=...&secret=...`) or a QR code — a fragment specifically, since fragments never reach a server, unlike a query string. | ||
| 28 | +4. **The depositor shares it — off-chain.** Over whatever out-of-band channel they already trust: an end-to-end encrypted chat, a QR code shown in person, a printed card. Never a channel with a persistent public log. | ||
| 29 | +5. **The recipient stores it — off-chain.** As a bearer instrument: whoever holds the code can claim, and there is no recovery if it's lost. | ||
| 30 | +6. **The recipient claims — on-chain.** `claim(depositId, secret, loveAmount)` proves knowledge of the secret and moves `loveAmount` LOVE from escrow to whoever called it. No address is checked, so this can be anyone who has the code — including, deliberately, someone other than the original depositor's counterpart, which is what makes the transfer unlinkable on-chain. | ||
| 31 | +7. **The recipient redeems for wETH — on-chain, optional, and later.** The claimed LOVE is now an ordinary balance. `withdraw(loveAmount)`, entirely unrelated to any of the above, burns it for wETH whenever the recipient chooses. | ||
| 32 | + | ||
| 33 | +## Code snippets | ||
| 34 | + | ||
| 35 | +The off-chain steps above, in [TypeScript](https://www.typescriptlang.org/) using [ethers](https://docs.ethers.org/v6/) v6. `love` is a `Contract` instance for [`Love`](../src/Love.sol); `weth` one for the registered wETH. | ||
| 36 | + | ||
| 37 | +**Generating a secret.** Client-side, with the browser's CSPRNG — never a library seeded from anything predictable, and never sent to a server. | ||
| 38 | + | ||
| 39 | +```ts | ||
| 40 | +function generateSecret(): `0x${string}` { | ||
| 41 | + const bytes = new Uint8Array(32); | ||
| 42 | + crypto.getRandomValues(bytes); | ||
| 43 | + return ethers.hexlify(bytes) as `0x${string}`; | ||
| 44 | +} | ||
| 45 | +``` | ||
| 46 | + | ||
| 47 | +**Opening an envelope.** Hash the secret, approve the wETH, then call the secret-locked `deposit` overload — ethers needs the full signature to pick it over the plain `deposit(uint256)`. | ||
| 48 | + | ||
| 49 | +```ts | ||
| 50 | +async function openEnvelope(wethAmount: bigint) { | ||
| 51 | + const secret = generateSecret(); | ||
| 52 | + const secretHash = ethers.keccak256(secret); | ||
| 53 | + | ||
| 54 | + await (await weth.approve(love.target, wethAmount)).wait(); | ||
| 55 | + | ||
| 56 | + const tx = await love.getFunction("deposit(bytes32,uint256)")(secretHash, wethAmount); | ||
| 57 | + const receipt = await tx.wait(); | ||
| 58 | + | ||
| 59 | + const deposited = receipt.logs | ||
| 60 | + .map((log: any) => love.interface.parseLog(log)) | ||
| 61 | + .find((parsed: any) => parsed?.name === "Deposit" && parsed.args.length === 5); | ||
| 62 | + | ||
| 63 | + const depositId: bigint = deposited!.args.depositId; | ||
| 64 | + return { depositId, secret }; | ||
| 65 | +} | ||
| 66 | +``` | ||
| 67 | + | ||
| 68 | +**Packaging the claim link.** A URL *fragment*, so a link-preview bot hitting the claim page server-side never sees the secret. | ||
| 69 | + | ||
| 70 | +```ts | ||
| 71 | +function buildClaimUrl(depositId: bigint, secret: `0x${string}`): string { | ||
| 72 | + const url = new URL("https://app.example/claim"); | ||
| 73 | + url.hash = `depositId=${depositId}&secret=${secret}`; | ||
| 74 | + return url.toString(); | ||
| 75 | +} | ||
| 76 | +``` | ||
| 77 | + | ||
| 78 | +**Parsing it back out**, on the claim page — reading `location.hash` client-side only, never anything the server saw. | ||
| 79 | + | ||
| 80 | +```ts | ||
| 81 | +function parseClaimUrl(url: string): { depositId: bigint; secret: `0x${string}` } { | ||
| 82 | + const params = new URLSearchParams(new URL(url).hash.slice(1)); | ||
| 83 | + return { | ||
| 84 | + depositId: BigInt(params.get("depositId")!), | ||
| 85 | + secret: params.get("secret") as `0x${string}`, | ||
| 86 | + }; | ||
| 87 | +} | ||
| 88 | +``` | ||
| 89 | + | ||
| 90 | +**Claiming.** | ||
| 91 | + | ||
| 92 | +```ts | ||
| 93 | +async function claimEnvelope(depositId: bigint, secret: `0x${string}`, loveAmount: bigint) { | ||
| 94 | + const tx = await love.claim(depositId, secret, loveAmount); | ||
| 95 | + await tx.wait(); | ||
| 96 | +} | ||
| 97 | +``` | ||
| 98 | + | ||
| 99 | +## What's happening off-chain | ||
| 100 | + | ||
| 101 | +None of the steps above marked off-chain are visible to, or enforced by, the contract. They are the actual privacy mechanism; the contract just custodies funds against a hash. Six things have to go right: | ||
| 102 | + | ||
| 103 | +- **Generation.** The secret must never exist on a server — not for "generating a shareable link," not for anything. A secret a backend could reconstruct or log defeats the design before the first transaction. | ||
| 104 | +- **Encoding.** Packing `depositId` and `secret` into a URL *fragment* rather than a query parameter means a link-preview bot, a server access log, or an analytics pixel on the claim page can't capture the secret just from the page being visited. | ||
| 105 | +- **Distribution.** Privacy comes from the channel's own properties — end-to-end encryption, no third-party log — not from anything on-chain. Posting a claim link in a public Discord or a public GitHub issue undoes the whole design before anyone even claims. | ||
| 106 | +- **Storage and recovery.** An unclaimed code is a bearer note. There's no on-chain notion of "identity," only "knowledge of the secret," so a lost code means permanently locked funds — the wallet holding it should treat it with the same care as a seed phrase fragment. | ||
| 107 | +- **Timing discipline.** A `Deposit` event followed within seconds by a `Claim` is a timing correlation an observer can use to link depositor and recipient even without any secret ever leaking. Instant-claim flows undermine the "untraceable if not claimed at the same time" property the design is going for. | ||
| 108 | +- **Operational hygiene.** No indexer should build a queryable depositId-to-claimant lookup, and no front-end should let a server "preview" a claim URL — both leak exactly the correlation this design exists to avoid. | ||
| 109 | + | ||
| 110 | +The full detail behind each of these — including recommendations for wallets and front-ends building on top of LOVE — is in [`docs/notes/add-privacy-to-love.md`](notes/add-privacy-to-love.md). | ||
| 111 | + | ||
| 112 | +## Bearer semantics and the front-running window | ||
| 113 | + | ||
| 114 | +`claim` has no recipient check on purpose: paying out to whoever proves the secret first is what makes the envelope a bearer instrument rather than an address-bound transfer. That same property opens a front-running window. `remaining` supports partial claims, so if a second, smaller claim is expected later, the *first* `claim`'s calldata already reveals `secret` publicly — in the mempool before confirmation, and permanently once mined. A bot watching for that calldata can submit its own `claim` for whatever is `remaining` before the intended recipient's second call lands. | ||
| 115 | + | ||
| 116 | +Two mitigations, both off-chain, both covered in the note above: | ||
| 117 | + | ||
| 118 | +- Submit `claim` through a private transaction relay (e.g. [Flashbots Protect](https://docs.flashbots.net/flashbots-protect/overview)) whenever a further partial claim is expected against the same envelope. | ||
| 119 | +- Prefer a single full claim for high-value envelopes, reserving multi-claim convenience for lower-stakes amounts where the exposure matters less. | ||
| 120 | + | ||
| 121 | +## Reference | ||
| 122 | + | ||
| 123 | +| Function | Effect | | ||
| 124 | +|---|---| | ||
| 125 | +| `deposit(uint256 wethAmount)` | Plain peg deposit — mints LOVE straight to the caller. | | ||
| 126 | +| `deposit(bytes32 secretHash, uint256 wethAmount) returns (uint256 depositId)` | Opens an envelope: mints LOVE into escrow, locked behind `secretHash`. | | ||
| 127 | +| `claim(uint256 depositId, bytes32 secret, uint256 loveAmount)` | Releases up to `loveAmount` LOVE from an envelope to whoever proves `secret`. | | ||
| 128 | +| `withdraw(uint256 loveAmount)` | Burns the caller's own LOVE for wETH. Unrelated to envelopes; unchanged by this feature. | | ||
| 129 | + | ||
| 130 | +Events: `Deposit` (both overloads), `Claim`, `Withdraw`. Errors specific to envelopes: `UnknownDeposit`, `WrongSecret`, `InsufficientRemaining`. | ||
| 131 | + | ||
| 132 | +## Further reading | ||
| 133 | + | ||
| 134 | +- [`docs/notes/add-privacy-to-love.md`](notes/add-privacy-to-love.md) — the off-chain processes this document summarizes, in full | ||
| 135 | +- [MDN: Crypto.getRandomValues()](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) | ||
| 136 | +- [Flashbots Protect — private transaction relay](https://docs.flashbots.net/flashbots-protect/overview) | ||
| 137 | +- [URL fragment identifier — MDN](https://developer.mozilla.org/en-US/docs/Web/URI/Fragment) | ||