names: derive one key per name at m/44'/60'/i'/0/k

This commit is contained in:
Alain Brenzikofer
2026-08-26 20:40:23 +02:00
parent 5fc7215f3b
commit 8a62fc49c1
13 changed files with 366 additions and 47 deletions
+3 -3
View File
@@ -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}
+27 -9
View File
@@ -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
+39 -1
View File
@@ -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)
+15 -5
View File
@@ -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
View File
@@ -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.