implement names purchase MVP integrating with the badges service

This commit is contained in:
Alain Brenzikofer
2026-08-18 14:06:21 +02:00
parent 352550089f
commit d85da0647b
18 changed files with 930 additions and 27 deletions
+5 -1
View File
@@ -48,7 +48,7 @@ import Data.Text.Encoding (decodeLatin1)
import Data.Time (NominalDiffTime, UTCTime)
import Data.Time.Clock.System (SystemTime (..), systemToUTCTime)
import Data.Version (showVersion)
import Data.Word (Word16)
import Data.Word (Word16, Word32)
import Language.Haskell.TH (Exp, Q, runIO)
import Network.Socket (HostName)
import Numeric.Natural
@@ -64,6 +64,7 @@ import Simplex.Chat.Remote.AppVersion
import Simplex.Chat.Remote.Types
import Simplex.Chat.Stats (PresentedServersSummary)
import Simplex.Chat.Store (AddressSettings, ChatLockEntity, GroupLinkInfo, StoreError (..), UserContactLink, UserMsgReceiptSettings)
import Simplex.Chat.Names.Protocol (NameRegPhase, TxHash)
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
@@ -413,6 +414,7 @@ data ChatCommand
| APIRejectContact {contactReqId :: Int64, notify :: Bool}
| APISendServiceRequest {userId :: UserId, sendTarget :: ConnectTarget 'CMContact, requestTimeout :: Maybe NominalDiffTime, signKey :: Maybe (C.StoredPrivateKey 'C.Ed25519), request :: J.Object}
| APISendServiceResponse {userId :: UserId, requestId :: AgentInvId, responseData :: J.Object}
| APINameRegister {sendTarget :: ConnectTarget 'CMContact, regName :: Text, registerLink :: Text}
| APISendCallInvitation ContactId CallType
| SendCallInvitation ContactName CallType
| APIRejectCall ContactId
@@ -839,6 +841,7 @@ 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}
| CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact}
| CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool}
| CRGroupsList {user :: User, groups :: [GroupInfo]}
@@ -956,6 +959,7 @@ data ChatEvent
| CEvtReceivedContactRequest {user :: User, contactRequest :: UserContactRequest, chat_ :: Maybe AChat}
| CEvtServiceRequest {user :: User, requestId :: AgentInvId, signerKey :: Maybe C.PublicKeyEd25519, requestData :: J.Object}
| CEvtServiceReplySent {connectionId :: AgentConnId}
| CEvtNameRegistrationProgress {user :: User, regName :: Text, regPhase :: NameRegPhase, waitMs :: Maybe Word32}
| CEvtContactRequestRejected {user :: User, contact :: Contact, rejectionReason :: Maybe ContactRejectionReason}
| CEvtAcceptingContactRequest {user :: User, contact :: Contact} -- there is the same command response
| CEvtAcceptingBusinessRequest {user :: User, groupInfo :: GroupInfo}
+69 -15
View File
@@ -33,6 +33,7 @@ import qualified Data.ByteString.Base64 as B64
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Word (Word32)
import Data.Char
import Data.Constraint (Dict (..))
import Data.Either (fromRight, partitionEithers, rights)
@@ -58,6 +59,9 @@ import qualified Data.UUID.V4 as V4
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 (getOrCreateAccountRef)
import Simplex.Chat.Wallet (AccountRef (..), accountAddress, deriveAccount, newSeed)
import Simplex.Chat.Call
import Simplex.Chat.Controller
import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..))
@@ -104,6 +108,7 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Messaging.Agent.Store.Interface (getCurrentMigrations)
import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode (..), NetworkTimeout (..), SMPWebPortServers (..), SocksMode (SMAlways), pattern NRMInteractive, textToHostMode)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.BIP39 (MnemonicStrength (..))
import qualified Simplex.Messaging.Crypto.ShortLink as SL
import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..))
import qualified Simplex.Messaging.Crypto.File as CF
@@ -1455,25 +1460,47 @@ processChatCommand cxt nm = \case
pure ct_
pure $ CRContactRequestRejected user cReq ct_
APISendServiceRequest userId sendTarget requestTimeout signKey request -> withUserId userId $ \user -> do
cReq <- resolveServiceTarget user sendTarget
cReq <- resolveServiceTarget nm user sendTarget
respData <- withAgent $ \a -> sendServiceRequestAsync a (aUserId user) cReq requestTimeout (C.unStored <$> signKey) (LB.toStrict $ J.encode request)
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
cReq <- resolveServiceTarget nm user sendTarget
g <- asks random
secret <- NameSecret <$> atomically (C.randomBytes 32 g)
let ttl = defaultNameTtl
commitment = mkCommitment nm' owner secret ttl
sendNames c = do
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
progress phase waitMs = toView $ CEvtNameRegistrationProgress user nm' phase waitMs
progress NRPhaseCommitting Nothing
sendNames (NRCommit commitment) >>= \case
NRPCommitted {} -> pure ()
NRPError {nrCode, nrMessage} -> throwCmdError $ nameRegError nrCode nrMessage
_ -> throwCmdError "unexpected commit response"
progress NRPhaseCommitted (Just commitWaitMs)
liftIO $ threadDelay $ fromIntegral commitWaitMs * 1000
progress NRPhaseRevealing Nothing
(expiry', txHash') <-
sendNames (NRReveal nm' owner secret ttl sLink) >>= \case
NRPRegistered {nrExpiry, nrTxHash} -> pure (nrExpiry, nrTxHash)
NRPError {nrCode, nrMessage} -> throwCmdError $ nameRegError nrCode nrMessage
_ -> throwCmdError "unexpected reveal response"
progress NRPhaseRegistered Nothing
pure $ CRNameRegistered user nm' (tshow owner) expiry' txHash'
where
resolveServiceTarget user = \case
CTFullContact cReq -> pure cReq
CTShortContact (CTLink sLnk) -> resolveShortLink sLnk
CTShortContact (CTName SimplexNameInfo {nameType, nameDomain}) -> case nameType of
NTContact -> resolveDomain nameDomain
_ -> throwCmdError "service request target must be a contact"
CTDomain d -> resolveDomain d
where
resolveDomain d = do
nr <- withAgent $ \a -> resolveSimplexName a nm (aUserId user) d
case firstNameLink CCTContact (nrSimplexContact nr) of
Just sLnk -> resolveShortLink sLnk
Nothing -> throwChatError $ CESimplexDomainNotReady d SDENoValidLink
resolveShortLink sLnk = (\(_, _, cReq) -> cReq) <$> getShortLinkConnReq nm user sLnk
-- 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.
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
nameRegError code message =
"name registration failed: " <> T.unpack (textEncode code) <> maybe "" ((": " <>) . T.unpack) message
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)
@@ -5021,6 +5048,32 @@ processChatCommand cxt nm = \case
gVar <- asks random
liftIO $ SharedMsgId <$> encodedRandomBytes gVar 12
-- | Resolve a service connect target to a contact connection request, shared by
-- the service-request and name-registration commands.
resolveServiceTarget :: NetworkRequestMode -> User -> ConnectTarget 'CMContact -> CM ConnReqContact
resolveServiceTarget nm user = \case
CTFullContact cReq -> pure cReq
CTShortContact (CTLink sLnk) -> resolveShortLink sLnk
CTShortContact (CTName SimplexNameInfo {nameType, nameDomain}) -> case nameType of
NTContact -> resolveDomain nameDomain
_ -> throwCmdError "service request target must be a contact"
CTDomain d -> resolveDomain d
where
resolveDomain d = do
nr <- withAgent $ \a -> resolveSimplexName a nm (aUserId user) d
case firstNameLink CCTContact (nrSimplexContact nr) of
Just sLnk -> resolveShortLink sLnk
Nothing -> throwChatError $ CESimplexDomainNotReady d SDENoValidLink
resolveShortLink sLnk = (\(_, _, cReq) -> cReq) <$> getShortLinkConnReq nm user sLnk
-- | Default name lifetime (365 days). Real min-commitment-age enforcement deferred.
defaultNameTtl :: NameTtl
defaultNameTtl = 31536000
-- | Hardcoded commit→reveal wait in ms, standing in for the on-chain minimum commitment age.
commitWaitMs :: Word32
commitWaitMs = 1000
firstNameLink :: ContactConnType -> [Text] -> Maybe (ConnShortLink 'CMContact)
firstNameLink ctType = foldr (\t r -> nameLink t <|> r) Nothing
where
@@ -5527,6 +5580,7 @@ chatCommandP =
"/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)),
"/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP),
"/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP),
"/name register " *> (APINameRegister <$> strP <* A.space <*> displayNameP <* A.space <*> textP),
"/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP),
"/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType),
"/_call reject @" *> (APIRejectCall <$> A.decimal),
+246
View File
@@ -0,0 +1,246 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
-- | Wire protocol for name registration over the badge service-RPC transport.
--
-- A 'NamesRequest' travels in @APISendServiceRequest.request@ and a
-- 'NamesResponse' comes back in @CRServiceResponse.responseData@ — one response
-- per request. The envelope and both command\/response sums are @type@-tagged
-- JSON objects, the same shape the badge service decodes.
--
-- Registration is commit-then-reveal (see the MVP plan): 'NRCommit' publishes
-- only @H(name, owner, secret, ttl)@ so the service cannot tell which name it
-- is; 'NRReveal' submits the plaintext once the aged commitment already binds
-- the name to /this/ owner address, so the service cannot front-run it.
module Simplex.Chat.Names.Protocol
( NamesVersion,
currentNamesVersion,
NameTtl,
NamesRequest (..),
NamesCommand (..),
NamesResponse (..),
NamesErrorCode (..),
Commitment (..),
NameSecret (..),
TxHash (..),
NameRegPhase (..),
mkCommitment,
)
where
import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?), (.=))
import qualified Data.Aeson as J
import qualified Data.Aeson.Types as JT
import Control.Applicative (optional)
import Data.Attoparsec.ByteString.Char8 (Parser)
import qualified Data.Attoparsec.ByteString.Char8 as A
import qualified Data.ByteArray.Encoding as BAE
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import Data.Char (isHexDigit)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Time.Clock (UTCTime)
import Data.Word (Word16, Word32)
import qualified Data.Aeson.TH as JQ
import Simplex.Messaging.Encoding (smpEncode)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (enumJSON)
import Simplex.Messaging.Eth.Address (Address, checksumAddress, parseAddress, unAddress)
import Simplex.Messaging.Eth.Keccak (keccak256)
import Simplex.Messaging.Util (safeDecodeUtf8)
-- | Protocol version, negotiated the way the badge service version is.
type NamesVersion = Word16
currentNamesVersion :: NamesVersion
currentNamesVersion = 1
-- | Name lifetime in seconds. Real min-commitment-age enforcement is deferred.
type NameTtl = Word32
-- | @H(name, owner, secret, ttl)@ — the only thing a commit reveals.
newtype Commitment = Commitment {unCommitment :: ByteString}
deriving (Eq, Show)
-- | Per-registration nonce, kept secret until reveal so the commitment is opaque.
newtype NameSecret = NameSecret {unSecret :: ByteString}
deriving (Eq, Show)
-- | A mock transaction hash. Returned now so block-inclusion checks (out of
-- scope) need no protocol change later.
newtype TxHash = TxHash {unTxHash :: ByteString}
deriving (Eq, Show)
-- | All three are on-chain byte values, so they are encoded the way Ethereum
-- writes them — @0x@-prefixed hex — not base64.
hexEncode :: ByteString -> ByteString
hexEncode = ("0x" <>) . BAE.convertToBase BAE.Base16
hexP :: Parser ByteString
hexP = do
_ <- optional (A.string "0x")
s <- A.takeWhile1 isHexDigit
either fail pure $ BAE.convertFromBase BAE.Base16 s
instance StrEncoding Commitment where
strEncode = hexEncode . unCommitment
strP = Commitment <$> hexP
instance StrEncoding NameSecret where
strEncode = hexEncode . unSecret
strP = NameSecret <$> hexP
instance StrEncoding TxHash where
strEncode = hexEncode . unTxHash
strP = TxHash <$> hexP
instance ToJSON Commitment where toJSON = strToJSON; toEncoding = strToJEncoding
instance FromJSON Commitment where parseJSON = strParseJSON "Commitment"
instance ToJSON NameSecret where toJSON = strToJSON; toEncoding = strToJEncoding
instance FromJSON NameSecret where parseJSON = strParseJSON "NameSecret"
instance ToJSON TxHash where toJSON = strToJSON; toEncoding = strToJEncoding
instance FromJSON TxHash where parseJSON = strParseJSON "TxHash"
-- | Bind a name to an owner address with a secret and a TTL. Uses keccak256, so
-- the commitment matches what an Ethereum registrar would hash.
mkCommitment :: Text -> Address -> NameSecret -> NameTtl -> Commitment
mkCommitment name owner (NameSecret secret) ttl =
Commitment . keccak256 $ B.concat [encodeUtf8 name, unAddress owner, secret, smpEncode ttl]
-- | @{ version, request }@ — the outer envelope. Fields are @nr@-prefixed so
-- they do not shadow local bindings where this module is imported unqualified.
data NamesRequest = NamesRequest
{ nrVersion :: NamesVersion,
nrRequest :: NamesCommand
}
deriving (Eq, Show)
data NamesCommand
= NRCommit {nrCommitment :: Commitment}
| NRReveal
{ nrName :: Text,
nrOwner :: Address,
nrSecret :: NameSecret,
nrTtl :: NameTtl,
nrLink :: Text
}
deriving (Eq, Show)
data NamesResponse
= NRPCommitted {nrTxHash :: TxHash}
| NRPRegistered {nrName :: Text, nrExpiry :: UTCTime, nrTxHash :: TxHash}
| NRPError {nrCode :: NamesErrorCode, nrMessage :: Maybe Text, nrRetryAfter :: Maybe Word32}
deriving (Eq, Show)
data NamesErrorCode
= NECNameTaken
| NECBadRequest
| NECUnsupportedVersion
| NECInternal
| NECUnknown Text -- forwards-compatible: service may be ahead of clients
deriving (Eq, Show)
instance TextEncoding NamesErrorCode where
textEncode = \case
NECNameTaken -> "name_taken"
NECBadRequest -> "bad_request"
NECUnsupportedVersion -> "unsupported_version"
NECInternal -> "internal"
NECUnknown t -> t
textDecode = Just . \case
"name_taken" -> NECNameTaken
"bad_request" -> NECBadRequest
"unsupported_version" -> NECUnsupportedVersion
"internal" -> NECInternal
t -> NECUnknown t
instance ToJSON NamesErrorCode where
toJSON = textToJSON
toEncoding = textToEncoding
instance FromJSON NamesErrorCode where
parseJSON = textParseJSON "NamesErrorCode"
instance ToJSON NamesRequest where
toJSON NamesRequest {nrVersion, nrRequest} = J.object ["version" .= nrVersion, "request" .= nrRequest]
instance FromJSON NamesRequest where
parseJSON = J.withObject "NamesRequest" $ \o ->
NamesRequest <$> o .: "version" <*> o .: "request"
instance ToJSON NamesCommand where
toJSON = \case
NRCommit {nrCommitment} ->
J.object ["type" .= ("commit" :: Text), "commitment" .= nrCommitment]
NRReveal {nrName, nrOwner, nrSecret, nrTtl, nrLink} ->
J.object
[ "type" .= ("reveal" :: Text),
"name" .= nrName,
"owner" .= addressJSON nrOwner,
"secret" .= nrSecret,
"ttl" .= nrTtl,
"simplex_link" .= nrLink
]
instance FromJSON NamesCommand where
parseJSON = J.withObject "NamesCommand" $ \o ->
(o .: "type") >>= \case
"commit" -> NRCommit <$> o .: "commitment"
"reveal" ->
NRReveal
<$> o .: "name"
<*> (o .: "owner" >>= parseAddressJSON)
<*> o .: "secret"
<*> o .: "ttl"
<*> o .: "simplex_link"
t -> fail $ "unknown names command: " <> T.unpack (t :: Text)
instance ToJSON NamesResponse where
toJSON = \case
NRPCommitted {nrTxHash} ->
J.object ["type" .= ("committed" :: Text), "txHash" .= nrTxHash]
NRPRegistered {nrName, nrExpiry, nrTxHash} ->
J.object ["type" .= ("registered" :: Text), "name" .= nrName, "expiry" .= nrExpiry, "txHash" .= nrTxHash]
NRPError {nrCode, nrMessage, nrRetryAfter} ->
J.object $
["type" .= ("error" :: Text), "code" .= nrCode]
<> ["message" .= m | Just m <- [nrMessage]]
<> ["retryAfter" .= r | Just r <- [nrRetryAfter]]
instance FromJSON NamesResponse where
parseJSON = J.withObject "NamesResponse" $ \o ->
(o .: "type") >>= \case
"committed" -> NRPCommitted <$> o .: "txHash"
"registered" -> NRPRegistered <$> o .: "name" <*> o .: "expiry" <*> o .: "txHash"
"error" -> NRPError <$> o .: "code" <*> o .:? "message" <*> o .:? "retryAfter"
t -> fail $ "unknown names response: " <> T.unpack (t :: Text)
-- | EIP-55 checksummed hex, the form a user pastes into a block explorer.
addressJSON :: Address -> J.Value
addressJSON = J.String . safeDecodeUtf8 . checksumAddress
parseAddressJSON :: J.Value -> JT.Parser Address
parseAddressJSON = J.withText "Address" $ either fail pure . parseAddress . encodeUtf8
-- | Progress phases streamed from core to the UI as registration advances.
-- Not part of the wire protocol — carried in @CEvtNameRegistrationProgress@.
-- | 'NRPhaseCommitted' carries @waitMs@: the commit→reveal wait starts as soon
-- as it is emitted, so it needs no phase of its own.
data NameRegPhase
= NRPhaseCommitting
| NRPhaseCommitted
| NRPhaseRevealing
| NRPhaseRegistered
deriving (Eq, Show)
$(JQ.deriveJSON (enumJSON $ \case "NRPhaseCommitting" -> "committing"; "NRPhaseCommitted" -> "committed"; "NRPhaseRevealing" -> "revealing"; "NRPhaseRegistered" -> "registered"; s -> s) ''NameRegPhase)
@@ -46,6 +46,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260715_profile_description
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.M20260806_wallet_seeds
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Text, Maybe Text)]
@@ -91,7 +92,8 @@ schemaMigrations =
("20260715_profile_description", m20260715_profile_description, Just down_m20260715_profile_description),
("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)
("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection),
("20260806_wallet_seeds", m20260806_wallet_seeds, Just down_m20260806_wallet_seeds)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,56 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.Postgres.Migrations.M20260806_wallet_seeds where
import Data.Text (Text)
import Text.RawString.QQ (r)
m20260806_wallet_seeds :: Text
m20260806_wallet_seeds =
[r|
CREATE TABLE wallet_seeds (
wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
seed BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
backed_up SMALLINT NOT NULL DEFAULT 0,
-- High-water mark for account allocation; see the SQLite migration for why
-- this cannot be derived from MAX(users.wallet_account_index).
next_account_index BIGINT NOT NULL DEFAULT 0
);
ALTER TABLE users ADD COLUMN wallet_seed_id BIGINT REFERENCES wallet_seeds ON DELETE RESTRICT;
ALTER TABLE users ADD COLUMN wallet_account_index BIGINT;
ALTER TABLE users ADD COLUMN wallet_scanned_to TEXT;
CREATE INDEX idx_users_wallet_seed_id ON users(wallet_seed_id);
CREATE TABLE wallet_one_time_addresses (
wallet_one_time_address_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE,
chain TEXT NOT NULL,
address BYTEA NOT NULL,
ephemeral_pub_key BYTEA NOT NULL,
discovered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
accepted_at TIMESTAMPTZ,
UNIQUE (user_id, chain, address)
);
CREATE INDEX idx_wallet_one_time_addresses_user ON wallet_one_time_addresses(user_id, chain);
|]
down_m20260806_wallet_seeds :: Text
down_m20260806_wallet_seeds =
[r|
DROP INDEX idx_wallet_one_time_addresses_user;
DROP TABLE wallet_one_time_addresses;
DROP INDEX idx_users_wallet_seed_id;
ALTER TABLE users DROP COLUMN wallet_scanned_to;
ALTER TABLE users DROP COLUMN wallet_account_index;
ALTER TABLE users DROP COLUMN wallet_seed_id;
DROP TABLE wallet_seeds;
|]
@@ -169,6 +169,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260715_profile_description
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.M20260806_wallet_seeds
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -325,6 +326,7 @@ schemaMigrations =
("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index),
("20260515_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access),
("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges),
("20260806_wallet_seeds", m20260806_wallet_seeds, Just down_m20260806_wallet_seeds),
("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders),
("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),
@@ -0,0 +1,74 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.SQLite.Migrations.M20260806_wallet_seeds where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
-- | Wallet seeds: BIP-39 entropy, one or more per database.
--
-- The schema allows several seeds per database, with each chat profile bound to
-- exactly one of them plus its own BIP-44 account index. Only the single-seed
-- case is reachable from the UI today — profiles all share one seed and differ
-- by account index — but modelling it this way now means importing a second
-- recovery key later is a UI change, not a migration of live key material.
m20260806_wallet_seeds :: Query
m20260806_wallet_seeds =
[sql|
CREATE TABLE wallet_seeds (
wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT,
seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes
created_at TEXT NOT NULL DEFAULT (datetime('now')),
backed_up INTEGER NOT NULL DEFAULT 0, -- user acknowledged saving the recovery key
-- High-water mark for account allocation. Deliberately not derived from
-- MAX(users.wallet_account_index): after recovery from the phrase alone that
-- table is empty while accounts 0..N already hold names on chain, so a new
-- profile would silently reuse a recovered account's keys and meta-address.
-- The recovery probe raises this before any profile is created.
next_account_index INTEGER NOT NULL DEFAULT 0
) STRICT;
ALTER TABLE users ADD COLUMN wallet_seed_id INTEGER REFERENCES wallet_seeds ON DELETE RESTRICT;
ALTER TABLE users ADD COLUMN wallet_account_index INTEGER;
-- Position of the last recovery scan, so a repeat scan resumes. Not a live
-- watermark: incoming names are learned from a chat message, not by scanning.
ALTER TABLE users ADD COLUMN wallet_scanned_to TEXT;
CREATE INDEX idx_users_wallet_seed_id ON users(wallet_seed_id);
-- Destinations learned from a sender's message, or rediscovered by a recovery
-- scan. Holds no private key: the key is re-derived from the seed and the
-- ephemeral public key on demand, so this table is a cache and losing it costs
-- a rescan rather than an asset.
--
-- 'chain' is carried from the first migration so that adding Bitcoin or Monero
-- later is new rows, not a schema change.
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' today; 'btc', 'xmr' later
address BLOB NOT NULL,
ephemeral_pub_key BLOB NOT NULL, -- compressed secp256k1 point, 33 bytes
discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
accepted_at TEXT, -- NULL = received but not accepted
UNIQUE (user_id, chain, address)
) STRICT;
CREATE INDEX idx_wallet_one_time_addresses_user ON wallet_one_time_addresses(user_id, chain);
|]
down_m20260806_wallet_seeds :: Query
down_m20260806_wallet_seeds =
[sql|
DROP INDEX idx_wallet_one_time_addresses_user;
DROP TABLE wallet_one_time_addresses;
DROP INDEX idx_users_wallet_seed_id;
ALTER TABLE users DROP COLUMN wallet_scanned_to;
ALTER TABLE users DROP COLUMN wallet_account_index;
ALTER TABLE users DROP COLUMN wallet_seed_id;
DROP TABLE wallet_seeds;
|]
@@ -53,7 +53,10 @@ CREATE TABLE users(
active_order INTEGER NOT NULL DEFAULT 0,
auto_accept_member_contacts INTEGER NOT NULL DEFAULT 0,
is_user_chat_relay INTEGER NOT NULL DEFAULT 0,
client_service INTEGER NOT NULL DEFAULT 0, -- 1 for active user
client_service INTEGER NOT NULL DEFAULT 0,
wallet_seed_id INTEGER REFERENCES wallet_seeds ON DELETE RESTRICT,
wallet_account_index INTEGER,
wallet_scanned_to TEXT, -- 1 for active user
FOREIGN KEY(user_id, local_display_name)
REFERENCES display_names(user_id, local_display_name)
ON DELETE RESTRICT
@@ -845,6 +848,29 @@ CREATE TABLE rcv_roster_transfers(
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
) STRICT;
CREATE TABLE wallet_seeds(
wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT,
seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes
created_at TEXT NOT NULL DEFAULT(datetime('now')),
backed_up INTEGER NOT NULL DEFAULT 0, -- user acknowledged saving the recovery key
-- High-water mark for account allocation. Deliberately not derived from
-- MAX(users.wallet_account_index): after recovery from the phrase alone that
-- table is empty while accounts 0..N already hold names on chain, so a new
-- profile would silently reuse a recovered account's keys and meta-address.
-- The recovery probe raises this before any profile is created.
next_account_index INTEGER NOT NULL DEFAULT 0
) STRICT;
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' today; 'btc',
'xmr' later
address BLOB NOT NULL,
ephemeral_pub_key BLOB NOT NULL, -- compressed secp256k1 point, 33 bytes
discovered_at TEXT NOT NULL DEFAULT(datetime('now')),
accepted_at TEXT, -- NULL = received but not accepted
UNIQUE(user_id, chain, address)
) STRICT;
CREATE INDEX contact_profiles_index ON contact_profiles(
display_name,
full_name
@@ -1379,6 +1405,11 @@ CREATE INDEX idx_files_roster_transfer_id ON files(roster_transfer_id);
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_one_time_addresses_user ON wallet_one_time_addresses(
user_id,
chain
);
CREATE TRIGGER on_group_members_insert_update_summary
AFTER INSERT ON group_members
FOR EACH ROW
+120
View File
@@ -0,0 +1,120 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Persistence for wallet seeds and per-profile accounts.
--
-- The schema holds several seeds and binds each chat profile to one of them
-- plus its own account index. Only the single-seed case is reachable from the
-- UI: 'getOrCreateAccountRef' reuses the database's first seed and allocates the
-- next free account index.
module Simplex.Chat.Store.Wallets
( getWalletSeeds,
getWalletSeed,
createWalletSeed,
getAccountRef,
bindAccount,
getOrCreateAccountRef,
getNextAccountIndex,
)
where
import Data.ByteString (ByteString)
import Data.Int (Int64)
import Data.Maybe (listToMaybe)
import Simplex.Chat.Store.Shared (insertedRowId)
import Simplex.Chat.Types (User (..))
import Simplex.Chat.Wallet (AccountIndex, AccountRef (..), SeedId (..), WalletSeed (..))
import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.DB as DB
#if defined(dbPostgres)
import Database.PostgreSQL.Simple (Only (..))
#else
import Database.SQLite.Simple (Only (..))
#endif
toSeed :: (Int64, ByteString, Bool) -> WalletSeed
toSeed (sId, seed, backedUp) = WalletSeed {wsId = SeedId sId, wsEntropy = seed, wsBackedUp = backedUp}
getWalletSeeds :: DB.Connection -> IO [WalletSeed]
getWalletSeeds db =
map toSeed
<$> DB.query_ db "SELECT wallet_seed_id, seed, backed_up FROM wallet_seeds ORDER BY wallet_seed_id"
getWalletSeed :: DB.Connection -> SeedId -> IO (Maybe WalletSeed)
getWalletSeed db (SeedId sId) =
maybeFirstRow toSeed $
DB.query db "SELECT wallet_seed_id, seed, backed_up FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId)
-- | Insert a seed. Callers generate the entropy; this module never does, so the
-- DRG stays with the agent.
createWalletSeed :: DB.Connection -> ByteString -> IO WalletSeed
createWalletSeed db seed = do
DB.execute db "INSERT INTO wallet_seeds (seed) VALUES (?)" (Only seed)
sId <- insertedRowId db
pure WalletSeed {wsId = SeedId sId, wsEntropy = seed, wsBackedUp = False}
getAccountRef :: DB.Connection -> User -> IO (Maybe AccountRef)
getAccountRef db User {userId} = do
r <-
maybeFirstRow id $
DB.query db "SELECT wallet_seed_id, wallet_account_index FROM users WHERE user_id = ?" (Only userId)
pure $ case r of
Just (Just sId, Just ix) -> Just AccountRef {arSeedId = SeedId sId, arIndex = fromIntegral (ix :: Int64)}
_ -> Nothing
bindAccount :: DB.Connection -> User -> AccountRef -> IO ()
bindAccount db User {userId} AccountRef {arSeedId = SeedId sId, arIndex} =
DB.execute
db
"UPDATE users SET wallet_seed_id = ?, wallet_account_index = ? WHERE user_id = ?"
(sId, fromIntegral arIndex :: Int64, userId)
-- | Bind this profile to a seed, creating one from @mkSeed@ if the database has
-- none yet, and allocating the next free account index.
--
-- Single-seed by construction: it always picks the first existing seed. When
-- multiple seeds become selectable this is the one function that changes.
getOrCreateAccountRef :: DB.Connection -> User -> IO ByteString -> IO (WalletSeed, AccountRef)
getOrCreateAccountRef db user mkSeed = do
existing <- getAccountRef db user
-- Load the seed this profile is actually bound to. Picking the first row in
-- the table instead would silently re-bind a profile whenever a second seed
-- exists - which is exactly what importing a recovery key creates - throwing
-- away the imported key and moving the profile to a new account index, so
-- the names it already owned stop being derivable too.
bound <- case existing of
Just r -> fmap (\s -> (r, s)) <$> getWalletSeed db (arSeedId r)
Nothing -> pure Nothing
case bound of
Just (r, s) -> pure (s, r)
Nothing -> do
seeds <- getWalletSeeds db
s <- case listToMaybe seeds of
Just s -> pure s
Nothing -> mkSeed >>= createWalletSeed db
ix <- takeAccountIndex db (wsId s)
let r = AccountRef {arSeedId = wsId s, arIndex = ix}
bindAccount db user r
pure (s, r)
-- | Take the next account index and advance the seed's high-water mark.
--
-- The mark is stored rather than computed as @MAX(users.wallet_account_index)@,
-- because after recovery from the phrase alone the @users@ table is empty while
-- accounts @0..N@ already hold names on chain. Computing it would hand the first
-- newly created profile index 0 and, with it, a recovered account's keys.
takeAccountIndex :: DB.Connection -> SeedId -> IO AccountIndex
takeAccountIndex db sId@(SeedId sId') = do
ix <- getNextAccountIndex db sId
DB.execute db "UPDATE wallet_seeds SET next_account_index = ? WHERE wallet_seed_id = ?" (fromIntegral ix + 1 :: Int64, sId')
pure ix
getNextAccountIndex :: DB.Connection -> SeedId -> IO AccountIndex
getNextAccountIndex db (SeedId sId) =
maybe 0 (fromIntegral :: Int64 -> AccountIndex)
<$> ( maybeFirstRow fromOnly $
DB.query db "SELECT next_account_index FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId)
)
+11
View File
@@ -53,6 +53,7 @@ import Simplex.Chat.Remote.Types
import Simplex.Chat.Store (AddressSettings (..), AutoAccept (..), StoreError (..), UserContactLink (..))
import Simplex.Chat.Styled
import Simplex.Chat.Names (SimplexDomainClaim (..), claimDomain)
import Simplex.Chat.Names.Protocol (NameRegPhase (..))
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
@@ -188,6 +189,8 @@ 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)]
CRNameRegistered u nm owner expiry txHash ->
ttyUser u [plain $ "name registered: " <> nm <> " -> " <> owner <> " (expires " <> tshow expiry <> ", tx " <> safeDecodeUtf8 (strEncode txHash) <> ")"]
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
@@ -474,6 +477,14 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView}
<> maybe [] (\k -> [plain $ "signed by " <> safeDecodeUtf8 (strEncode k)]) sigKey_
<> ["request: " <> viewJSON req]
CEvtServiceReplySent (AgentConnId cId) -> [plain $ "service reply sent, connection id: " <> safeDecodeUtf8 (strEncode cId)]
CEvtNameRegistrationProgress u nm regPhase waitMs ->
ttyUser u [plain $ "name " <> nm <> ": " <> phaseText]
where
phaseText = case regPhase of
NRPhaseCommitting -> "committing"
NRPhaseCommitted -> "committed" <> maybe "" (\ms -> ". waiting " <> tshow (ms `div` 1000) <> "s before revealing") waitMs
NRPhaseRevealing -> "revealing"
NRPhaseRegistered -> "registered"
CEvtContactRequestRejected u Contact {localDisplayName = c} _reason -> ttyUser u [ttyContact c <> ": contact request rejected"]
CEvtRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci
CEvtRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci
+97
View File
@@ -0,0 +1,97 @@
{-# LANGUAGE OverloadedStrings #-}
-- | The wallet: BIP-39 seeds, and the per-chat-profile accounts 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.
-- * __wallet__ — this module: creation and derivation.
--
-- Names are a /consumer/ of the wallet, which is why this sits here rather than
-- under "Simplex.Chat.Names".
--
-- The schema allows several seeds; a profile binds to exactly one plus its own
-- account index. Only the single-seed case is reachable today.
--
-- This module is pure. Persistence lives in "Simplex.Chat.Store.Wallets".
--
-- This is the read-only subset needed to register a name: no signing, no
-- stealth keys, no recovery-phrase import or export. Those are additive —
-- the types here match the full wallet, so adding them changes no signature.
module Simplex.Chat.Wallet
( SeedId (..),
WalletSeed (..),
AccountIndex,
AccountRef (..),
WalletAccount (..),
newSeed,
deriveAccount,
accountAddress,
)
where
import Control.Concurrent.STM
import Crypto.Random (ChaChaDRG)
import Data.ByteString (ByteString)
import Data.Int (Int64)
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)
newtype SeedId = SeedId Int64
deriving (Eq, Ord, Show)
-- | BIP-44 account index within a seed. One per chat profile.
type AccountIndex = 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.
--
-- 'Show' is redacting: this is the root secret behind every name it owns.
data WalletSeed = WalletSeed
{ wsId :: SeedId,
wsEntropy :: ByteString,
wsBackedUp :: Bool
}
deriving (Eq)
instance Show WalletSeed where
show s = "WalletSeed " <> show (wsId s) <> " <redacted, backedUp=" <> show (wsBackedUp s) <> ">"
-- | What a chat profile stores: which seed, and which account index within it.
data AccountRef = AccountRef
{ arSeedId :: SeedId,
arIndex :: AccountIndex
}
deriving (Eq, Show)
-- | A derived account: the reference plus the key it resolves to.
data WalletAccount = WalletAccount
{ waRef :: AccountRef,
waKey :: S.PrivateKey
}
deriving (Eq)
instance Show WalletAccount where
show a = "WalletAccount " <> show (waRef a) <> " <redacted>"
-- | Fresh seed entropy. The caller stores it; this module never persists.
-- A 25th-word passphrase is deliberately not used — it would be a second secret
-- to back up.
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 <- 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}
-- | The Ethereum address that owns names registered by this account.
accountAddress :: WalletAccount -> Address
accountAddress = addressFromPrivateKey . waKey