mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 15:48:54 +00:00
names: derive one key per name at m/44'/60'/i'/0/k
This commit is contained in:
@@ -23,6 +23,75 @@ The client keeps no local names record.
|
||||
chain, payment, stealth/gifting, GUIs, min-commitment-age enforcement,
|
||||
tx-hash inclusion verification, anti-grief deposits.
|
||||
|
||||
## One key per name
|
||||
|
||||
A name is owned by a key derived at `m/44'/60'/i'/0/k` — the profile's BIP-44
|
||||
account `i`, and the name at address index `k`. `k` is taken when the name is
|
||||
registered, so a profile's second name lands on a different address.
|
||||
|
||||
```
|
||||
seed (BIP-39)
|
||||
└── profile account i
|
||||
└── m/44'/60'/i'/0/k one key per name; k = 0 is the profile's first
|
||||
```
|
||||
|
||||
Worked through, for a profile that buys two names and later imports a second
|
||||
seed it had used in a dapp:
|
||||
|
||||
```
|
||||
seed1 generated by the CLI
|
||||
└── profile Alice = account 0
|
||||
├── m/44'/60'/0'/0/0 0x69A6…2d32 owns alice.simplex
|
||||
└── m/44'/60'/0'/0/1 0x4C1f…9Ab7 owns lizzy.simplex
|
||||
|
||||
seed2 imported later
|
||||
└── m 0x1D07…4bE9 owns lucy.simplex (root, no derivation)
|
||||
```
|
||||
|
||||
Nothing here is a custom layout: `account` and `address_index` are what BIP-44
|
||||
has those levels for, and the addresses line up with wallets users already have.
|
||||
Pinned in the tests against the standard `abandon … about` mnemonic:
|
||||
|
||||
```
|
||||
m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 MetaMask account 1
|
||||
m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0 MetaMask account 2
|
||||
m/44'/60'/1'/0/0 0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265 Ledger Live account 2
|
||||
```
|
||||
|
||||
So **profile 0's names are exactly MetaMask's account list, in order**, and each
|
||||
profile's first name is the matching Ledger Live account. Moving a single name
|
||||
between this wallet and another one is therefore a derivation question already
|
||||
answered — but the commands to do it (recovery-phrase import, single-name key
|
||||
export) are follow-up work, not in this PR.
|
||||
|
||||
**Why not one key per profile.** Exporting it would hand over every name that
|
||||
profile owns, and `SimplexResolver` keeps one nonce per signer shared across
|
||||
every node it owns — so a shared key would serialise every name's record edits
|
||||
behind one counter. Both go away with an index per name.
|
||||
|
||||
**Which key owns which name is not on chain and not derivable**, so it is
|
||||
recorded locally (`wallet_name_keys`). The path is stored literally rather than
|
||||
as indices, because a name found on an imported seed may sit on a layout that is
|
||||
not ours — `lucy.simplex` above stays at the root and is re-derived from `"m"`.
|
||||
|
||||
### Where stealth addresses will attach
|
||||
|
||||
Not in this PR, but the layout has to leave room. A profile publishes **one
|
||||
meta-address**: a spend key and a viewing key, both hardened under purpose
|
||||
`5564'` at the profile level, `m/5564'/60'/i'/0'/0` and `m/5564'/60'/i'/1'/0`.
|
||||
|
||||
A sender derives a fresh destination from it without any handshake — shared
|
||||
secret `s = keccak256(r · P_view)` for a random ephemeral `r`, destination
|
||||
`addr(P_spend + s·G)` — and the recipient recomputes `s` from the sender's
|
||||
ephemeral public key `R` as `keccak256(p_view · R)`, holding the name with
|
||||
`p_spend + s`.
|
||||
|
||||
So a received name's key is **not at a derivation path**: it is the spend key
|
||||
plus a scalar, recoverable from `R` rather than from an index. One meta-address
|
||||
per profile therefore serves any number of received names, which is why the
|
||||
meta-address sits at the profile level while owned names sit at the address
|
||||
level.
|
||||
|
||||
## Why two RPCs for one call
|
||||
|
||||
`purchaseName` splits into **commit** then **reveal** so the registrar cannot
|
||||
@@ -146,8 +215,8 @@ a user registering the same name again.
|
||||
|
||||
| Component | This PR | Extends to |
|
||||
|---|---|---|
|
||||
| **Wallet** | `Wallet.newSeed` + `deriveAccount` + `accountAddress`, already built. No signing. | `signIntent` (also built) unlocks edits/transfers with no shape change. |
|
||||
| **Wallet storage** | the prototype's `wallet_seeds` migration and `Store.Wallets` verbatim: one seed per DB, one account index per profile, allocated from a stored high-water mark. | recovery import, `backed_up` reminder and the one-time-address table are already in the schema; they need code only. |
|
||||
| **Wallet** | `Wallet.newSeed` + `deriveNameKey` / `deriveAtPath` + `accountAddress`. No signing. | `signIntent` (also built) unlocks edits/transfers with no shape change. |
|
||||
| **Wallet storage** | `wallet_seeds` plus `wallet_name_keys`: one account index per profile, one address index per name, both from stored high-water marks. | recovery import, `backed_up` reminder and the one-time-address table are already in the schema; they need code only. |
|
||||
| **RPC transport** | badges' `APISendServiceRequest` / `APISendServiceResponse`, unchanged. | shared. |
|
||||
| **Service** | extend `BadgeService/Service.hs` `handleServiceRequest` to dispatch `NRCommit`/`NRReveal`; add an in-memory chain mock (a `TVar (Map name entry)`, like `Names.Service.Mock`). | swap the mock for a relayer to a deployed SNRC. |
|
||||
| **Resolution** | the mock chain is the record: the name resolves there to owner and SimpleX link. No client-side names store. | a resolver read against a deployed SNRC; add a local cache only if a listing UX needs it. |
|
||||
|
||||
@@ -162,6 +162,7 @@ library
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260731_user_badges
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260818_wallet_seeds
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260826_wallet_name_keys
|
||||
else
|
||||
exposed-modules:
|
||||
Simplex.Chat.Archive
|
||||
@@ -334,6 +335,7 @@ library
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260731_user_badges
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260818_wallet_seeds
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260826_wallet_name_keys
|
||||
other-modules:
|
||||
Paths_simplex_chat
|
||||
hs-source-dirs:
|
||||
|
||||
@@ -842,8 +842,8 @@ data ChatResponse
|
||||
| CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest, contact_ :: Maybe Contact}
|
||||
| CRServiceResponse {user :: User, responseData :: J.Object}
|
||||
| CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId}
|
||||
| CRNameRegistered {user :: User, regName :: Text, regOwner :: Text, regExpiry :: UTCTime, regTxHash :: TxHash}
|
||||
| CRNameAddress {user :: User, nameAddress :: Maybe Text}
|
||||
| CRNameRegistered {user :: User, regName :: Text, regOwner :: Text, regPath :: Text, regExpiry :: UTCTime, regTxHash :: TxHash}
|
||||
| CRNameAddress {user :: User, nameAddresses :: [(Text, Text, Text)]}
|
||||
| CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact}
|
||||
| CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool}
|
||||
| CRGroupsList {user :: User, groups :: [GroupInfo]}
|
||||
@@ -1475,7 +1475,7 @@ data ChatErrorType
|
||||
| CESimplexDomainNotReady {simplexDomain :: SimplexDomain, simplexDomainError :: SimplexDomainError}
|
||||
-- | the names service refused the registration; @nameRegCode@ is the protocol
|
||||
-- error code as sent on the wire, e.g. @name_taken@
|
||||
| CENameRegistrationFailed {nameRegCode :: Text, nameRegMessage :: Maybe Text}
|
||||
| CENameRegistrationFailed {nameRegCode :: Text, nameRegMessage :: Maybe Text, nameRegRetryAfter :: Maybe Word32}
|
||||
| CENotResolvedLocally -- a name or link is not a known chat in the local store and online resolution is off (PRMNever)
|
||||
| CEUnsupportedConnReq
|
||||
| CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String}
|
||||
|
||||
@@ -60,8 +60,8 @@ import Simplex.Chat.Library.Subscriber
|
||||
import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), maxXFTPFileSize, mkBadgeStatus, verifyCredential)
|
||||
import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim)
|
||||
import Simplex.Chat.Names.Protocol
|
||||
import Simplex.Chat.Store.Wallets (boundAccount, getOrCreateAccountRef)
|
||||
import Simplex.Chat.Wallet (AccountRef (..), accountAddress, deriveAccount, newSeed)
|
||||
import Simplex.Chat.Store.Wallets (boundAccount, getNameKeys, getOrCreateAccountRef, recordNameKey, takeNameIndex)
|
||||
import Simplex.Chat.Wallet (AccountRef (..), WalletSeed (..), accountAddress, deriveAtPath, deriveNameKey, newSeed, renderNameKeyPath)
|
||||
import Simplex.Chat.Call
|
||||
import Simplex.Chat.Controller
|
||||
import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..))
|
||||
@@ -1465,7 +1465,7 @@ processChatCommand cxt nm = \case
|
||||
resp <- either (const $ throwCmdError "invalid service response") pure $ J.eitherDecodeStrict' respData
|
||||
pure $ CRServiceResponse user resp
|
||||
APINameRegister sendTarget nm' sLink -> withUser $ \user -> do
|
||||
owner <- deriveNameOwner user
|
||||
(seedId, nameIx, acctIx, owner) <- deriveNameOwner user
|
||||
cReq <- resolveServiceTarget nm user sendTarget
|
||||
g <- asks random
|
||||
secret <- NameSecret <$> atomically (C.randomBytes 32 g)
|
||||
@@ -1477,7 +1477,7 @@ processChatCommand cxt nm = \case
|
||||
let req = NamesRequest currentNamesVersion c
|
||||
respData <- withAgent $ \a -> sendServiceRequestAsync a (aUserId user) cReq Nothing Nothing (LB.toStrict $ J.encode req)
|
||||
either (const $ throwCmdError "invalid names response") pure (J.eitherDecodeStrict' respData) >>= \case
|
||||
NRPError {nrCode, nrMessage} -> throwChatError $ CENameRegistrationFailed (textEncode nrCode) nrMessage
|
||||
NRPError {nrCode, nrMessage, nrRetryAfter} -> throwChatError $ CENameRegistrationFailed (textEncode nrCode) nrMessage nrRetryAfter
|
||||
r -> pure r
|
||||
progress phase waitMs = toView $ CEvtNameRegistrationProgress user nm' phase waitMs
|
||||
progress NRPhaseCommitting Nothing
|
||||
@@ -1492,22 +1492,40 @@ processChatCommand cxt nm = \case
|
||||
NRPRegistered {nrExpiry, nrTxHash} -> pure (nrExpiry, nrTxHash)
|
||||
_ -> throwCmdError "unexpected reveal response"
|
||||
progress NRPhaseRegistered Nothing
|
||||
pure $ CRNameRegistered user nm' (tshow owner) expiry' txHash'
|
||||
-- Recorded only after the name is actually registered: an index consumed by
|
||||
-- a failed attempt is simply skipped, which costs nothing, while recording
|
||||
-- a name we do not own would make the list lie.
|
||||
let path = renderNameKeyPath acctIx nameIx
|
||||
withFastStore' $ \db -> recordNameKey db seedId path nm'
|
||||
pure $ CRNameRegistered user nm' (tshow owner) path expiry' txHash'
|
||||
where
|
||||
-- The seed is created on first registration and persisted, so the owner
|
||||
-- address survives restart — a name whose key we cannot re-derive is lost.
|
||||
--
|
||||
-- One key per name: the index is taken here, so a second registration by
|
||||
-- the same profile lands on a different address rather than sharing one.
|
||||
deriveNameOwner user = do
|
||||
g <- asks random
|
||||
(seed, AccountRef {arIndex}) <-
|
||||
withFastStore' $ \db -> getOrCreateAccountRef db user (atomically $ newSeed MS256 g)
|
||||
either (throwCmdError . ("wallet: " <>)) (pure . accountAddress) $ deriveAccount seed arIndex
|
||||
nameIx <- withFastStore' $ \db -> takeNameIndex db user
|
||||
acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveNameKey seed arIndex nameIx
|
||||
pure (wsId seed, nameIx, arIndex, accountAddress acc)
|
||||
APINameAddress -> withUser $ \user -> do
|
||||
-- Read-only: never creates a seed. A key appears when you register a name,
|
||||
-- not when you ask which address you have.
|
||||
--
|
||||
-- There is no single "profile address" any more: each name has its own key,
|
||||
-- so this answers with one row per name.
|
||||
acc_ <- withFastStore' $ \db -> boundAccount db user
|
||||
addr <- forM acc_ $ \(seed, AccountRef {arIndex}) ->
|
||||
either (throwCmdError . ("wallet: " <>)) (pure . tshow . accountAddress) $ deriveAccount seed arIndex
|
||||
pure $ CRNameAddress user addr
|
||||
addrs <- case acc_ of
|
||||
Nothing -> pure []
|
||||
Just (seed, AccountRef {arIndex}) -> do
|
||||
named <- withFastStore' $ \db -> getNameKeys db (wsId seed)
|
||||
forM named $ \(nm_, path) -> do
|
||||
acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveAtPath seed arIndex path
|
||||
pure (nm_, tshow (accountAddress acc), path)
|
||||
pure $ CRNameAddress user addrs
|
||||
APISendServiceResponse userId requestId responseData -> withUserId userId $ \user -> do
|
||||
let AgentInvId invId = requestId
|
||||
connId <- withAgent $ \a -> sendServiceReplyAsync a "" (aUserId user) invId (LB.toStrict $ J.encode responseData)
|
||||
|
||||
@@ -47,6 +47,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260818_wallet_seeds
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260826_wallet_name_keys
|
||||
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
|
||||
|
||||
schemaMigrations :: [(String, Text, Maybe Text)]
|
||||
@@ -93,7 +94,8 @@ schemaMigrations =
|
||||
("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history),
|
||||
("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles),
|
||||
("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection),
|
||||
("20260818_wallet_seeds", m20260818_wallet_seeds, Just down_m20260818_wallet_seeds)
|
||||
("20260818_wallet_seeds", m20260818_wallet_seeds, Just down_m20260818_wallet_seeds),
|
||||
("20260826_wallet_name_keys", m20260826_wallet_name_keys, Just down_m20260826_wallet_name_keys)
|
||||
]
|
||||
|
||||
-- | The list of migrations in ascending order by date
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Store.Postgres.Migrations.M20260826_wallet_name_keys where
|
||||
|
||||
import Data.Text (Text)
|
||||
import Text.RawString.QQ (r)
|
||||
|
||||
m20260826_wallet_name_keys :: Text
|
||||
m20260826_wallet_name_keys =
|
||||
[r|
|
||||
CREATE TABLE wallet_name_keys (
|
||||
wallet_name_key_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
wallet_seed_id BIGINT NOT NULL REFERENCES wallet_seeds ON DELETE RESTRICT,
|
||||
derivation_path TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (wallet_seed_id, derivation_path),
|
||||
UNIQUE (wallet_seed_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_wallet_name_keys_wallet_seed_id ON wallet_name_keys(wallet_seed_id);
|
||||
|
||||
ALTER TABLE users ADD COLUMN wallet_next_name_index BIGINT NOT NULL DEFAULT 0;
|
||||
|]
|
||||
|
||||
down_m20260826_wallet_name_keys :: Text
|
||||
down_m20260826_wallet_name_keys =
|
||||
[r|
|
||||
ALTER TABLE users DROP COLUMN wallet_next_name_index;
|
||||
|
||||
DROP INDEX idx_wallet_name_keys_wallet_seed_id;
|
||||
|
||||
DROP TABLE wallet_name_keys;
|
||||
|]
|
||||
@@ -170,6 +170,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260818_wallet_seeds
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260826_wallet_name_keys
|
||||
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
|
||||
|
||||
schemaMigrations :: [(String, Query, Maybe Query)]
|
||||
@@ -328,6 +329,7 @@ schemaMigrations =
|
||||
("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges),
|
||||
("20260818_wallet_seeds", m20260818_wallet_seeds, Just down_m20260818_wallet_seeds),
|
||||
("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders),
|
||||
("20260826_wallet_name_keys", m20260826_wallet_name_keys, Just down_m20260826_wallet_name_keys),
|
||||
("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services),
|
||||
("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at),
|
||||
("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Store.SQLite.Migrations.M20260826_wallet_name_keys where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
-- | One key per name, at BIP-44 address index k under the profile's account.
|
||||
--
|
||||
-- Which key owns which name is recorded nowhere else: it is not on chain, and a
|
||||
-- name found by a future recovery scan may sit on a layout that is not ours (a
|
||||
-- name bought in a dapp is typically at the master key, with no derivation at
|
||||
-- all). So the path is stored literally rather than as indices.
|
||||
m20260826_wallet_name_keys :: Query
|
||||
m20260826_wallet_name_keys =
|
||||
[sql|
|
||||
CREATE TABLE wallet_name_keys (
|
||||
wallet_name_key_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
wallet_seed_id INTEGER NOT NULL REFERENCES wallet_seeds ON DELETE RESTRICT,
|
||||
derivation_path TEXT NOT NULL, -- "m/44'/60'/0'/0/1", or "m" for a root key
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (wallet_seed_id, derivation_path),
|
||||
UNIQUE (wallet_seed_id, name)
|
||||
) STRICT;
|
||||
|
||||
-- required: the .lint check enforces an index on every foreign key
|
||||
CREATE INDEX idx_wallet_name_keys_wallet_seed_id ON wallet_name_keys(wallet_seed_id);
|
||||
|
||||
-- High-water mark for k, per profile. Same reason as next_account_index: after
|
||||
-- recovery from the phrase alone nothing else knows which indices are taken.
|
||||
ALTER TABLE users ADD COLUMN wallet_next_name_index INTEGER NOT NULL DEFAULT 0;
|
||||
|]
|
||||
|
||||
down_m20260826_wallet_name_keys :: Query
|
||||
down_m20260826_wallet_name_keys =
|
||||
[sql|
|
||||
ALTER TABLE users DROP COLUMN wallet_next_name_index;
|
||||
|
||||
DROP INDEX idx_wallet_name_keys_wallet_seed_id;
|
||||
|
||||
DROP TABLE wallet_name_keys;
|
||||
|]
|
||||
@@ -55,7 +55,8 @@ CREATE TABLE users(
|
||||
is_user_chat_relay INTEGER NOT NULL DEFAULT 0,
|
||||
client_service INTEGER NOT NULL DEFAULT 0,
|
||||
wallet_seed_id INTEGER REFERENCES wallet_seeds ON DELETE RESTRICT,
|
||||
wallet_account_index INTEGER, -- 1 for active user
|
||||
wallet_account_index INTEGER,
|
||||
wallet_next_name_index INTEGER NOT NULL DEFAULT 0, -- 1 for active user
|
||||
FOREIGN KEY(user_id, local_display_name)
|
||||
REFERENCES display_names(user_id, local_display_name)
|
||||
ON DELETE RESTRICT
|
||||
@@ -856,6 +857,15 @@ CREATE TABLE wallet_seeds(
|
||||
-- profile would silently reuse a recovered account's keys.
|
||||
next_account_index INTEGER NOT NULL DEFAULT 0
|
||||
) STRICT;
|
||||
CREATE TABLE wallet_name_keys(
|
||||
wallet_name_key_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
wallet_seed_id INTEGER NOT NULL REFERENCES wallet_seeds ON DELETE RESTRICT,
|
||||
derivation_path TEXT NOT NULL, -- "m/44'/60'/0'/0/1", or "m" for a root key
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
UNIQUE(wallet_seed_id, derivation_path),
|
||||
UNIQUE(wallet_seed_id, name)
|
||||
) STRICT;
|
||||
CREATE INDEX contact_profiles_index ON contact_profiles(
|
||||
display_name,
|
||||
full_name
|
||||
@@ -1391,6 +1401,9 @@ CREATE INDEX idx_chat_items_item_signed_by_group_member_id ON chat_items(
|
||||
item_signed_by_group_member_id
|
||||
);
|
||||
CREATE INDEX idx_users_wallet_seed_id ON users(wallet_seed_id);
|
||||
CREATE INDEX idx_wallet_name_keys_wallet_seed_id ON wallet_name_keys(
|
||||
wallet_seed_id
|
||||
);
|
||||
CREATE TRIGGER on_group_members_insert_update_summary
|
||||
AFTER INSERT ON group_members
|
||||
FOR EACH ROW
|
||||
|
||||
@@ -13,15 +13,19 @@
|
||||
module Simplex.Chat.Store.Wallets
|
||||
( getOrCreateAccountRef,
|
||||
boundAccount,
|
||||
takeNameIndex,
|
||||
recordNameKey,
|
||||
getNameKeys,
|
||||
)
|
||||
where
|
||||
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Int (Int64)
|
||||
import Data.Maybe (listToMaybe)
|
||||
import Data.Text (Text)
|
||||
import Simplex.Chat.Store.Shared (insertedRowId)
|
||||
import Simplex.Chat.Types (User (..))
|
||||
import Simplex.Chat.Wallet (AccountIndex, AccountRef (..), SeedId (..), WalletSeed (..))
|
||||
import Simplex.Chat.Wallet (AccountIndex, AccountRef (..), NameIndex, SeedId (..), WalletSeed (..))
|
||||
import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow)
|
||||
import qualified Simplex.Messaging.Agent.Store.DB as DB
|
||||
|
||||
@@ -123,3 +127,37 @@ getNextAccountIndex db (SeedId sId) =
|
||||
<$> ( maybeFirstRow fromOnly $
|
||||
DB.query db "SELECT next_account_index FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId)
|
||||
)
|
||||
|
||||
-- | Take the next name index for this profile and advance its high-water mark.
|
||||
--
|
||||
-- Names are BIP-44 address indices under the profile's account, so this counter
|
||||
-- is per profile, not per seed. Stored for the same reason as
|
||||
-- 'next_account_index': after recovery from the phrase alone nothing else knows
|
||||
-- which indices already own names on chain.
|
||||
takeNameIndex :: DB.Connection -> User -> IO NameIndex
|
||||
takeNameIndex db User {userId} = do
|
||||
ix <-
|
||||
maybe 0 (fromIntegral :: Int64 -> NameIndex)
|
||||
<$> ( maybeFirstRow fromOnly $
|
||||
DB.query db "SELECT wallet_next_name_index FROM users WHERE user_id = ?" (Only userId)
|
||||
)
|
||||
DB.execute db "UPDATE users SET wallet_next_name_index = ? WHERE user_id = ?" (fromIntegral ix + 1 :: Int64, userId)
|
||||
pure ix
|
||||
|
||||
-- | Record which key owns a name, once it is registered. Without this the
|
||||
-- client cannot tell which of a profile's keys owns which name: the binding is
|
||||
-- not on chain and is not derivable.
|
||||
recordNameKey :: DB.Connection -> SeedId -> Text -> Text -> IO ()
|
||||
recordNameKey db (SeedId sId) path name =
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO wallet_name_keys (wallet_seed_id, derivation_path, name) VALUES (?, ?, ?)"
|
||||
(sId, path, name)
|
||||
|
||||
-- | Every name this seed owns, with the path its key was derived at.
|
||||
getNameKeys :: DB.Connection -> SeedId -> IO [(Text, Text)]
|
||||
getNameKeys db (SeedId sId) =
|
||||
DB.query
|
||||
db
|
||||
"SELECT name, derivation_path FROM wallet_name_keys WHERE wallet_seed_id = ? ORDER BY wallet_name_key_id"
|
||||
(Only sId)
|
||||
|
||||
@@ -190,10 +190,15 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te
|
||||
CRContactRequestRejected u UserContactRequest {localDisplayName = c} _ct_ -> ttyUser u [ttyContact c <> ": contact request rejected"]
|
||||
CRServiceResponse u resp -> ttyUser u ["service response: " <> viewJSON resp]
|
||||
CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)]
|
||||
CRNameAddress u addr_ ->
|
||||
ttyUser u [maybe "no name address yet - it is created when you register a name" (plain . ("name address: " <>)) addr_]
|
||||
CRNameRegistered u nm owner expiry txHash ->
|
||||
ttyUser u [plain $ "name registered: " <> nm <> " -> " <> owner <> " (expires " <> tshow expiry <> ", tx " <> safeDecodeUtf8 (strEncode txHash) <> ")"]
|
||||
CRNameAddress u addrs ->
|
||||
ttyUser u $ case addrs of
|
||||
[] -> ["no name addresses yet - one is created for each name you register"]
|
||||
as -> "name addresses:" : map (\(nm, addr, path) -> plain $ " " <> nm <> " -> " <> addr <> " " <> path) as
|
||||
CRNameRegistered u nm owner path expiry txHash ->
|
||||
ttyUser u
|
||||
[ plain $ "name registered: " <> nm <> " -> " <> owner <> " (expires " <> tshow expiry <> ", tx " <> safeDecodeUtf8 (strEncode txHash) <> ")",
|
||||
plain $ " derivation path: " <> path
|
||||
]
|
||||
CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView
|
||||
CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView
|
||||
CRPublicGroupCreationFailed u results -> ttyUser u $ viewPublicGroupCreationFailed results
|
||||
@@ -2808,7 +2813,12 @@ viewChatError isCmd logLevel testView = \case
|
||||
CEAgentNoSubResult connId -> ["no subscription result for connection: " <> sShow connId]
|
||||
CEServerProtocol p -> [plain $ "Servers for protocol " <> strEncode p <> " cannot be configured by the users"]
|
||||
CECommandError e -> ["bad chat command: " <> plain e]
|
||||
CENameRegistrationFailed code msg -> ["name registration failed: " <> plain code <> maybe "" ((": " <>) . plain) msg]
|
||||
CENameRegistrationFailed code msg retryAfter ->
|
||||
[ "name registration failed: "
|
||||
<> plain code
|
||||
<> maybe "" ((": " <>) . plain) msg
|
||||
<> maybe "" (\s -> plain (" (retry after " <> tshow s <> "s)")) retryAfter
|
||||
]
|
||||
CEAgentCommandError e -> ["agent command error: " <> plain e]
|
||||
CEInvalidFileDescription e -> ["invalid file description: " <> plain e]
|
||||
CEConnectionIncognitoChangeProhibited -> ["incognito mode change prohibited"]
|
||||
|
||||
+65
-13
@@ -1,13 +1,20 @@
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
-- | The wallet: BIP-39 seeds, and the per-chat-profile accounts derived from
|
||||
-- them.
|
||||
-- | The wallet: BIP-39 seeds, and the keys derived from them.
|
||||
--
|
||||
-- * __seed__ — BIP-39 entropy. Generic and profile-scoped, /not/ name-specific.
|
||||
-- * __account__ — a profile's slot in a seed, index @i@, holding the main
|
||||
-- address that owns the names the profile registers.
|
||||
-- * __seed__ — BIP-39 entropy. Generic, /not/ name-specific.
|
||||
-- * __account__ — a profile's slot in a seed, BIP-44 account index @i@.
|
||||
-- * __name key__ — @m\/44'\/60'\/i'\/0\/k@: one key per name, at BIP-44
|
||||
-- address index @k@ under the profile that bought it. This is what the
|
||||
-- registry records as the name's owner.
|
||||
-- * __wallet__ — this module: creation and derivation.
|
||||
--
|
||||
-- One key per name, not one per profile. A per-profile key would mean exporting
|
||||
-- it hands over every name that profile owns, and would put every name's signed
|
||||
-- record edits behind one shared nonce counter on the resolver. Both are avoided
|
||||
-- by giving each name its own address index. @k = 0@ is the profile's first
|
||||
-- name.
|
||||
--
|
||||
-- Names are a /consumer/ of the wallet, which is why this sits here rather than
|
||||
-- under "Simplex.Chat.Names".
|
||||
--
|
||||
@@ -23,10 +30,14 @@ module Simplex.Chat.Wallet
|
||||
( SeedId (..),
|
||||
WalletSeed (..),
|
||||
AccountIndex,
|
||||
NameIndex,
|
||||
AccountRef (..),
|
||||
WalletAccount (..),
|
||||
newSeed,
|
||||
deriveAccount,
|
||||
deriveNameKey,
|
||||
deriveAtPath,
|
||||
nameKeyPath,
|
||||
renderNameKeyPath,
|
||||
accountAddress,
|
||||
)
|
||||
where
|
||||
@@ -35,11 +46,13 @@ import Control.Concurrent.STM
|
||||
import Crypto.Random (ChaChaDRG)
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Int (Int64)
|
||||
import Data.Text (Text)
|
||||
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
|
||||
import Data.Word (Word32)
|
||||
import qualified Simplex.Messaging.Crypto.BIP32 as B32
|
||||
import qualified Simplex.Messaging.Crypto.BIP39 as B39
|
||||
import qualified Simplex.Messaging.Crypto.Secp256k1 as S
|
||||
import Simplex.Messaging.Eth.Address (Address, addressFromPrivateKey, ethereumPath)
|
||||
import Simplex.Messaging.Eth.Address (Address, addressFromPrivateKey)
|
||||
|
||||
newtype SeedId = SeedId Int64
|
||||
deriving (Eq, Ord, Show)
|
||||
@@ -47,6 +60,9 @@ newtype SeedId = SeedId Int64
|
||||
-- | BIP-44 account index within a seed. One per chat profile.
|
||||
type AccountIndex = Word32
|
||||
|
||||
-- | BIP-44 address index within a profile account. One per name.
|
||||
type NameIndex = Word32
|
||||
|
||||
-- | A seed, held as BIP-39 entropy. Stored in the chat database so it rides the
|
||||
-- existing archive export and Migrate-to-another-device flows.
|
||||
--
|
||||
@@ -83,14 +99,50 @@ instance Show WalletAccount where
|
||||
newSeed :: B39.MnemonicStrength -> TVar ChaChaDRG -> STM ByteString
|
||||
newSeed strength g = B39.mnemonicToEntropy <$> B39.randomMnemonic strength g
|
||||
|
||||
-- | Derive the account at @m\/44'\/60'\/i'\/0\/0@.
|
||||
deriveAccount :: WalletSeed -> AccountIndex -> Either String WalletAccount
|
||||
deriveAccount s ix = do
|
||||
-- | @m\/44'\/60'\/i'\/0\/k@ — the standard BIP-44 layout, with the profile at
|
||||
-- the account level and the name at the address level. Nothing here is a custom
|
||||
-- path, so profile @i@'s names are the account list an ordinary Ethereum wallet
|
||||
-- would show for that account.
|
||||
nameKeyPath :: AccountIndex -> NameIndex -> [Word32]
|
||||
nameKeyPath acc nm = [B32.hardened 44, B32.hardened 60, B32.hardened acc, 0, nm]
|
||||
|
||||
-- | The path a name's key was derived at, for display. Users need it only to
|
||||
-- import a single name into a third-party wallet.
|
||||
renderNameKeyPath :: AccountIndex -> NameIndex -> Text
|
||||
renderNameKeyPath acc nm = decodeLatin1 . B32.renderPath $ nameKeyPath acc nm
|
||||
|
||||
-- | Derive the key that owns one name.
|
||||
deriveNameKey :: WalletSeed -> AccountIndex -> NameIndex -> Either String WalletAccount
|
||||
deriveNameKey s acc nm = do
|
||||
m <- B39.entropyToMnemonic (wsEntropy s)
|
||||
master <- B32.masterKey (B39.mnemonicToSeed m "")
|
||||
xk <- B32.derivePath master (ethereumPath ix)
|
||||
pure WalletAccount {waRef = AccountRef {arSeedId = wsId s, arIndex = ix}, waKey = B32.xkKey xk}
|
||||
xk <- B32.derivePath master (nameKeyPath acc nm)
|
||||
pure WalletAccount {waRef = AccountRef {arSeedId = wsId s, arIndex = acc}, waKey = B32.xkKey xk}
|
||||
|
||||
-- | The Ethereum address that owns names registered by this account.
|
||||
-- | Derive at a path given literally, e.g. @"m\/44'\/60'\/0'\/0\/1"@ or @"m"@
|
||||
-- for the master key with no derivation.
|
||||
--
|
||||
-- Names record the path they were derived at rather than an index, because a
|
||||
-- name found on an imported seed may sit on a layout that is not ours — a name
|
||||
-- bought in a dapp is typically at the master key. Re-deriving from the stored
|
||||
-- path keeps those usable without special-casing them.
|
||||
deriveAtPath :: WalletSeed -> AccountIndex -> Text -> Either String WalletAccount
|
||||
deriveAtPath s acc path = do
|
||||
m <- B39.entropyToMnemonic (wsEntropy s)
|
||||
master <- B32.masterKey (B39.mnemonicToSeed m "")
|
||||
ixs <- B32.parsePath (encodeUtf8 path)
|
||||
xk <- B32.derivePath master ixs
|
||||
pure WalletAccount {waRef = AccountRef {arSeedId = wsId s, arIndex = acc}, waKey = B32.xkKey xk}
|
||||
|
||||
-- | The Ethereum address that owns the name this key was derived for.
|
||||
accountAddress :: WalletAccount -> Address
|
||||
accountAddress = addressFromPrivateKey . waKey
|
||||
|
||||
-- Stealth addresses, when they arrive, hang off the same profile account but
|
||||
-- are not at a derivation path at all. A profile publishes one meta-address —
|
||||
-- a spend key and a viewing key, both hardened under purpose 5564' — and a
|
||||
-- sender derives a fresh destination from it as @spend + H(r·view)·G@. The
|
||||
-- recipient's key for that destination is @spend + H(view·R)@, recomputed from
|
||||
-- the sender's ephemeral public key @R@ rather than from an index. So the
|
||||
-- wallet must be able to hold a key that is "spend key plus a scalar", which is
|
||||
-- why one meta-address per profile is enough for any number of received names.
|
||||
|
||||
@@ -16,6 +16,8 @@ import Simplex.Messaging.Encoding.String (strEncode)
|
||||
import Simplex.Messaging.Eth.Address (parseAddress)
|
||||
import Simplex.Messaging.Eth.Keccak (keccak256)
|
||||
import Test.Hspec hiding (it)
|
||||
import qualified Simplex.Messaging.Crypto.BIP39 as B39
|
||||
import Simplex.Chat.Wallet (SeedId (..), WalletSeed (..), accountAddress, deriveNameKey)
|
||||
import qualified Test.Hspec as Hspec
|
||||
|
||||
namesServiceTests :: SpecWith TestParams
|
||||
@@ -23,12 +25,27 @@ namesServiceTests = do
|
||||
it "registers a name via commit/reveal and rejects a taken name" testNamesRegister
|
||||
it "rejects a reveal with no matching commitment" testRevealWithoutCommit
|
||||
it "shows the owner address without creating one" testNameAddress
|
||||
it "derives the same owner address after restart" testSeedPersists
|
||||
it "gives each name its own key, still derivable after restart" testSeedPersists
|
||||
|
||||
-- | Pins the wire format. The end-to-end test cannot catch a key renamed on
|
||||
-- both sides at once, so the encodings are asserted literally here.
|
||||
namesProtocolTests :: Spec
|
||||
namesProtocolTests = do
|
||||
-- Name keys are plain BIP-44, so they line up with wallets users already have.
|
||||
-- Pinned against the standard test mnemonic: profile 0's names are exactly
|
||||
-- MetaMask's account list (m/44'/60'/0'/0/k), and each profile's first name is
|
||||
-- the matching Ledger Live account (m/44'/60'/i'/0/0). That is what lets an
|
||||
-- owner move a single name into another wallet, and a name bought in a dapp be
|
||||
-- found here.
|
||||
it "name keys line up with other wallets' derivation" $ \_ -> do
|
||||
let mn = either error id $ B39.parseMnemonic "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
sd = WalletSeed {wsId = SeedId 1, wsEntropy = B39.mnemonicToEntropy mn}
|
||||
addrOf i k = either error (show . accountAddress) (deriveNameKey sd i k)
|
||||
-- MetaMask account 1 and 2 for this phrase
|
||||
addrOf 0 0 `shouldBe` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"
|
||||
addrOf 0 1 `shouldBe` "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0"
|
||||
-- Ledger Live account 2 for this phrase
|
||||
addrOf 1 0 `shouldBe` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265"
|
||||
Hspec.it "encodes commit and reveal requests" $ do
|
||||
-- on-chain byte values are 0x-prefixed hex, as Ethereum writes them
|
||||
encodes (NamesRequest 1 (NRCommit $ Commitment "0123456789abcdef")) $
|
||||
@@ -75,7 +92,9 @@ testNamesRegister ps =
|
||||
-- the final progress event and the command response arrive on separate channels
|
||||
client
|
||||
<### [ ConsoleString "name alice.simplex: registered",
|
||||
StartsWith "name registered: alice.simplex -> 0x"
|
||||
StartsWith "name registered: alice.simplex -> 0x",
|
||||
-- one key per name: the profile's first name is address index 0
|
||||
ConsoleString " derivation path: m/44'/60'/0'/0/0"
|
||||
]
|
||||
-- re-running the identical command is rejected too, not silently accepted:
|
||||
-- the owner is the same within a session, so this is the duplicate a user hits.
|
||||
@@ -99,14 +118,15 @@ testNameAddress ps =
|
||||
withBadgeService ps $ \client bsLink -> do
|
||||
-- asked repeatedly before any registration: still no address, none created
|
||||
client ##> "/name address"
|
||||
client <## "no name address yet - it is created when you register a name"
|
||||
client <## "no name addresses yet - one is created for each name you register"
|
||||
client ##> "/name address"
|
||||
client <## "no name address yet - it is created when you register a name"
|
||||
client <## "no name addresses yet - one is created for each name you register"
|
||||
client ##> ("/name register " <> bsLink <> " carol.simplex simplex:/contact#/x")
|
||||
owner <- ownerOf client "carol.simplex"
|
||||
-- now it exists, and reports the address the name was registered to
|
||||
-- now it exists, and reports the address and path the name was registered to
|
||||
client ##> "/name address"
|
||||
client <## ("name address: " <> owner)
|
||||
client <## "name addresses:"
|
||||
client <## (" carol.simplex -> " <> owner <> " m/44'/60'/0'/0/0")
|
||||
|
||||
-- | The front-running defence: a reveal only registers a name if that exact
|
||||
-- commitment was published first. Sent as a raw service request, because the
|
||||
@@ -124,6 +144,12 @@ testRevealWithoutCommit ps =
|
||||
-- | The seed is persisted, so a name registered in one session is still owned by
|
||||
-- an address the next session can derive. Without this the key is unrecoverable
|
||||
-- after restart and the name is orphaned.
|
||||
--
|
||||
-- It also pins the other half of one-key-per-name: the second registration must
|
||||
-- land on a /different/ address, at the next BIP-44 address index under the same
|
||||
-- profile account. Sharing one key across a profile's names is what this
|
||||
-- replaces — it would put every name behind one resolver nonce and make
|
||||
-- exporting one name's key hand over all of them.
|
||||
testSeedPersists :: HasCallStack => TestParams -> IO ()
|
||||
testSeedPersists ps = do
|
||||
let opts = mkBadgeServiceOpts ps
|
||||
@@ -143,20 +169,29 @@ testSeedPersists ps = do
|
||||
owner2 <- withTestChat ps "client" $ \client -> do
|
||||
client ##> ("/name register " <> bsLink <> " second.simplex simplex:/contact#/y")
|
||||
ownerOf client "second.simplex"
|
||||
owner2 `shouldBe` owner1
|
||||
owner2 `shouldNotBe` owner1
|
||||
-- and both are still derivable in a third session, each at its own path
|
||||
withTestChat ps "client" $ \client -> do
|
||||
client ##> "/name address"
|
||||
client <## "name addresses:"
|
||||
client <## (" first.simplex -> " <> owner1 <> " m/44'/60'/0'/0/0")
|
||||
client <## (" second.simplex -> " <> owner2 <> " m/44'/60'/0'/0/1")
|
||||
|
||||
-- | Reads past startup and progress lines to the registration result, returning
|
||||
-- the owner address. Keeps reading until the final progress event has arrived
|
||||
-- too — it races with the command response and would otherwise be left
|
||||
-- unconsumed, failing the next assertion or the session close.
|
||||
ownerOf :: HasCallStack => TestCC -> String -> IO String
|
||||
ownerOf client nm = go (40 :: Int) Nothing False
|
||||
ownerOf client nm = go (40 :: Int) Nothing False False
|
||||
where
|
||||
pfx = "name registered: " <> nm <> " -> "
|
||||
pathLine = " derivation path: "
|
||||
lastEvt = "name " <> nm <> ": registered"
|
||||
go _ (Just a) True = pure a
|
||||
go 0 _ _ = error $ "no registration line for " <> nm
|
||||
go n addr seen = do
|
||||
-- three lines must be consumed before the next assertion: the progress
|
||||
-- event, the registration line, and the derivation path under it
|
||||
go _ (Just a) True True = pure a
|
||||
go 0 _ _ _ = error $ "no registration line for " <> nm
|
||||
go n addr seen path = do
|
||||
l <- getTermLine client
|
||||
let addr' = if pfx `isPrefixOf` l then Just (takeWhile (/= ' ') $ drop (length pfx) l) else addr
|
||||
go (n - 1) addr' (seen || l == lastEvt)
|
||||
go (n - 1) addr' (seen || l == lastEvt) (path || pathLine `isPrefixOf` l)
|
||||
|
||||
Reference in New Issue
Block a user