/_wallet export name <k> rather than an index straight after export. The
bare form stays the seed mnemonic, and a profile's key can be added beside
the name one without the argument having to be guessed from its shape.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
Delete re-ran /_wallet, so a successful delete printed "no wallet key",
which is the same words the error uses and reads as a failure. There is
nothing to show after a delete.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
create and import differed only in where the entropy came from and already
shared one store function, so they are one command with the source named:
/_wallet create new, or /_wallet create seed=<phrase>. Naming it means no
seed is generated by typing a prefix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
A name's profile is the record it resolves to, not the key that owns it, so
the account level was carrying a mapping nothing needs. It was also the only
source of the profile to account ambiguity after a restore, of /_wallet
bind, and of the index gap and the refusal that told a visible profile an
account was held by one it cannot see. All of it goes.
Names sit in account 0 from index 1. Index 0 is left unused so neither the
names nor the profile accounts, which start at 1, claim the origin of both
dimensions. users gains no columns at all now, so the migration is one
CREATE TABLE.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
On Postgres a bare ByteString binds as a text literal, so entropy with a
backslash or a high bit was rejected and entropy with a zero byte was
truncated: the BIP-39 test vector stored as an empty seed. Blob columns go
through DB.Binary here, as every other one does.
/_wallet bind with no account moved a profile that already had one to a
fresh account, abandoning the old one without saying so. It refuses now;
moving is asked for by number.
The counter could walk past 2^31, where BIP-32 hardening folds every index
back onto a low one, so a profile handed 2147483648 derived account 0's
keys and printed account 0's path. Binding refuses once the counter is
there, and a very long index is now rejected on its digit count rather than
after reading it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
AccountRef and WalletAccount were built and never read: deriveNameKey now
returns the key, and the address comes from addressFromPrivateKey, which
already exists. SeedId is a plain Int64 like every other row id here.
The master key is derived once per command rather than once per name key,
which is where PBKDF2 runs.
A device has a key and a profile has an account, so /_wallet says "no
account for this profile" rather than repeating "no wallet key", which is
what it says when the device has none.
Also: no "wallet: " in front of "bad chat command: ", strEncode for the
address, the repeated seed lookup in one place, comments cut to what the
code does not say, and the export test pins its addresses and secrets,
including one whose first byte is zero.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
A database backed up after the key carries the key and the binding, so the
restore is all of it and an import is refused. A database backed up before
the key comes back with the profiles and no binding, so the phrase is
imported and each profile says which account was its. A key imported before
the database is restored is replaced by what the backup held.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
Create and import differed only in whether they bound a profile, which made
the convenient one inconsistent. Neither binds now, so the store keeps one
function for both, and a profile reaches an account only through bind.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
The two were one command: on a device that already had a seed, /_wallet
create only bound a profile. Now create makes the seed and refuses when one
exists, and binds every profile that exists, as a new seed has no account
that already owns a name.
/_wallet import binds nothing. Which account a profile had is what an
import is recovering, and the seed does not say, so the profile says it
with /_wallet bind <account>. /_wallet bind with no account takes the next
free one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
Restoring a chat database older than the key loses which account each
profile had, and the keys are still there, so /_wallet bind <account> sets
it by hand. The counter moves past an account bound this way, and an
account another profile holds is refused.
/_wallet now shows the paths of the active profile only, and names the
other profiles on the key. Their account indexes were the gap that showed
a hidden profile exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
A.decimal wraps into Word32 and hardened leaves anything at or above 2^31
alone, so /_wallet export 4294967296 0 returned account 0's secret under the
path m/44'/60'/0'/0/0. Handing out a different account's key for a mistyped
index is the wrong failure for this command.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
/_wallet export gives the seed mnemonic, /_wallet export <account> <name>
gives the secret of one derived key, so a single name can be handed over
without the seed. Both are what a wallet takes on import: the mnemonic as a
recovery phrase, the secret as hex.
The commands are named for what they return, APIWalletExportSeedMnemonic
and APIWalletExportDerivedSecret, as the difference is which secret leaves
the device.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
The wallet is an API, and no other API command takes a typed confirmation:
/_delete user takes the profile password. The word was also no barrier to a
caller, which can read it from /_wallet export. Confirming belongs in the
command that will wrap this one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
The key is one per device, so both commands read it directly instead of
going through the active profile's account. A profile without an account
of its own can now export, and the delete error says that the key goes for
every profile on the device. The confirmation word is lowercased, as the
phrase is when imported.
Two known limitations are written down: account indexes restart at 0 after
an import, so profiles are bound in the order they ask for a key, and the
gap left by a hidden profile in the printed indexes shows that it exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
Names commands will use the wallet; users do not. The commands move to
/_wallet, and the help section, its topic and the changelog entry are
removed. The tests drive the API end to end.
Comments cut to what the code does not say, dead exports removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
Concurrency: the account index is incremented in SQL and read back in the
same transaction, so two profiles cannot be handed the same key. Importing
a phrase is one transaction and single_seed is UNIQUE, so a phrase cannot
be discarded in favour of a key created meanwhile, and a device cannot end
up with two keys.
Wallet commands are no longer forwarded to a remote host: the recovery
phrase must not leave the device, and the raw command is logged there.
/wallet delete removes the key, confirmed by the last word of the phrase,
so creating a key before importing your own is no longer a dead end.
/wallet now shows every profile on the key with the first two name
addresses each, to check derivation against other wallets. Hidden profiles
are left out, as they are by /users. A bad phrase no longer says which word
was wrong. /wallet export uses the profile's own key.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
A name has to be owned by an address the client can still derive after a
restart or on a new device. This adds that key and nothing else.
/wallet create makes one BIP-39 key per device and one BIP-44 account per
chat profile under it, /wallet shows the address that would own the next
name that profile buys, /wallet import and /wallet export move the key with
its recovery phrase.
A name key sits at m/44'/60'/<profile>'/0/<name>, which is ordinary BIP-44,
so the phrase reaches the same addresses in other wallets. No signing, so
nothing can be bought or edited yet.
Split out of #7390 / #7425.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
* core, ui: auto-accept group invitations per user profile
Add a per-profile toggle for auto-accepting group invitations, and regroup it
with the existing contact-requests setting under a single Auto-accept section
in Privacy & Security, relabelled "Contact requests in groups".
The join is fully async. processGroupInvitation already had an async accept
path, used when the invitation matches a group link the user opened:
prepareAgentJoin + createMemberConnectionAsync + joinAgentConnectionAsync,
with the outcome reported later against the CFJoinConn command id. Auto-accept
takes that same path instead of going through APIJoinGroup, so it works while
the app is closed and never blocks message processing.
An auto-accepted invitation still records a CIRcvGroupInvitation item in the
chat with the inviting contact, so there is a record of who added the user to
which group.
Two details worth noting for review:
hostContact is reported to clients only for group links. Clients respond to it
by replacing the transient host connection view with the group and removing
that chat - correct for a group link, where the contact is a placeholder, but
wrong for a plain invitation, where it is a real contact.
A resent invitation returns the existing group, because createGroupInvitation
is idempotent on inv_queue_info. The join therefore only runs while the
membership is still GSMemInvited, so a resend cannot open a second connection.
* booldef
* order
* refactor
* update translation key
* query plans
* ios: export translations
---------
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
* directory: identify registration owner by member id to fix incorrect de-listing
The leave/removed/role-changed handlers identified the registration owner by
contact id. A non-owner member can be associated with the owner's contact (via
the contact/member merge), so its departure incorrectly de-listed the group.
Compare by group member id (owner_member_id) instead, falling back to contact
id for registrations recorded before owner_member_id existed.
* fix test, always compare contact ID in owner check
---------
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
* directory: only create group links after approval
* update test
* update messages
* diff
* get group and link in one query
* reduce database reads
* better errors
* typos
* query plans
---------
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Messages of blocked members are hidden, but they still marked chat item
as user mention, showing mention badge and counting group as unread in
"mentions only" notification mode:
- createNonLive passed mentions unfiltered, allowing members blocked by
admin to mention user - it is the default path, as it is only used
when full delete is not allowed;
- userReply was set for replies to user messages from blocked members.
* core: remove SimpleX Status preset contact
Preset contact cards are only created at user record creation
(createPresetContactCards), so this affects new profiles only;
existing profiles keep their stored SimpleX Status contact.
Removing the card shifts contact ids allocated after /create user
down by one, hence the test id updates.
* plans: justify SimpleX Status preset contact removal
* core: don't mark member support chat items read when reading group without scope
Reading a group without a scope marked support-scope items read without
decrementing the per-member support_chat_items_* counters, so members stayed
unread in the support list even after their chat was fully read. Restrict the
no-scope group read and its timed-items query to main-scope items.
* plans: support chat unread on no-scope group read
* core: update query plans for group scope read
The main-scope read and timed-items queries now filter on group_scope_tag
and group_scope_group_member_id, so they seek via idx_chat_items_group_scope_stats_all
(5-column) instead of idx_chat_items_groups_user_mention (3-column).
* plans: document query-plan and benchmark performance results
* tests: fix unreliable support item id capture in no-scope group read test
lastItemId returns the latest item by item_ts, which right after createGroup2
can be the group "connected" event rather than the just-sent support message.
The per-item read then targeted the wrong (already-read, main-scope) item and
never decremented the support counters, so the test failed regardless of the
fix (consistently in CI, flakily locally depending on item ordering).
Capture the support item id directly from the member-support scope instead,
keeping the change contained to this test. Verified: the test passes with the
fix and fails when the fix is reverted.
* tests: fix name-shadowing build error in no-scope group read test
The local pattern binding `itemId` shadowed the `itemId` helper imported
from ChatTests.Utils, which -Wname-shadowing (Werror) rejects. Rename the
local binding to `iId`.
* core, ui: plan per-server roles for self-hosted servers
* core: add per-server roles field to UserServer
* core: add nullable role columns to protocol_servers
* core: persist per-server roles
* core: validate server coverage using per-server roles
* test: cover per-server roles resolution and coverage
* multiplatform: per-server role toggles for self-hosted servers
* ios: per-server role toggles for self-hosted servers
* core: per-server role overrides with per-role defaults
* test: cover three-state per-server role resolution
* fix: derive Eq for ServerRolesOverride
* multiplatform: three-state role dropdowns on saved servers
* ios: three-state role pickers on saved servers
* test: per-server roles independent across two servers
* test: enable names role in name-resolution tests
* style: trim comments in per-server roles code
* chore: rename server_roles migration to 20260716 (last)
* core: per-server roles override operator roles, inherit when unset
* multiplatform: per-server role default inherits from operator
* ios: per-server role default inherits from operator
* refactor(servers): tidy per-server roles per review
- dedup no-operator default into ServerRoles.noOperatorDefault (Kotlin/Swift)
- iOS: move roles-section control flow to the call site via a named gate,
and parse the server address once instead of up to three times
- Kotlin: collapse redundant derivedState; revert defaultOn->default rename
for cross-platform parity
- drop unused Hashable conformance on Swift ServerRoles
- add agentServerCfgs test for names inheritance from an operator
- remove no-op enableNamesRole calls from dormant DirectoryTests
- fix ChatClient import ordering
* chore(migration): date server_roles migration 20260720
* only show roles when server is enabled, move section above QR code
---------
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
* cli: add --relay-address-server option for chat relay
New CLI flag --relay-address-server SERVER selects the SMP server used
for the chat relay address link created at startup. Only valid together
with --relay; errors out otherwise.
Threads Maybe SMPServerWithAuth through APICreateMyAddress to the new
agent createConnection parameter.
* cli: add --user-display-name option
Selects or creates the active user non-interactively:
- no active user: create one with the given display name
- active user with matching localDisplayName: continue
- active user with different name: exit with error
Mutually exclusive with --create-bot-display-name.
* cli: add --user-image-file option
Sets the active user's profile image from a .png/.jpg/.jpeg file at
startup. Reads file, base64-encodes as data URL, and updates the user
profile directly in the DB - no notification is sent to existing
contacts. Skips the update if the stored image already matches.
Requires --user-display-name.
* cli: address PR review comments
- rename APICreateMyAddress field srv_ to server_
- extract repeated `loop` and putStrLn from createActiveUser via
prompt where-clause
- fuse u_ inspection: validate active user display name in the same
case that creates the user when missing
* cli: enforce profile image size limit in --user-image-file
Reject the file if the encoded data URL exceeds 12500 bytes - matches
the cap mobile and desktop UIs pass to resizeImageToStrSize for profile
images. Without this, oversized images would be silently set on the
user profile.
* core: validate profile image size in chat commands
Enforced in CreateActiveUser, updateProfile_, newGroup, runUpdateGroupProfile
via checkProfileImageSize; max 12500 bytes (matches mobile UIs).
* fix: thread new ChatOpts/CoreChatOpts fields through bot/test constructors
* bots/docs: filter hidden params before type introspection
Hide APICreateMyAddress server_ field so the bot doc generator does not
try to introspect SMPServerWithAuth (an unregistered type).
* core: validate full encoded profile size
Add checkProfileSize / checkGroupProfileSize that encode the full
ChatMessage and check against maxEncodedInfoLength, so a long
displayName/bio combined with a near-max image is also caught at
command time instead of failing later at send time with CEException.
Run alongside the existing checkProfileImageSize (image-only cap of
12500 bytes, matching mobile UIs) in CreateActiveUser, updateProfile_,
newGroup, runUpdateGroupProfile. Update genProfileImg to fit the cap.
* cli: add --headless option for chat relay
Skips interactive prompts (relay address creation, display name) so the
chat relay can run non-interactively as a service. Requires --relay;
creating a new profile also requires --user-display-name.
* test: cover profile image size limit and address server
Adds tests for two new capabilities:
- profile image size validation rejects oversized images
- /_address with a server pins the address to the requested SMP server
* core: update simplexmq (pass optional SMP server to prepareConnectionLink)
* cli: add /set profile image file, fix image flag
Add "/set profile image file <path>" command to set the profile image
from a .png/.jpg/.jpeg file in a running session.
Make --user-image-file create-only: for an existing user it now no-ops
with a note instead of failing with "chat not started" (the update ran
before the chat controller was started).
* core: unify image loading and profile size checks
- loadImageFile (CLI) and readProfileImageFile (command) now share one
loadImageData; removes the duplicated mime/base64 encoding and its
decodeUtf8/safeDecodeUtf8 divergence. A missing --user-image-file no
longer crashes with an uncaught IOException, and empty files are rejected.
- checkProfileSize/checkGroupProfileSize share checkInfoSize.
- --relay-address-server/--headless requires-relay checks use
errorWithoutStackTrace, matching the adjacent validation (no callstack dump).
* bots/docs: document UpdateProfileImageFromFile as a CLI command
* core, web: correctly process name verification for channels, show names in directory
* remove duplicate verification
* remove plan
* refactor
* remove comments
* stabilize tests
---------
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>