mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 17:58:47 +00:00
names: changelog entry and design doc
This commit is contained in:
committed by
Alain Brenzikofer
parent
a62d85514c
commit
d266ba9f17
@@ -1,5 +1,16 @@
|
||||
# Release History
|
||||
|
||||
## Unreleased
|
||||
|
||||
SimpleX names v2 (in development, core only):
|
||||
- Non-custodial in-app name registration: BIP-39/32 wallet and ERC-5564 stealth
|
||||
addresses (via simplexmq), a names-service interface with an in-memory mock
|
||||
that verifies real EIP-712 signatures, per-profile derived accounts, and
|
||||
signed-intent commands for transfer, record edits and gifting.
|
||||
- CLI and end-to-end tests only in this change; the GUIs follow in a stacked PR,
|
||||
and the flow runs against the mock, not a deployed contract.
|
||||
- Design: docs/rfcs/2026-08-05-simplex-names-v2.md
|
||||
|
||||
## v6.5
|
||||
|
||||
30 April, 2026
|
||||
|
||||
@@ -0,0 +1,888 @@
|
||||
# SimpleX Names v2 — in-app purchase, non-custodial ownership
|
||||
|
||||
This is the design for moving SimpleX name registration into the app, with in-app
|
||||
payment and non-custodial ownership. The wallet underneath is built for where it is
|
||||
going, not only for names.
|
||||
|
||||
## Table of contents
|
||||
|
||||
1. [Design decisions at a glance](#1-design-decisions-at-a-glance)
|
||||
2. [The vision the wallet must survive](#2-the-vision-the-wallet-must-survive)
|
||||
3. [Unlinkability: what it buys and what it does not](#3-unlinkability-what-it-buys-and-what-it-does-not)
|
||||
4. [Context](#4-context)
|
||||
5. [Executive summary](#5-executive-summary)
|
||||
6. [Decisions taken](#6-decisions-taken)
|
||||
7. [Architecture](#7-architecture)
|
||||
8. [Workstream A — contracts](#8-workstream-a--contracts)
|
||||
9. [Workstream B — client crypto](#9-workstream-b--client-crypto)
|
||||
10. [Workstream C — the wallet](#10-workstream-c--the-wallet)
|
||||
11. [Workstream D — names service](#11-workstream-d--names-service)
|
||||
12. [Workstream E — payments](#12-workstream-e--payments)
|
||||
13. [Workstream F — UI](#13-workstream-f--ui)
|
||||
14. [Decisions](#14-decisions)
|
||||
15. [Sequencing](#15-sequencing)
|
||||
16. [Verification](#16-verification)
|
||||
17. [Risks and accepted limitations](#17-risks-and-accepted-limitations)
|
||||
|
||||
---
|
||||
|
||||
## 1. Design decisions at a glance
|
||||
|
||||
The choices this plan rests on, each expanded in a later section:
|
||||
|
||||
- **The wallet is chain-generic and stealth-first.** Every chain is modelled as a
|
||||
`(spend, view)` key pair per profile account, with one-time destination addresses
|
||||
derived by the sender and recoverable by the recipient from the view key alone.
|
||||
Ethereum, Bitcoin and Monero all fit this shape. §2, §10.
|
||||
- **A recipient can decline a name and avoid the on-chain link.** Received names land
|
||||
on one-time addresses and are never auto-claimed or auto-displayed. Preventing
|
||||
someone from squatting your name and asserting it is yours is *not* attempted — it
|
||||
is stated as a non-goal so it is not re-litigated. §3.
|
||||
- **The sender learns where to transfer via a published stealth meta-address**, so a
|
||||
name can be gifted with no handshake and one message; the on-chain announcement
|
||||
exists only so the recovery phrase alone can rediscover a gift. No background scan. §7.
|
||||
- **Acceptance is a paid on-chain write**, not a local action (§7.6); `grantEditCredits`
|
||||
**adds** rather than sets, because `renew()` is unauthenticated (§8 A2); a transfer
|
||||
requires `to != from` to stop free announcement spam (§8 A1); recovery discovers
|
||||
accounts via a cheap `balanceOf` probe and a gap limit (§10 C6); the client signs
|
||||
structured intents only, never opaque digests (§10 C3); and `exportOneTimeKey` keeps
|
||||
received names non-custodial (§10 C3).
|
||||
- **A name is the only identity that survives a dead device.** The recovery phrase
|
||||
recovers assets, not profiles: the owner re-points the name at a fresh address and
|
||||
everyone who knows it finds them again. Most users have no database backup at all,
|
||||
so the phrase is the primary recovery path, not a fallback. §5, C6, C8.
|
||||
- **Registration is funded by on-chain credits, one-time purchases for 1–10 years, a
|
||||
permanent beneficiary role, and a staged lockdown** of the deployed contracts. §5, §8.
|
||||
|
||||
## 2. The vision the wallet must survive
|
||||
|
||||
Names are the first use of the wallet, not its only purpose. The direction is:
|
||||
|
||||
- **ERC-20 transfers between SimpleX users with as little linkability as possible.**
|
||||
- **Bitcoin and Monero**, from the same seed, with as close to the same properties as each chain allows.
|
||||
- One recovery phrase behind all of it.
|
||||
|
||||
All three chains already converge on one shape, which is what makes this tractable rather than
|
||||
three separate designs:
|
||||
|
||||
| Chain | Mechanism | Spend / view keys | Status |
|
||||
|---|---|---|---|
|
||||
| Ethereum | ERC-5564 stealth addresses | spending key + viewing key | standard, needs an announcer |
|
||||
| Bitcoin | BIP-352 silent payments | spend key + scan key | standard, no notification tx needed |
|
||||
| Monero | native one-time addresses | spend key + view key | built into the protocol |
|
||||
|
||||
Monero has done this since inception; BIP-352 is Bitcoin catching up; ERC-5564 is Ethereum's
|
||||
version. So the abstraction is not speculative — it is the shape all three arrived at
|
||||
independently.
|
||||
|
||||
**The wallet's core type is therefore `(chain, account) → (spend key, view key, discovered
|
||||
one-time keys)`**, not "an Ethereum key". Adding Bitcoin or Monero later becomes adding a chain
|
||||
module, not reworking the wallet. That is the whole point of doing this now: the seed layout and
|
||||
the account model are the two things that cannot be changed later without moving live assets.
|
||||
|
||||
Derivation, all hardened, one seed:
|
||||
|
||||
```
|
||||
seed (BIP-39)
|
||||
└── profile account i one per chat profile
|
||||
├── ETH m/44'/60'/i'/0/0 main account — names you register yourself
|
||||
│ m/5564'/60'/i'/0'/0 stealth spend
|
||||
│ m/5564'/60'/i'/1'/0 stealth view
|
||||
├── BTC m/352'/0'/i'/0'/0 silent-payment spend (BIP-352 layout)
|
||||
│ m/352'/0'/i'/1'/0 silent-payment scan
|
||||
└── XMR m/44'/128'/i'/0' → SHA3 → sc_reduce32 → spend/view, then native subaddresses
|
||||
```
|
||||
|
||||
The `0'` = spend, `1'` = view convention is BIP-352's; reusing it across chains means one mental
|
||||
model. There is no registered purpose number for ERC-5564, so `5564'` is ours — **define it once,
|
||||
document it, never change it**, because changing a derivation path after users hold assets is the
|
||||
one migration with no safe path.
|
||||
|
||||
Monero is the awkward one: ed25519 forbids non-hardened public derivation, and Monero applies an
|
||||
extra SHA3 and `sc_reduce32` to reach a valid scalar. It still hangs off the same seed, which is
|
||||
what matters.
|
||||
|
||||
**Out of scope for this plan.** No ERC-20 transfers, no BTC, no XMR. Only the seed layout, the
|
||||
account model and the storage schema are being fixed now, because those are what future work
|
||||
cannot change.
|
||||
|
||||
---
|
||||
|
||||
## 3. Unlinkability: what it buys and what it does not
|
||||
|
||||
**The requirement.** A recipient can decline a name sent to them, and by declining creates no
|
||||
on-chain link between themselves and it. Acceptance is the act that creates the link, and it is the
|
||||
recipient's alone.
|
||||
|
||||
**The non-goal, stated so it is not re-litigated.** Preventing someone from registering your name
|
||||
and asserting publicly that it is yours is *not* achievable and is not attempted. The registration
|
||||
is itself the assertion; no transfer is needed to make it, so no wallet design can prevent it. What
|
||||
the app does prevent is the assertion having any in-app effect: a name renders against a profile
|
||||
only with a `SimplexDomainProof` signed by that profile's SimpleX identity key
|
||||
(`Simplex/Chat/Names.hs:35-41`, `contactDomainVerified` on `LocalProfile`). A squatter cannot make
|
||||
the app show their name on your profile, whoever holds the token.
|
||||
|
||||
### What the meta-address does and does not expose
|
||||
|
||||
Deriving a one-time address requires either the sender's ephemeral secret `r` or the recipient's
|
||||
private viewing key `v`. **A meta-address is neither.** So a holder of `(P_s, P_v)` — which after
|
||||
§10 C4 means every member of every group you are in — can send you a name, and nothing more:
|
||||
|
||||
- they **cannot** find the one-time addresses of names you received,
|
||||
- they **cannot** link two gifts from different senders to each other,
|
||||
- they **cannot** tell whether you accepted.
|
||||
|
||||
Broad distribution is therefore a spam surface, not a privacy leak. The residual privacy facts are
|
||||
narrower and both inherent: **the sender always knows**, and can prove the derivation by revealing
|
||||
`r` — stealth protects against observers, never the counterparty — and **the relayer sees the
|
||||
destination**, so unlinkability holds against chain analysis, not against SimpleX.
|
||||
|
||||
### Rules this imposes
|
||||
|
||||
**Never auto-claim, never auto-display.** An unaccepted name is not yours: it must not appear in the
|
||||
names list or attach to a profile. Declining does nothing on-chain and so leaves no trace.
|
||||
|
||||
**Incognito profiles carry no meta-address.** An incognito profile that inherited the user's
|
||||
meta-address would hand the contact a direct correlator back to the main identity, defeating the
|
||||
feature. Incognito profiles get no account index and no meta-address field, and cannot receive
|
||||
names.
|
||||
|
||||
**ERC-6538 is not used.** The on-chain meta-address registry buys nothing here — the SimpleX profile
|
||||
already distributes the meta-address to exactly the people who can send you anything — while making
|
||||
a permanent public identity binding out of something that has no reason to be public.
|
||||
|
||||
---
|
||||
|
||||
## 4. Context
|
||||
|
||||
SimpleX names ship today in v7.0 (App Store, 2026-08-02) as BETA. The app resolves
|
||||
`@name.testing` / `#name.testing` over SMP and claims a name on a profile, but registration happens
|
||||
outside the app: install MetaMask, fund it with ETH, visit `testing-names.simplex.chat`, complete a
|
||||
commit/reveal. `docs/guide/register-simplex-name.md` is five steps and the first is "Create a wallet".
|
||||
|
||||
The target is: press a button, type a name, pay with the store sheet, done. No wallet, no ETH, no
|
||||
browser — with one exception, an exportable recovery key.
|
||||
|
||||
Three constraints shape everything. **Ownership stays non-custodial**: the name is owned by a key
|
||||
only the user controls, and no later change is possible without their signature. **SimpleX does not
|
||||
route user funds**: it sells a service and never takes store money in order to send value onward on
|
||||
a user's behalf — a standing project constraint, not a legal position (§14.4). **Unlinkability is a
|
||||
requirement, not a nicety**, for the reason in §3.
|
||||
|
||||
Verified against mainnet during planning: `SimplexController.register()` takes `owner` as a struct
|
||||
field and `makeCommitment` hashes the whole struct rather than `msg.sender`, so a registrar can
|
||||
register on a user's behalf with no user signature; gas is negligible (0.172 gwei base fee, ETH
|
||||
$1,840 — a registration with records is about $0.15); and Apple explicitly permits using in-app
|
||||
purchase to sell NFT minting and transferring services.
|
||||
|
||||
What does not work against the deployed contracts is everything after registration: no permit, no
|
||||
ERC-2771, no EIP-712, no ERC-4337. Closing that gap is the technical core.
|
||||
|
||||
---
|
||||
|
||||
## 5. Executive summary
|
||||
|
||||
Build against **`.simplex`**, not yet deployed and therefore still designable. Leave `.testing` on
|
||||
the dApp flow.
|
||||
|
||||
**Registration is funded by on-chain credits, not payment.** The treasury cold wallet grants a
|
||||
registrar hot wallet a credit balance; a credited registrar registers with no value attached. The
|
||||
fee would have gone to the controller and been withdrawn straight back to the same treasury, so it
|
||||
is removed rather than performed. The public payable path stays open.
|
||||
|
||||
**Every post-registration action is a one-shot EIP-712 intent** signed by the user's key and relayed
|
||||
by SimpleX, which pays only gas — `transferWithSig` on the registrar, `setTextWithSig` on a new
|
||||
signature-capable resolver. No standing delegation, no smart account. Relayed record edits are
|
||||
metered by on-chain per-name credits.
|
||||
|
||||
**The wallet is stealth-first and chain-generic.** One seed, one account per chat profile, a
|
||||
`(spend, view)` pair per chain. Names you register yourself land on your main account; names sent to
|
||||
you land on one-time addresses only you can find, and are never claimed without your consent.
|
||||
|
||||
**A name is the only identity that survives a dead device.** The recovery phrase brings back the
|
||||
names but not the profiles or the queues, so the owner re-points the name at a fresh SimpleX address
|
||||
and everyone who knows it finds them again. Nothing else in the app recovers a relationship after
|
||||
total device loss, and since most users have no database backup at all (C8), this is the majority
|
||||
path rather than a corner case.
|
||||
|
||||
**Payment is a one-time purchase for 1–10 years**, not a subscription. The store product matrix is
|
||||
out of scope.
|
||||
|
||||
Deploy `.simplex` with `Root` locked, then burn the controller admin key while keeping a permanent
|
||||
beneficiary role and a timelocked registrar owner.
|
||||
|
||||
---
|
||||
|
||||
## 6. Decisions taken
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| TLD | `.simplex` only. |
|
||||
| Registration funding | On-chain credits from the treasury cold wallet. No fee transfer for sponsored registrations. **One credit = one operation**, whatever term is bought. |
|
||||
| Ownership | Plain EOA per chat profile, from a generic profile seed. |
|
||||
| Sponsorship | One-shot EIP-712 intents; SimpleX pays gas only. |
|
||||
| Record edits | On-chain per-name credits, 10 × years, consumed only by relayed edits. Granted by **adding**, never setting. |
|
||||
| Accepting a gift | A paid on-chain write: a **10-credit** top-up, record rewrite, signed claim. Declining is free and silent. |
|
||||
| Renewals | Extension purchase, 1–10 years. No subscriptions. |
|
||||
| **Transfer destination** | **ERC-5564 stealth addresses. Meta-address distributed in the SimpleX profile; never on-chain, never ERC-6538.** |
|
||||
| **Received names** | **Land on one-time addresses. Never auto-claimed, never auto-displayed.** |
|
||||
| **Discovery** | **A chat message from the sender. On-chain announcement is the recovery path only — no background scan, viewing key never leaves the device.** |
|
||||
| **Wallet shape** | **`(chain, account) → (spend, view, discovered one-time keys)`. ETH now; BTC and XMR fit the same shape later.** |
|
||||
| End state | `Root` locked; controller upgrade-frozen and burned; beneficiary permanent; **`BaseRegistrar`'s owner kept as a timelocked multisig, not burned**. |
|
||||
| Payments | Store IAP first, one-time products; Stripe stage 2. Matrix out of scope. |
|
||||
| Transport | Ordinary SimpleX connection to a names service. simplexmq protocol unchanged. |
|
||||
| Crypto | Vendored `libsecp256k1`; BIP-39/32, keccak, EIP-712 in Haskell. **Done.** |
|
||||
| Key backup | Optional, persistent reminder until acknowledged. |
|
||||
| Naming | *seed* / *account* / *wallet*. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Architecture
|
||||
|
||||
### 7.1 Trust and funding
|
||||
|
||||
The treasury cold wallet grants **registration credits** to registrar hot wallets. A registrar
|
||||
spends a credit to register or renew; no ETH price moves. Gas is paid by the registrar from its own
|
||||
balance — an infrastructure cost paid to validators, not a payment made for a user.
|
||||
|
||||
Nothing of value moves on a user's behalf; a compromised registrar can burn credits registering junk
|
||||
but cannot drain funds or touch existing names; the cold wallet can zero its credits in one
|
||||
transaction.
|
||||
|
||||
After lockdown SimpleX can refuse to relay, refuse to renew, and see which name a paying user
|
||||
registers. It cannot transfer, re-point or seize a name.
|
||||
|
||||
### 7.2 Purchase flow
|
||||
|
||||
```
|
||||
app names service Ethereum
|
||||
|-- quote(name) --------------->|
|
||||
|-- IAP purchase, 1-10 years -------------------------------------> App Store / Play
|
||||
|-- register(name, ownerAddr, years, receipt) -->|
|
||||
| |-- validate receipt
|
||||
| |-- commit(...) wait >= 60s
|
||||
| |-- registerWithCredit(...) <- no value attached
|
||||
| | consumes 1 registrar credit
|
||||
| | grants 10 x years edit credits to the node
|
||||
|<-- registered(txHash, expires) -----|
|
||||
|-- RSLV name --------------------------------> SMP names role
|
||||
|<-- NameRecord{owner == my address} (independent confirmation)
|
||||
```
|
||||
|
||||
Seed creation is lazy but happens **before** the request: `makeCommitment` binds `owner`, so the
|
||||
address must exist by the time the service commits.
|
||||
|
||||
### 7.3 Signed intents and edit credits
|
||||
|
||||
```solidity
|
||||
struct TransferName { address from; address to; uint256 tokenId; uint256 nonce; uint256 deadline; }
|
||||
struct SetText { bytes32 node; string key; string value; uint256 nonce; uint256 deadline; }
|
||||
struct SetBatch { bytes32 node; bytes32 dataHash; uint256 nonce; uint256 deadline; }
|
||||
```
|
||||
|
||||
`SetBatch` must reuse `multicallWithNodeCheck` semantics — every entry's first 32 bytes must equal
|
||||
`node` — or a signed batch could write records on nodes the signer does not own.
|
||||
|
||||
Edit credits meter the sponsored path only. A direct `setText` from the owner paying their own gas
|
||||
is never metered: the credit bounds SimpleX's gas exposure, not what an owner may do with their own
|
||||
name. Credits live on-chain against the node, so they recover with it.
|
||||
|
||||
### 7.4 Stealth transfer — how Bob sends Alice a name with no handshake
|
||||
|
||||
Alice's app derives a spending key `p` and a viewing key `v` from her profile account and publishes
|
||||
the **meta-address** `(P_s, P_v)` in her SimpleX profile. It is not an address, never appears
|
||||
on-chain, and costs no gas.
|
||||
|
||||
```
|
||||
Bob: r random, R = r·G, s = H(r·P_v)
|
||||
one-time address = addr(P_s + s·G)
|
||||
transferWithSig(..., to = one-time address, ephemeralPubKey = R, viewTag = s[0])
|
||||
|
||||
Alice: for each announcement: if s'[0] != viewTag -> discard (kills ~255/256 cheaply)
|
||||
s' = H(v·R), check addr(P_s + s'·G)
|
||||
her key for it: p + s' mod n
|
||||
```
|
||||
|
||||
Non-interactive and unlinkable to third parties. `R` is public, so Bob simply sends it in an
|
||||
ordinary chat message and Alice derives the key immediately — **no scanning in normal operation**.
|
||||
|
||||
### 7.5 Why there is no background scan
|
||||
|
||||
Bob can only derive the one-time address if he holds Alice's meta-address, and that reaches him
|
||||
through her profile over an established connection. **The sender therefore always has a channel**,
|
||||
by construction. Discovery is a message, not a search.
|
||||
|
||||
The announcement exists for one purpose — **recovery from the phrase alone**. Restore on a new
|
||||
device with no chat database and there is no message to read, so `R` must also live on-chain. A scan
|
||||
runs on seed import, on explicit user request, and never otherwise.
|
||||
|
||||
Two gaps this leaves, both benign. The transfer and the message are not atomic, so Bob could submit
|
||||
and crash before sending; and a contact can forward Alice's meta-address to someone she has no
|
||||
channel with, who can then send without being able to tell her. In both cases nothing is lost — the
|
||||
name sits at an address Alice's seed already controls and a rescan finds it — and nothing is
|
||||
attached to her without consent, because acceptance is a deliberate, paid action (§7.6). An
|
||||
unannounced incoming name is inert.
|
||||
|
||||
Two deliberate choices remain. The announcement is emitted by **our registrar**, not the shared
|
||||
mainnet ERC-5564 announcer, so a recovery scan covers SimpleX name transfers only rather than all
|
||||
stealth activity on Ethereum; with view tags discarding ~255/256 by one hash, a full-history scan is
|
||||
milliseconds. And discovery reuses machinery already needed: scan → candidate addresses →
|
||||
`balanceOf` + `tokenOfOwnerByIndex` + `labelOf` on the ERC721Enumerable registrar gives the names
|
||||
with no indexer, which is exactly what recovery-key import needs anyway.
|
||||
|
||||
The names service serves announcement ranges; the client does the ECDH. **The viewing key never
|
||||
leaves the device**, and no delegated-scanning privacy trade arises.
|
||||
|
||||
Unlinkability here is against chain observers. The relayer submits the transfer and later relays
|
||||
Alice's intents from that address, so it can correlate if she uses the same service connection. This
|
||||
defeats public chain analysis, not the service.
|
||||
|
||||
### 7.6 Accepting a received name costs an edit-credit purchase
|
||||
|
||||
Holding the token is not enough to use a name. The app renders a name against a profile only with a
|
||||
`SimplexDomainProof` signed by that profile's identity key (§3), and resolution needs the node's
|
||||
records pointing at the recipient's address. So **acceptance is an on-chain write** — a relayed
|
||||
`setTextWithSig` signed by the one-time key — not a local UI action.
|
||||
|
||||
Edit credits are granted per node at registration, to whoever registered it. The sender may have
|
||||
spent them, deliberately or not, so the recipient cannot rely on inheriting any. **Acceptance
|
||||
therefore buys a small edit-credit top-up through IAP**, priced as the cheapest product in the
|
||||
matrix. This is the right shape anyway: it prices the gas SimpleX relays on the recipient's behalf,
|
||||
and it removes any dependence on the sender's leftovers.
|
||||
|
||||
Declining costs nothing and writes nothing.
|
||||
|
||||
```
|
||||
Alice: [decline] -> local row deleted, no chain state touched
|
||||
[accept] -> IAP top-up -> grantEditCredits(node) -> setTextWithSig(node, records)
|
||||
-> sign SimplexDomainProof -> name renders on her profile
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Workstream A — contracts
|
||||
|
||||
**A1. `BaseRegistrarImplementation` — `transferWithSig` plus the announcement.**
|
||||
|
||||
```solidity
|
||||
function transferWithSig(address from, address to, uint256 tokenId,
|
||||
uint256 nonce, uint256 deadline, bytes calldata sig,
|
||||
bytes calldata ephemeralPubKey, bytes1 viewTag) external;
|
||||
event StealthNameTransfer(address indexed to, bytes ephemeralPubKey, bytes1 viewTag, uint256 tokenId);
|
||||
```
|
||||
|
||||
Verify against the grace-aware `ownerOf` (which reverts for an expired name, correctly forbidding
|
||||
transfer of a lapsed one), consume the nonce, call `_transfer` so the existing auto-reclaim fires and
|
||||
the registry node and subnames follow the token. Emit the announcement only when
|
||||
`ephemeralPubKey` is non-empty, so a plain transfer costs nothing extra. Expose `DOMAIN_SEPARATOR()`
|
||||
and `nonces(address)`. Do **not** implement ERC-4494 permit.
|
||||
|
||||
**`require(to != from)`.** Without it, an owner can self-transfer in a loop with a fresh nonce and a
|
||||
fresh ephemeral key — roughly 50k gas, effectively free at current base fees — and inflate the
|
||||
recovery-scan set without bound. This one line is what makes the "a full-history scan is
|
||||
milliseconds" claim in §7.5 hold, because it means every announcement costs a real transfer of a
|
||||
real name to a different party.
|
||||
|
||||
**A2. New `SimplexResolver.sol`** — `PublicResolver` plus `setTextWithSig`, `multicallWithSig`,
|
||||
`grantEditCredits` (controller only, **adds** to the node's balance) and `editCredits(node)`.
|
||||
|
||||
Adding rather than setting is a security requirement, not a generosity choice. `renew()` is
|
||||
`external payable` with no access control (`ETHRegistrarController.sol:352`) — **anyone can renew
|
||||
anyone's name** — so with set semantics a stranger renews your ten-year name for the minimum term
|
||||
and collapses your remaining credits to 10. Adding makes the hostile renewal a gift, which is the
|
||||
correct outcome for an unauthenticated call. Signatures verify against `ens.owner(node)`. Reuse the in-tree
|
||||
idiom: `reverseRegistrar/SignatureUtils.sol` already provides `validateSignatureWithExpiry` over OZ
|
||||
`SignatureChecker`. Keep the standard profiles untouched so `snrc-resolve.py` and the dApp read it
|
||||
with no changes. Pass `trustedETHController` as ENS does, but **`address(0)` for
|
||||
`trustedReverseRegistrar`** — that slot is a second permanently-trusted address with authority over
|
||||
every node, and a probe confirms it is exercisable on the live `.testing` resolver.
|
||||
|
||||
**A3. `SimplexController` — beneficiary role, credits, freeze.**
|
||||
|
||||
```solidity
|
||||
address public beneficiary; // permanent, multisig
|
||||
function setBeneficiary(address) external onlyBeneficiary;
|
||||
function setRegistrarCredits(address, uint256) external onlyBeneficiary;
|
||||
function withdraw() public; // pays beneficiary, not owner()
|
||||
function registerWithCredit(Registration calldata) external;
|
||||
function renewWithCredit(string calldata, uint256, bytes32) external;
|
||||
function freezeUpgrades() external onlyOwner; // one-way
|
||||
```
|
||||
|
||||
The credited paths are the existing `register` (`:309`) and `renew` (`:411`) with the `msg.value`
|
||||
checks at `:321`/`:419` replaced by a credit decrement and the refund branches at `:405`/`:425`
|
||||
removed. One credit = one register or renew call. Keep the payable paths.
|
||||
|
||||
**A4. Deploy `Root.sol` and `lock("simplex")`.** `.testing` skipped this.
|
||||
|
||||
**A5. Deployment script** — price curve `[0, 0, 4056075240196, 1014018810049, 31688087814]`,
|
||||
`nftGateEnabled = false`, `minCharLength = 6`, both reverse registrars `address(0)`, resolver =
|
||||
`SimplexResolver`, reserved names finalised before lockdown, initial credits granted.
|
||||
|
||||
**A6. Staged lockdown.** `_register` opens with `require(available(id))`, so **no owner power on
|
||||
either contract can seize, transfer or re-point a live name** — that is what makes burning safe.
|
||||
Burn the controller owner: what remains after freeze is nuisance-grade (arbitrary pricing until
|
||||
`freezePriceOracle()`, reserve-then-mint reaching only unregistered names, monotonic switches).
|
||||
**`BaseRegistrar`'s owner is kept as a timelocked multisig and is not burned** (§14.2) —
|
||||
`addController` is the only recovery path from a buggy frozen controller and cannot touch live names,
|
||||
while `removeController` and `setSubnameHook` are DoS levers the timelock exists to expose. Order:
|
||||
`freezePriceOracle()` → finalise reserved names → `Root.lock` → `freezeUpgrades()` → burn the
|
||||
controller owner.
|
||||
|
||||
**A7. Docs.** Update `docs/architecture.md`, `docs/security.md`, `docs/sequence-happy-flow.md`;
|
||||
correct `verification.mainnet.testing.json`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Workstream B — client crypto (`simplexmq`) — **done**
|
||||
|
||||
Merged on `ab/eth-crypto`; 98 tests against published vectors. `Crypto/Secp256k1.hs` (vendored
|
||||
libsecp256k1 v0.8.0, recoverable ECDSA), `Crypto/BIP39.hs`, `Crypto/BIP32.hs`, `Eth/Keccak.hs`,
|
||||
`Eth/Address.hs`, `Eth/EIP712.hs`. No RLP — the client signs typed data and never builds a
|
||||
transaction. See `plans/2026-08-05-eth-crypto-bindings.md`.
|
||||
|
||||
**This design adds one primitive:** ECDH on secp256k1 for stealth derivation. libsecp256k1 already provides
|
||||
`secp256k1_ec_pubkey_tweak_add` and the ecdh module; enabling `ENABLE_MODULE_ECDH` alongside
|
||||
`ENABLE_MODULE_RECOVERY` is a one-word cabal change. `Eth/Stealth.hs` then holds meta-address
|
||||
encoding, sender derivation, and the scan-with-view-tag loop.
|
||||
|
||||
Cross-compilation: `aarch64-android` compiles and links; `armv7a-android` compiles; Windows is
|
||||
blocked by a duplicate patch in the pinned haskell.nix branch, unrelated to this work.
|
||||
|
||||
---
|
||||
|
||||
## 10. Workstream C — the wallet
|
||||
|
||||
### C1. Vocabulary
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| **seed** | BIP-39 entropy. Generic, profile-scoped, chain-agnostic. |
|
||||
| **account** | A profile's slot in the seed, index `i`. Holds a key *per chain*, not one key. |
|
||||
| **main address** | The account's ordinary address on a chain. Names you register yourself. |
|
||||
| **meta-address** | The published `(spend, view)` public pair. Not an address; never on-chain. |
|
||||
| **one-time address** | A destination derived by a sender. Where received names and, later, received funds land. |
|
||||
| **wallet** | `Simplex.Chat.Wallet` — creation, derivation, storage, scanning, signing. |
|
||||
|
||||
### C2. Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE wallet_seeds (
|
||||
wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
seed BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
backed_up INTEGER NOT NULL DEFAULT 0,
|
||||
-- High-water mark for account allocation. Cannot be derived from users.wallet_account_index:
|
||||
-- after a phrase-only restore that table is empty while accounts 0..N already hold names,
|
||||
-- so a new profile would collide with a recovered account (C6). The recovery probe sets this.
|
||||
next_account_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
ALTER TABLE users ADD COLUMN wallet_seed_id INTEGER REFERENCES wallet_seeds ON DELETE RESTRICT;
|
||||
ALTER TABLE users ADD COLUMN wallet_account_index INTEGER;
|
||||
|
||||
-- Destinations learned from a sender's message, or rediscovered by a recovery scan.
|
||||
-- Chain is carried from day one so BTC and XMR
|
||||
-- need no migration: only new rows with a different chain value.
|
||||
CREATE TABLE wallet_one_time_addresses (
|
||||
wallet_one_time_address_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
chain TEXT NOT NULL, -- 'eth' now; 'btc', 'xmr' later
|
||||
address BLOB NOT NULL,
|
||||
ephemeral_pub_key BLOB NOT NULL, -- enough to re-derive the key from the seed
|
||||
discovered_at TEXT NOT NULL,
|
||||
accepted_at TEXT, -- NULL = not accepted; see §3
|
||||
UNIQUE (user_id, chain, address)
|
||||
);
|
||||
CREATE INDEX idx_wallet_one_time_addresses_user ON wallet_one_time_addresses(user_id, chain);
|
||||
|
||||
-- Position of the last recovery scan, so a repeat scan is resumable. Not a live watermark:
|
||||
-- normal discovery is a chat message, not a scan (§7.5).
|
||||
ALTER TABLE users ADD COLUMN wallet_scanned_to TEXT;
|
||||
```
|
||||
|
||||
Storing `ephemeral_pub_key` means a private key is never stored — it is re-derived from the seed on
|
||||
demand, so the table is a cache and the phrase remains sufficient. Losing the table costs a rescan,
|
||||
not an asset.
|
||||
|
||||
The schema allows several seeds; the UI exposes one. Storing secrets in the chat DB follows the
|
||||
existing pattern (`badge_master_key`, `root_priv_key`, `link_priv_sig_key`), so it is SQLCipher
|
||||
encrypted and rides `exportArchive` and Migrate-to-another-device with **no new backup code** — with
|
||||
the large caveat in C8: for a default-configured user those paths are closed, and the phrase is the
|
||||
only recovery that exists.
|
||||
|
||||
### C3. Modules
|
||||
|
||||
- **`Simplex.Chat.Wallet`** — seeds, accounts, derivation, signing. Chain-parameterised:
|
||||
`data Chain = ChainEth | ChainBtc | ChainXmr` exists from day one even though only `ChainEth` is
|
||||
implemented, so the type signatures do not change when the others arrive.
|
||||
|
||||
**The module must not export a digest-signing function.** `signDigest` becomes internal and the
|
||||
only public entry point is `signIntent :: WalletAccount -> Intent -> Either String EthSignature`,
|
||||
taking the structured intent and hashing it locally. This makes "the app never signs an opaque
|
||||
payload" a property of the type signature rather than a rule someone has to remember — it is
|
||||
otherwise exactly the mistake a `SetBatch { dataHash }` invites, since a service-supplied hash
|
||||
would let a compromised service write arbitrary records on a node the user owns.
|
||||
|
||||
It also exports **`exportOneTimeKey`**: the private key `p + s mod n` for a single received name.
|
||||
That is an ordinary secp256k1 key, importable into MetaMask, and it discloses that one address and
|
||||
nothing else — not the seed, not the other names. It is what keeps the non-custodial guarantee
|
||||
true for received names (§17) and it is the manual escape hatch if the relayer stops serving.
|
||||
- **`Simplex.Chat.Wallet.Stealth`** — meta-address encode/decode, sender derivation, scan loop.
|
||||
- **`Simplex.Chat.Store.Wallets`** — persistence; `getOrCreateAccountRef` reuses the first seed and
|
||||
allocates the next account index.
|
||||
- **`Simplex.Chat.Names.Snrc`** — namehash, token id, EIP-712 intents and canonical type strings.
|
||||
- **`Simplex.Chat.Names.Service`** / `.Mock` / `.Default` — the client interface, an in-memory mock
|
||||
that verifies real signatures and enforces both credit types, and the single binding to swap for
|
||||
the SMP-backed client.
|
||||
|
||||
### C4. Meta-address distribution
|
||||
|
||||
Add an optional field to `Profile` carrying the meta-address, following the mechanism already used
|
||||
twice — `badge` in 6.5, `contactDomain` in 7.0. Field on `Profile` (`Types.hs:693`) and
|
||||
`LocalProfile` (`:777`), a migration, row plumbing through `Store/Shared.hs` and the three
|
||||
`updateContactProfile_'` variants in `Store/Direct.hs`, and a redaction decision in
|
||||
`redactedMemberProfile` (`Internal.hs:1263`).
|
||||
|
||||
A profile field might seem to broadcast a persistent on-chain identifier. **A
|
||||
meta-address is not one** — it never appears on-chain, and holding it does not let anyone find or
|
||||
link the addresses derived from it (§3).
|
||||
|
||||
Two rules for the plumbing. `redactedMemberProfile` (`Internal.hs:1260`) passes `badge` and
|
||||
`contactDomain` through to group members by default, so the meta-address will reach every member of
|
||||
every group unless a decision is made. **Pass it through** — the exposure is a spam surface, not a
|
||||
privacy leak (§3), and redacting it would break gifting for anyone you know only through a group.
|
||||
And **incognito profiles carry no meta-address and no account index** (§3), which is a positive
|
||||
check in `fromLocalProfile`, not an omission to be relied on.
|
||||
|
||||
### C5. Receiving
|
||||
|
||||
Normally a chat message from the sender carries `R`; the app derives the address, confirms the name
|
||||
is held there, and creates a `wallet_one_time_addresses` row with `accepted_at = NULL`. **Nothing is
|
||||
claimed, displayed as yours, or attached to a profile until the user accepts** (§3). Declining
|
||||
deletes the row and touches no chain state.
|
||||
|
||||
Accepting runs the sequence in §7.6: an edit-credit top-up purchase, `grantEditCredits`, a relayed
|
||||
`setTextWithSig` signed by the one-time key, then a `SimplexDomainProof` signed by the profile's
|
||||
identity key. Only then does the name render.
|
||||
|
||||
Scanning is the recovery path only (§7.5): on seed import, and behind an explicit "check for
|
||||
received names" action. **No background scan, no scheduler.**
|
||||
|
||||
### C6. Recovery from the phrase alone
|
||||
|
||||
Restoring from the phrase gives no database, so the app does not know how many profiles existed or
|
||||
which account indices they held. Each index has its own viewing key, and scanning is per-key, so
|
||||
guessing wide is not free.
|
||||
|
||||
**Indices are allocated densely and never reused.** An index is taken when a profile first needs one
|
||||
— which is when its meta-address is first published, not at first purchase — so the live set is
|
||||
`0..N` with holes only where profiles were deleted.
|
||||
|
||||
**Discovery is a cheap probe before an expensive scan.** Main addresses (`m/44'/60'/i'/0/0`) are
|
||||
ordinary addresses, so `balanceOf` on the registrar answers "was this index ever used" in one batched
|
||||
call per index, with no scanning at all. Walk `i = 0, 1, 2, …` until **10 consecutive empty
|
||||
indices** — the BIP-44 gap-limit idiom — to fix `N`. Only then scan announcements, and only with
|
||||
those `N` viewing keys.
|
||||
|
||||
The one case this misses is a profile that received names but never bought one, whose main address
|
||||
is empty and reads as a gap. A **"scan further accounts"** action extends the probe by another
|
||||
10 indices for anyone in that position, which keeps the default cheap without making the edge case
|
||||
unrecoverable.
|
||||
|
||||
Sharing one viewing key across all accounts would collapse the scan to a single pass, and is
|
||||
**rejected**: the viewing key is published inside the meta-address, so a shared one would let any
|
||||
contact holding two of your profiles' meta-addresses link them.
|
||||
|
||||
**What comes back, and what does not.** The phrase recovers *assets, not identity*. Back: every
|
||||
account's keys and addresses, which indices were used, the names they hold, each name's remaining
|
||||
edit credits (on-chain, per node), and any name someone sent that was never seen, since the scan does
|
||||
not depend on the message having arrived. Gone: everything that made an account a profile — display
|
||||
name, image, contacts, groups, history, all chat DB — and the SimpleX addresses themselves, which are
|
||||
queues in the agent DB, so every contact must reconnect. The index-to-profile mapping is gone too;
|
||||
in practice the names recovered against each index are what identify it.
|
||||
|
||||
**Recovered indices must be reserved before new profiles allocate.** `nextAccountIndex`
|
||||
(`Store/Wallets.hs`) takes `MAX(wallet_account_index) + 1` over `users`, which after a phrase-only
|
||||
restore is empty — so the first profile created would take index 0 and silently derive the same keys
|
||||
and the same meta-address as recovered account 0. The high-water mark therefore cannot be inferred
|
||||
from `users`; it lives on `wallet_seeds` (C2) and the probe sets it. New profiles then allocate above
|
||||
the recovered range, and **re-adopting a recovered account for a profile is an explicit offered
|
||||
action**, which is what attaches the names to a profile again.
|
||||
|
||||
### C7. Commands
|
||||
|
||||
Working in the terminal UI: `/names key`, `key import`, `key saved`, `address`, `quote`, `buy`,
|
||||
`list`, `info`, `link`, `gift`, each with an `/_name …` API form. The set also includes `/names incoming`,
|
||||
`/names accept <name>`, `/names decline <name>`, `/names export <name>` (the one-time key, C3),
|
||||
`/names rescan [more]` (C6), and a meta-address in `/names address`.
|
||||
|
||||
### C8. How this fits SimpleX backup and recovery today
|
||||
|
||||
The claim that the seed "rides existing backup flows" is true but load-bearing enough to write down
|
||||
what those flows actually are, because one of them is closed by default.
|
||||
|
||||
**There are two, and they are the same mechanism.** `exportArchive` (`Archive.hs:53`) zips the chat
|
||||
DB, the agent DB, the files folder and wallpaper assets; import replaces both databases wholesale.
|
||||
Migrate-to-another-device (`MigrateFromDevice.kt:87`) is that same archive stopped, uploaded to XFTP
|
||||
as a standalone file (`:600`), handed over as a one-time link, and deleted at the source. So nothing
|
||||
is transfer-only: migration is archive export plus a transport and a cleanup.
|
||||
|
||||
**Both are blocked on the default passphrase.** Onboarding leaves users on a random passphrase in
|
||||
the keychain (`initialRandomDBPassphrase`). Export shows `exportProhibitedAlert()` and redirects to
|
||||
the encryption screen (`DatabaseView.kt:251`); migration lands in `PassphraseNotSet`
|
||||
(`MigrateFromDevice.kt:453`). Such a user does not have a stale backup — they have **none**.
|
||||
Migration still works for them only because it offers to set the passphrase inline, on a device that
|
||||
still runs. **If the device dies first, everything is gone.**
|
||||
|
||||
**An archive also degrades, in a way the wallet does not.** A restored archive carries stale
|
||||
double-ratchet state, so messaging surfaces decryption errors — `RSAllowed` / `RSRequired`,
|
||||
"decryption error (connection may be out of sync)" (`CIContent.hs:415-416`, handled at
|
||||
`Subscriber.hs:669`) — and each contact and group member has to be repaired with `/sync`. Messages
|
||||
that arrived in the interval are gone; there is no server-side retention to replay.
|
||||
|
||||
**The wallet is the resilient part**, which is unusual enough to state. Keys derive from a seed and
|
||||
hold no live state, so a stale archive yields fully working names and addresses while the chat side
|
||||
is still limping through resync. The only stale artefact is the `wallet_one_time_addresses` cache,
|
||||
and C6's rescan rebuilds it from the seed.
|
||||
|
||||
### A name is the only identity that survives a dead device
|
||||
|
||||
Follow the worst case through. The phone is gone, there was no passphrase and so no archive, and all
|
||||
that is left is the recovery phrase. C6 brings back the accounts and the names, but not the profiles
|
||||
and not the queues, so every contact is unreachable and none of them can tell that the person
|
||||
reappearing is the same one.
|
||||
|
||||
**Except that the name still resolves.** The token is still owned by a key derived from the phrase,
|
||||
so the owner re-points the name at a brand-new SimpleX address and signs a fresh `SimplexDomainProof`
|
||||
with the new profile's identity key. Everyone who knows the name finds them again.
|
||||
|
||||
Nothing else in SimpleX does this. Today a dead device with no backup is a permanent break in every
|
||||
relationship the user had, and given that most users have no backup at all, this is the majority
|
||||
path rather than a corner case. **Names are therefore an identity-recovery mechanism, not only an
|
||||
identifier**, and that is the strongest argument for the feature — stronger than convenience or
|
||||
vanity.
|
||||
|
||||
Two consequences follow, and they pull in opposite directions. Re-pointing costs an edit credit, so
|
||||
a user whose credits are exhausted must buy a top-up (§7.6) before doing the first thing recovery
|
||||
requires, and until then the name resolves to a dead queue. And the name key is thereby an
|
||||
**identity-redirect key**, not merely an asset key: whoever holds it can point everyone who knows
|
||||
that name at themselves. That is an argument for wording the backup screen around impersonation
|
||||
rather than loss (§13).
|
||||
|
||||
Two conclusions the rest of the plan depends on. **The recovery phrase is not redundant with the
|
||||
archive**: it is the only recovery path for every user who never set a passphrase, and the only one
|
||||
that survives a dead device for anyone. And **the backup reminder is not a wallet nicety** — for
|
||||
default-configured users it is the first backup prompt the app has ever given them, which raises
|
||||
the bar for how it is worded (§13).
|
||||
|
||||
---
|
||||
|
||||
## 11. Workstream D — names service
|
||||
|
||||
A SimpleX bot plus a registrar/relayer.
|
||||
|
||||
- **Bot** — quote, purchase, record-update, transfer, enumeration, credit balance, and
|
||||
**announcement ranges for recovery scans**. No HTTP endpoint exposed to clients. Enumeration is advisory:
|
||||
the app confirms each name by resolving it, so a lying service can withhold but not invent.
|
||||
- **Receipt validation** — App Store Server API and Google Play Developer API, with a consumed-receipt
|
||||
ledger against replay. Model on `Badges.hs:236`'s `BadgePurchase` sketch, whose `verifyPayment` is
|
||||
a stub.
|
||||
- **Registrar** — hot wallet holding **credits, not spendable value**. Serialised nonces, gas bumping,
|
||||
idempotency keys, pre-flight simulation, KMS/HSM custody. Monitor the credit balance and alert with
|
||||
generous headroom: exhaustion is a hard service stop only the beneficiary multisig can clear.
|
||||
- **Scheduler** — `commit`, wait ≥ 60 s, `registerWithCredit` within 24 h. Completes even if the app
|
||||
closes.
|
||||
- **Extension** — `renewWithCredit` on an extension purchase. **No subscription cron.**
|
||||
|
||||
There is no scanning service. The bot serves raw announcement ranges and the client does the ECDH,
|
||||
so a viewing key never leaves the device (§7.5).
|
||||
|
||||
**Race handling.** Commitments do not reserve a name. Model the purchase as an entitlement to one
|
||||
registration of the paid tier and duration, with the name choice reassignable, so losing a race means
|
||||
choosing again rather than losing money. **Refunds are out of scope** but will be needed.
|
||||
|
||||
---
|
||||
|
||||
## 12. Workstream E — payments
|
||||
|
||||
**Out of scope**, deliberately: this plan starts from "the user paid for a name of N letters for M
|
||||
years". The product matrix is configured in App Store Connect and Play Console.
|
||||
|
||||
In scope is the shape the design assumes: **one-time purchases, not subscriptions**; extension is
|
||||
another purchase; and a **10-credit edit top-up** as the cheapest product in the matrix, which is what
|
||||
a recipient buys to accept a name sent to them (§7.6), what a recovering user buys to re-point a name
|
||||
(C8), and what anyone buys after exhausting a name's credits. StoreKit 2 on iOS (`SettingsView.swift:11` already links the framework); Play
|
||||
Billing on Android needs a **product flavour split** in `apps/multiplatform/android/build.gradle.kts`
|
||||
because the library is proprietary and cannot ship in the F-Droid build. Stripe-backed redeem codes
|
||||
in stage 2, behind the same payment-proof interface.
|
||||
|
||||
Hard constraints: **a name must never gate an app feature** — badges are the entitlement mechanism
|
||||
(they already drive the XFTP size lift in `Badges.hs:184-199`), names are identity, and they must
|
||||
never overlap or Apple's "NFT ownership must not unlock features" clause bites. Remove or
|
||||
US-storefront-gate the `register_test_name` external link (`strings.xml:399`) once IAP ships. Sell
|
||||
the service; keep contract addresses, token ids and marketplace links out of the flow.
|
||||
|
||||
---
|
||||
|
||||
## 13. Workstream F — UI
|
||||
|
||||
Twelve screens are drawn and reviewed, built on the app's own tokens from `Color.kt` and the
|
||||
`SectionView` idioms `SetSimplexDomainView` already uses. Extend that editor rather than building new
|
||||
ones: Kotlin `views/usersettings/SetSimplexNameView.kt` with call sites `UserAddressView.kt:373` and
|
||||
`GroupChatInfoView.kt:181`; Swift `UserAddressView.swift:805-935` with call sites at `:196` and
|
||||
`GroupChatInfoView.swift:688`.
|
||||
|
||||
An **incoming name** screen with two outcomes — decline, which does nothing on-chain and
|
||||
is free, and accept, which is a purchase (§7.6) and creates the on-chain link, so the screen must say
|
||||
both things before the sheet opens rather than after. Also: expiry warnings, because
|
||||
nothing auto-renews now, and a remaining-changes count wherever a record can be edited.
|
||||
|
||||
Recovery key shown once after the first purchase with a persistent reminder until acknowledged
|
||||
(`wallet_seeds.backed_up`). Word it as a recovery key, never a seed phrase or wallet, and make clear
|
||||
it is independent of the database passphrase — for users on `initialRandomDBPassphrase` it is the
|
||||
only recovery path there is, in the strict sense established in C8: archive export and migration are
|
||||
both refused in that state, so this screen is the first and only backup the app has ever offered
|
||||
them. It should not read like a wallet formality, and it is worth pairing with a prompt to set a
|
||||
database passphrase, since that is what unlocks backup for everything else they own.
|
||||
|
||||
**Word the stakes as impersonation, not loss.** Whoever holds this key can re-point the name at their
|
||||
own address, so losing it is not "you lose a name" but "someone else can become findable under it"
|
||||
(C8). That is both the honest framing and the one users act on.
|
||||
|
||||
**A post-recovery flow is needed**, and it is the payoff screen for the whole feature: after
|
||||
importing a phrase on a fresh install, show the recovered names, let the user attach each to a
|
||||
profile — new or existing — and re-point it at that profile's address in one step, buying a
|
||||
credit top-up inline if the node has none left. Handle the case where the probe found nothing by
|
||||
offering "scan further accounts" (C6) rather than declaring the phrase wrong.
|
||||
|
||||
---
|
||||
|
||||
## 14. Decisions
|
||||
|
||||
These four are settled, recorded here with the reasoning that matters downstream:
|
||||
|
||||
1. **Credit unit: per operation.** One registration credit buys one `register` or `renew` call,
|
||||
regardless of the term bought. Simpler to reason about than per-name-year and adequate for what
|
||||
the credit is actually for, which is bounding a compromised registrar's blast radius.
|
||||
2. **`BaseRegistrar`'s owner is not burned.** It stays a timelocked multisig. `addController` is the
|
||||
only recovery path from a frozen, buggy controller, and it cannot touch a live name because
|
||||
`_register` requires `available(id)`. `freezeControllers()` and `freezeSubnameHook()` are
|
||||
therefore not needed and are dropped from A6.
|
||||
3. **Acceptance top-up is 10 edit credits**, the cheapest product in the matrix — one year's worth of
|
||||
edits, enough for a full record set with headroom (§7.6).
|
||||
4. **Legal questions are out of scope.** Selling names has been researched separately and is settled;
|
||||
this plan does not carry an opinion on it. Registration credits remain in the design on their
|
||||
engineering merits — no fee to move, a small blast radius, and a one-transaction kill switch.
|
||||
|
||||
---
|
||||
|
||||
## 15. Sequencing
|
||||
|
||||
1. **Contracts.** Credits, `transferWithSig` + announcement, `SimplexResolver` with edit credits,
|
||||
`freezeUpgrades` + beneficiary, `Root`, deployment script, tests. Independent; start now.
|
||||
2. **Client crypto.** Enable the ECDH module; write `Eth/Stealth.hs` with test vectors. Small.
|
||||
3. **Wallet.** Chain-parameterised types, the one-time-address table, message-driven receiving, and
|
||||
the recovery scan. **This is the
|
||||
piece that must be right first** — schema and derivation paths are what later work cannot change.
|
||||
4. **Names service.** Bot, receipt validation, registrar, scheduler, announcement ranges.
|
||||
5. **Payments and UI.** The Android flavour split can start early.
|
||||
6. **Sepolia end-to-end**, then `.simplex` mainnet, then staged lockdown.
|
||||
|
||||
---
|
||||
|
||||
## 16. Verification
|
||||
|
||||
- **Contracts.** A credited registrar registers with zero value and the credit decrements; an
|
||||
uncredited caller is rejected; the payable path still works; `transferWithSig` succeeds for the
|
||||
owner and fails for wrong signer, replayed nonce, expired deadline and expired name; the
|
||||
announcement is emitted only when an ephemeral key is supplied; **`to == from` reverts**;
|
||||
`setTextWithSig` fails at zero edit
|
||||
credits while a direct `setText` by the owner is not metered; **a renewal by an unrelated address
|
||||
adds to the node's edit credits and never reduces them**; auto-reclaim moves the registry node
|
||||
and subnames. Fork-test against mainnet state.
|
||||
- **Stealth.** Round-trip: derive as sender, recover as recipient from the message, sign from the
|
||||
recovered key. Check the view tag discards the expected fraction. Cross-check against the ScopeLift
|
||||
ERC-5564 SDK so we are interoperable rather than merely self-consistent. **The load-bearing test is
|
||||
the recovery path**: wipe the device, restore from the phrase alone, and confirm the probe-then-scan
|
||||
of C7 rediscovers every received name across several profiles with no chat history — including a
|
||||
profile that only ever received, which the gap limit alone would miss. Confirm `exportOneTimeKey`
|
||||
produces a key MetaMask accepts, and that it discloses only that address.
|
||||
- **Backup interaction** (C8). Export an archive, buy a name, restore the archive: the wallet must
|
||||
still derive the same addresses and the name must still be controllable, even while messaging is
|
||||
in ratchet resync. Separately, confirm the recovery-key screen appears for a user still on
|
||||
`initialRandomDBPassphrase` — the case with no database backup at all, and the one that most needs
|
||||
it.
|
||||
- **Dead-device recovery**, end to end and on a clean install with nothing but the phrase: recover the
|
||||
names, attach one to a newly created profile, re-point it, and confirm a third party resolving the
|
||||
name reaches the new address. Then create a further profile and confirm it allocates **above** the
|
||||
recovered high-water mark — the collision `nextAccountIndex` produces today (C6), which is silent
|
||||
and would publish one meta-address under two identities.
|
||||
- **Resolver compatibility.** Point `snrc-resolve.py` at the local deployment; a name registered
|
||||
against `SimplexResolver` must resolve identically with zero script changes.
|
||||
- **Chat integration.** Extend `tests/ChatTests/Names.hs` with the `tests/NameResolver.hs` Warp
|
||||
fixture. The mock already recovers the signer from every intent and enforces both credit types;
|
||||
`cabal run simplex-names-demo` walks the journey with no money and no chain, and the tmux TUI
|
||||
harness drives the same flow through the real terminal UI.
|
||||
- **End-to-end on Sepolia.** Buy from a clean install; confirm the owner matches the derived address;
|
||||
edit a record and watch the credit decrement; send a name to a second device with no handshake and
|
||||
confirm it arrives on one message; accept it; extend and confirm credits reset. Then
|
||||
export the recovery key, import it into MetaMask, and confirm the name is transferable — the test
|
||||
that proves the non-custodial claim.
|
||||
- **Stores.** StoreKit sandbox and Play internal testing across the duration matrix.
|
||||
|
||||
---
|
||||
|
||||
## 17. Risks and accepted limitations
|
||||
|
||||
- **Derivation paths and the account model are unchangeable after launch.** Everything else in this
|
||||
plan can be revised; these cannot, without moving live assets. That is why §2 fixes them before
|
||||
any of it ships.
|
||||
- **Squatting-as-doxxing is not preventable** and is explicitly not attempted (§3). The mitigation
|
||||
is that a squatted name has no in-app effect without the target's own signed claim.
|
||||
- **Stealth does not protect against the sender**, who can prove the derivation by revealing the
|
||||
ephemeral secret. It protects against everyone else, which is what the requirement asks for.
|
||||
- **Accepting a gifted name costs money** (§7.6). A free gift is not free to use, and that has to be
|
||||
said on the screen before the sheet opens.
|
||||
- **Received names need our software or a reimplementation.** They sit at addresses derived along an
|
||||
unregistered path against our own announcer, so no third-party wallet finds them. `exportOneTimeKey`
|
||||
keeps the non-custodial guarantee true per name, but not via the phrase alone in someone else's
|
||||
wallet.
|
||||
- **Anyone in a group with you can send you a name.** Spam surface, not a privacy leak (§3), and
|
||||
bounded by the sender paying to register.
|
||||
- **Unlinkability is against chain observers, not against SimpleX.** The relayer sees destinations
|
||||
and later relays intents from them.
|
||||
- **A crash between transfer and message loses the notification**, though not the name (§7.5). The
|
||||
recipient hears nothing until the sender resends or a recovery scan runs.
|
||||
- **Registrar credits are a service stop when exhausted** — only the beneficiary multisig can refill.
|
||||
- **Names lapse.** No subscriptions means an ignored expiry warning loses the name after grace.
|
||||
- **Relayer liveness is a single point of failure** for every write. The escape hatch is manual:
|
||||
export the key, fund the address, act directly.
|
||||
- **Nothing ships until `.simplex` is deployed.**
|
||||
- **The secondary market is outside IAP.** Names are plain ERC-721s; the defence is framing.
|
||||
- **Purchase and name are linkable to SimpleX.** Blind-signed vouchers would remove this and compose
|
||||
with the Community Credits work in `docs/rfcs/2025-12-10-vouchers-2.md`.
|
||||
- **Expired names keep resolving** until re-registered (`docs/security.md` L7); readers must gate on
|
||||
`nameExpires(id) > block.timestamp`.
|
||||
- **Subnames still need a standing `setApprovalForAll`**, which cannot be relayed — decide before
|
||||
lockdown, since `SubnameRegistrar` is immutable.
|
||||
- **The chat database now holds assets.** A copy plus its passphrase can sign transfers, and an old
|
||||
copy after migration keeps working. The threat model of an archive changes character: a leaked
|
||||
archive was message history, and is now spendable keys.
|
||||
- **Most users have no database backup at all** (C8), because export and migration are both refused
|
||||
on the default random passphrase. The recovery phrase is therefore the primary recovery mechanism
|
||||
for names, not a secondary one, and it carries that weight from the first purchase.
|
||||
- **The name key is an identity-redirect key.** Whoever holds it can re-point the name at their own
|
||||
address and become findable under it. This is the flip side of the recovery property in C8 and it
|
||||
makes the phrase materially more sensitive than "a name could be lost" suggests.
|
||||
- **Recovery restores names, not relationships.** Contacts still have to reconnect; what the name
|
||||
buys is that they can find the right person to reconnect *to*.
|
||||
- **Refunds are unbuilt.**
|
||||
|
||||
### Two live `.testing` issues, outside this plan
|
||||
|
||||
`.testing`'s three trust anchors are open — the registry root is a plain EOA (`0xDa064C…`,
|
||||
`eth_getCode` returns `0x`) with no `Root` and no lock, and a simulated `setSubnodeOwner` from it
|
||||
succeeds; the controller's UUPS authority sits on the hot deployer `0xd83bb610…`; and `setText` on an
|
||||
arbitrary node succeeds from both the controller proxy and the ReverseRegistrar `0x84c2a977…`.
|
||||
Separately, `deployments.mainnet.testing.json` gives ENSRegistry `0x58fc46…` while `snrc-resolve.py`
|
||||
defaults to `0x03f438…` — both live, so production may be resolving against the older deployment.
|
||||
Reference in New Issue
Block a user