julien/lovepublic Fork
⑂ e99639d
Commits
⬇ Clone ▾
git clone https://git.rickub.com/julien/love.git
git clone ssh://git@rickub.com/julien/love.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Merge 7-add-docs into main

rickub committed 2026-09-18T13:45:03Z Browse files
e99639d parent: 13964fb
modified README.md +12 -0
@@ -19,6 +19,15 @@ forge fmt # format
1919 anvil # local node
2020 ```
2121
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+
2231 ## How wETH is chosen
2332
2433 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
4958 WETH9 and fully redeemable, but check `WETH()` against the chain's canonical
5059 address before treating a deployment as the real one.
5160
61+Full design rationale, the allowlist's contents, and how to verify determinism
62+yourself: see [docs/MULTICHAIN.md](docs/MULTICHAIN.md).
63+
5264 ## Deploy
5365
5466 Deployment uses CREATE2, so the token has the same address on every EVM network.
@@ -19,6 +19,15 @@ forge fmt # format
19 anvil # local node19 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 chosen31 ## How wETH is chosen
23 32
24 LOVE is pegged to wETH, and that wETH is not a constructor argument. It is read33 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 canonical58 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 ## Deploy64 ## 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)