Restoring a phrase now scans for the account numbers it already uses,
instead of opening account 1 and leaving the rest to be guessed at. Each
number is derived under both schemes, the chain is asked in one request
which of those addresses exist (a System::Account entry is there for an
account that has ever been funded), and the scan carries on while a batch
held any, stopping after a gap of 20 empty numbers, as the mobile
wallet's import does. Numbers found are opened and remembered like a
hand-added one, so they come back on the next unlock. The same scan is a
"Find accounts" button on every open wallet with a phrase, and it says
what it found. A wallet with no phrase, or a chain that is not connected,
offers no scan; a failure during restore is swallowed, since the wallet
is open either way and the button retries.
On the dev node: 7 DEV sent from the CLI to account 3 of the dev phrase,
then that phrase restored here. Without pressing anything the wallet
opened accounts 1, 2 and 3 under both schemes, account 3 showing the
7 DEV, after looking at 40 numbers; settings recorded 1 and 2. On a
freshly created phrase, Find accounts looked at 20 numbers, added
nothing, and said so.
Closes#69
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
"Add account" on a wallet that carries a phrase derives the next account
number under both schemes, numbered as Quantus's clients number them
(m/44'/189189'/<account>'/0'/<scheme>', the mobile SDK's pathForIndex),
opens them at once, and remembers the number in settings under the
wallet's name; the next unlock re-derives them from the phrase. The
keystore file stays quantus-cli's single keypair, so nothing about the
added accounts is written there, and nothing secret is written anywhere.
Accounts show "Account N" wherever they are listed or picked unless the
person named them. A single-key wallet offers no Add account and the
command refuses it by name. settings_set keeps the stored account
numbers, so a save from the webview's cached settings cannot erase one
added since.
Pinned against quantus-cli 2.2.2 imports of the dev phrase at
--derivation-path: account 1 is qzm41eg2… (ML-DSA-65) and qzn2nAa4…
(ML-DSA-87), account 2 is qzohziq6… (ML-DSA-65). Driven on the dev node:
restore, Add account showed those two addresses as Account 2; after a
restart and unlock they were still there; 4 DEV from crystal_alice to
Account 2 and 1 DEV back from it, both submitted.
Closes#67
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
No scheme choice anywhere. Create and restore no longer ask for ML-DSA-65
or ML-DSA-87, and the commands behind them take no scheme. A wallet that
carries a phrase opens with an account under each scheme at its
conventional path, ML-DSA-65 first and ML-DSA-87 labelled legacy, plus
the file's own keypair when it sits at another path, de-duplicated by
account id. A raw key such as crystal_alice keeps its one account. The
default follows Quantus's own clients: quantus-cli's --scheme default,
the mobile SDK's DilithiumSchemeExtension.current and the extension's
DEFAULT_TYPE are all ML-DSA-65, and the extension already shows both
accounts with no key type to choose. The keystore file stays
quantus-cli's and records the ML-DSA-65 keypair; the rest is derived on
every open.
Any number of wallets open at once. The session holds a list of open
wallets, each with its accounts and seed; unlocking adds one, locking
takes a wallet name or locks all, signing finds the account in
whichever wallet holds it, and one idle clock locks them all. Session
status lists the wallets and a flat account list where every account
names its wallet. The sidebar lists open wallets with a lock each, an
"open another" page and "lock all"; accounts groups rows by wallet;
send, receive and history pick from every account of every open wallet,
grouped by wallet and labelled by scheme; the wormhole panel is per
wallet.
Driven on the dev node: crystal_alice open, the public dev phrase
restored beside it with no scheme field, and its two accounts appeared,
qzq29m9… (ML-DSA-65) and qzjrYTUnn… (ML-DSA-87, legacy), the addresses
quantus-cli lists for that phrase. 5 DEV from crystal_alice to the
legacy account, 2 DEV from it to its ML-DSA-65 sibling, both in blocks.
Locking devphrase left crystal_alice signing while a transfer from
devphrase's account was refused; lock all returned to the lock screen.
Unit tests cover both-schemes derivation for every CLI fixture,
side-by-side wallets, per-wallet lock and the shared idle rule; the
secret-leak test passes with both signers held.
Closes#65Closes#66
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
wallet_data::ramp::StubRamp builds a session URL on our own host from
the request (direction, asset, destination, currency) and nothing else;
it is what proves the plumbing before a provider is signed (#44). The
ramp_session command decodes the address, builds the request, refuses a
URL that is not https, and hands the URL back; the receive screen's Buy
and Sell buttons pass it to the opener, whose capability allows
blackbeard.observer and the explorer and nothing else. The wallet's
webview never navigates: driven live, the client returned
https://blackbeard.observer/ramp?direction=buy&asset=dev%2Fnative&…,
Buy handed it to the opener without error, and the webview stayed on
#/receive.
doc/reproducibility.md records what the build pins and what a second
build of the same tag is expected to differ in; the release workflow
exports SOURCE_DATE_EPOCH from the tagged commit. The diffoscope
comparison waits on the first tag.
Closes#43
Refs #51
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
wallet_core::orders is the pure part: Quoted → AwaitingDeposit →
Processing → Success | Refunded | Failed, with Expired when a quote's
deadline passes before a deposit is seen; it never moves backwards, a
quote past its deadline expires without a network call, and the poll
delay doubles from ten seconds to five minutes (five seconds while
processing). Orders persist through an OrderStore port on the same
SQLite file; on start the app follows every order still open, and the
follower asks the provider with backoff and jitter, persists each move
and emits orders:updated.
Commands: swap_assets (what 1Click lists and the registry maps),
swap_preview (a dry quote), swap_order_create (a wet quote persisted
with its deposit address and deadline) and orders_list. The swap screen
sits behind a preview setting, says plainly that every pair today is
paid from an external wallet, quotes, shows the deposit address with a
QR, memo and deadline, and lists orders with their states.
Driven live on the dev network: BTC on bitcoin to USDC on near, 0.001
BTC quoted at 75.568313 USDC, at least 74.812629, about 805 s. Reserving
a deposit address failed with 1Click's "Failed to get quote": a wet
quote needs the partner credential the relay will hold (#40), which is
the operator's registration. Until then the screen previews and the
poller has nothing to follow; the state machine and the store are
covered by unit tests, including the mid-order restart the store makes
possible.
Refs #39
Refs #41
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
tauri-plugin-updater with a minisign public key in tauri.conf.json (the
private key and its password are repository secrets, generated for this
and never in the repo), the manifest endpoint on the same host as the dnf
repo, and updater:default plus process:allow-restart granted with their
reasons. app_info now says how the binary was installed; the rpm and deb
opt out because dnf and apt own their updates, the AppImage, NSIS and dmg
opt in. The shell shows an offer three seconds after settling, with the
two versions, and applies it only on "Update and restart". The release
workflow signs the bundles (createUpdaterArtifacts), writes latest.json
for linux-x86_64 pointing at the release asset with its signature, and
the publish job rsyncs it to rpm.lair.cafe last, so a manifest never
names an asset that is not yet released. Windows and macOS entries come
with #34; the run-N-then-N+1 check waits on two published releases.
The secret handling audit is a test, secrets_do_not_leak.rs: create a
wallet from a known phrase, open it, unlock, sign, derive the wormhole
keys, use both caches, lock, all under trace logging into a sink; then
assert the log, every file in the data directory and the Debug output of
every secret-holding type contain none of the words, the seed, the
secret key or the wormhole secret, as hex, as raw bytes or as the decimal
list a derived Debug prints. OpenedWallet and Session gain redacting
Debug impls so the test can log them. The sample of the secret key
starts inside K: an ML-DSA secret key opens with rho, which is also the
public key's first 32 bytes, and the first draft of this test flagged the
public key for it.
Closes#50
Refs #35
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
keys/wormhole.rs derives the wormhole keys from the seed at the wormhole
coin type (m/44'/189189189'/i'/0'/0'), pinned to what quantus-node's
`key quantus --scheme wormhole` prints for the public dev phrase at
index 0 and 1, and derives a miner's address from its published inner
hash (bob's, as the observer shows it). The nullifier that spends a leaf
is mirrored on the Poseidon core alone, pinned to four vectors produced
once with the circuit crate, so the wallet does not carry the prover to
know what it has spent.
substrate/wormhole.rs reads ZkTree::LeafCount, scans ZkTree::Leaves in
pages of 500 locally computed keys for leaves paying an address, and
checks Wormhole::UsedNullifiers. Nothing on chain indexes leaves by
recipient, so the scan runs once from zero and then only over what was
appended; the SQLite cache keeps the leaves and the frontier under a
ShieldedCache port. The wormhole_summary command derives the keys from
the session's seed for the call, scans, marks spent leaves, and answers
with the address, the inner hash and the shielded and received totals;
the accounts page shows it with copy and rescan. A raw-seed wallet with
no seed is told so rather than shown a fake address.
Verified on mainnet through the public endpoint: a full scan of 107,127
leaves in 16 s found 31 leaves paying bob's wormhole address for
9.34 QTC, the observer's 30-reward attribution of 9.33 plus the one
since; the dev phrase restored in the app shows
qzq32vN2ZtX2T9YrvCh69CMvbC2nWmEq4tCjRWKg4CyuxeShg and inner hash
0x5a5891e7…, the node's own numbers, with the tree scanned to its end.
doc/threat-model.md names the assets, the boundaries and the file behind
each mitigation; its review by someone else is what closes#48.
Closes#45
Refs #48
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
An "Other networks" table under the accounts: every profile the settings
allow other than the selected one, each connected on demand and
following heads like the main table, with the account's spendable and
total balance there and the chain's state. One seed gives the same
address on every Quantus-family chain (they share coin type 189189), and
the table says so rather than pretending they are different accounts.
Run on the dev network with testnets shown: Quantus mainnet through
wss://quantus.blackbeard.observer at block 54032 and Planck at 1111122
listed beside the dev chain, both at zero for crystal_alice; a Polkadot
address in the send form refused as "a Polkadot address, not a Local dev
node address".
Closes#37
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
History comes from the observer's account activity route, chosen over a
relay because the observer already indexes every chain with a profile
and serves per-account activity keyset-paginated (recorded on #23). A
HistorySource port in wallet-core with an observer adapter in wallet-data
reads it; the rows a person looks for are typed (transfers in and out,
wormhole exits, reversible schedule, cancel and execute, and calls the
account signed) and the rest dropped, while the cursor always advances
over the raw rows. The wormhole pallet mirrors every native transfer as
NativeTransferred, so only rows with the wormhole's own account as a
party count as wormhole movements; the mirrors would otherwise list each
send twice. A saved response pins the parser.
A HistoryCache port with a rusqlite adapter keeps what the indexer said
under <data>/history.sqlite, keyed by chain, account and position, with
a per-account record of when the indexer last answered and how far it
had read. The history_page command stores as it fetches and, when the
indexer cannot be reached, serves the cache with from_cache set and the
reason attached.
Profiles carry the indexer URL (none for the dev node, and the screen
says so). The history screen pages with "Older", refreshes, shows an
offline chip with the cache's age, and opens the account on the public
explorer through tauri-plugin-opener, granted open-url for those two
hosts only, with the reason in the capability file.
Verified on mainnet: two pages of 45 and 46 rows for an active account
through the client, the cache file written; with the indexer overridden
to a dead address, the same call served 50 cached rows marked offline
with the error and the last fetch time, and the screen showed
"offline: cached 1 min ago". An unknown account reads as "Nothing yet".
The observer's ignored live test reads two pages.
Closes#23Closes#30
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
The accounts page lists PendingTransfersBySender for every account of the
open wallet, refreshed every five seconds, with when each executes: at a
block, counted down at the block interval the chain manager now measures
over its last 32 best heads (exposed as ChainStatus.block_interval_ms,
with the profile's nominal time as the fallback and labelled as such), or
at a timestamp, counted down by the clock. Cancel prepares
ReversibleTransfers.cancel and goes through the same review pane as a
send, which moves out of the send route into ReviewAndSign.
The call decoder now renders a 32-byte argument by its metadata type: an
AccountId32 as SS58, anything else as hex. Before, a cancel's tx_id was
dressed up as an address. Unit tests cover both the interval and the
rendering; the remaining Option<u64> fields are typed `number | null`.
On the dev node: a 2 DEV transfer with a 60-block window listed at block
314 with "49 blocks, about 52 s" against a measured 1.1 s interval; a
3 DEV transfer with a 1000-block window cancelled through review, the
tx_id shown as hex, and the balance back up by 3 less the fee. Block 314
arrived 14 s after the estimate: the dev chain's interval swung between
0.6 and 1.7 s per ten blocks over that minute. See the issue for what
that means for the acceptance criterion.
Closes#29
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
The form collects text. Review shows the call decoded from the bytes
that will be signed, from PreparedTransferInfo: pallet and call, each
argument as Rust rendered it, the fee, the mortality and the nonce.
Submit hands back only the prepared id, so nothing the form holds can
reach the signature; Back discards the prepared transaction on the Rust
side. A cancellation window is offered as presets or a custom block
count, described in the chain's nominal block time, and hidden for a
high-security account whose delay is fixed. After submit the tx:status
events drive a stage list until finalized, failed or dropped.
Event subscriptions now go through one helper that copes with `listen`
resolving after the effect is torn down, which under StrictMode left a
second listener alive and showed every stage twice. Optional u64 fields
are typed `number | null` rather than `number`.
On the dev node, from crystal_alice: 3 DEV reviewed as
Balances.transfer_keep_alive with the recipient and "3 DEV", signed and
in a block; 2 DEV with a 200-block window reviewed as
ReversibleTransfers.schedule_transfer_with_delay, in a block, and then
listed by reversible_pending with cancellable_by_me; a prefix-42 address
refused at prepare with the cross-chain message; 5,000,000 DEV signed
and reported Failed: Arithmetic(Underflow).
Closes#28
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
The receive screen shows one account's address as text, as a QR (drawn
in the webview by qrcode-generator, since an address is public), and as
the five-word checkphrase Rust derives, with a copy button through
tauri-plugin-clipboard-manager granted only write-text, with its reason.
Accounts can be named in place; the labels live in settings under
account_names, keyed by address, and show on the receive screen too.
Checked against the dev node: the checkphrase for crystal_alice reads
Toast-Terrific-Kitten-Warrior-Churn, the same as `quantus wallet list`;
a `quantus send` of 7 DEV from crystal_bob raised the accounts row from
99,957.786510548 to 99,964.786510548 within the next head; Copy flips
to Copied; a rename is written to settings.json and survives a lock.
Closes#27
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
Three routes under /onboarding, reachable from the lock screen and from
the shell, since a second wallet is the same flow as the first. Create
picks a scheme (ML-DSA-65 by default), shows the 24 words once, and
stands the backup challenge between seeing them and having a wallet; a
failed challenge drops the phrase on the Rust side and starts over.
Restore takes a phrase and a scheme, with a note that the same phrase
gives a different account under each. Import opens a native file chooser
through tauri-plugin-dialog, the first plugin, granted only
dialog:allow-open with its reason in the capability file; the path goes
to Rust, which reads the file. Every path ends unlocked on the accounts
page.
Driven through the WebKit inspector against the dev node: create wrote
onboard_test.json and unlocked; restoring the same phrase under a new
name produced the same address; importing a fixture file with the right
password opened it and with a wrong one mapped to wrong_password. The
chooser itself is a Wayland window nothing here can type into, so the
permission is verified by the open promise staying pending rather than
rejecting; CLAUDE.md and the inspector script now say so.
Closes#26
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
The frame every screen sits in. A lock screen fronts everything while the
session is locked: pick a wallet file, type a password, and the password
goes to Rust and nowhere else. Unlocked, a sidebar carries the sections,
the network switcher and a lock button with the minutes until the idle rule
fires; a status bar shows the chain in use, its endpoint, best and finalized
heights and spec version, and says "connecting…" or the last error
otherwise. Testnets are labelled in the switcher, the status bar and above
their content, as on the observer: a balance on a testnet is not money and
the chrome says so before the number does.
Settings persist as JSON in the app-data directory, written atomically:
network, idle lock, whether testnets are offered. Changing the network
re-keys every query; changing the idle lock takes effect on the next check.
The theme (system, dark, light) is a local preference applied before first
paint from the entry point, because the CSP allows no inline script.
Design tokens are the observer's, lifted whole so the wallet and the
scoreboard read as one family. The accounts page shows spendable and total
per account with the block the numbers were read at and how far that is
from final, refreshed by Rust on every head; send, receive and history are
placeholders naming their issues.
Wide integers now cross the IPC typed as `number`: they are JSON numbers on
the wire, which `bigint` cannot be serialised to, and none of them (block
heights, seconds, nonces) approaches 2^53. Amounts still travel as strings.
Closes#25
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
`SubstrateAdapter` implements the chain-adapter port for Quantus. `prepare`
builds the call from a chain-neutral intent, decodes it back from its own
bytes through the runtime metadata into a summary a person can check (a
32-byte account renders as the chain's SS58 address, an amount in the
token's units), prices it by assembling a full extrinsic with a zeroed
signature of the right size and asking `TransactionPaymentApi_query_info`,
and returns the signer payload for the session to sign. `submit` refuses
if the runtime spec moved or the payload no longer matches, assembles with
the session's signature, and streams statuses; a dispatch error is reported
at inclusion, named `Pallet::Variant` where the chain names it, so nobody
waits for finality to learn a transfer failed.
Reversible transfers: `reversible_for` routes through
`ReversibleTransfers::schedule_transfer_with_delay`; a high-security
account is routed through the pallet's default-delay call whether it asked
or not, since the chain's extension refuses a plain transfer from one.
`pending_reversible` lists what is waiting with the execution block from
the scheduler's lookup, whose task name is the tx id itself, and
`CancelReversible` cancels.
Three things the dev node taught, each now written into the code:
- Mortality must be anchored at the *best* block. Finality on this
proof-of-work chain trails the tip by a hundred blocks, and an era born
at the finalized head names a birth block whose hash the runtime resolves
differently: "Transaction has a bad signature", for both schemes, while
immortal transactions from the same keys passed. Probed all four
scheme-by-era combinations to isolate it.
- Everything the UI shows is read at the best block, with the block number
and the finalized height alongside so confirmations can be shown.
subxt's `at_latest()` is the finalized block; the pending list read there
showed the state of a hundred blocks ago while the chain's events showed
every schedule landing.
- subxt's runtime-update subscription died on this node with "header not
found" after a reorg and took the connection with it. It is advisory
now: on error the runtime version is polled every ten heads and a change
forces a reconnect, which refetches metadata.
Live, against `quantus-node --dev` with the funded dev accounts: a 3 DEV
transfer from crystal_alice to crystal_bob is included and moves bob's
balance; a reversible transfer with a 200-block window is scheduled, shows
as pending with its execution block, and is cancelled; an overspend fails
as `Token(FundsUnavailable)`. Fee for a plain transfer: 0.010157927 DEV.
In the app: `transfer_prepare` (amount parsed from its decimal form in
Rust), `transfer_submit` (signs through the session, emits `tx:status`),
`transfer_discard`, `reversible_pending`, `reversible_cancel_prepare`.
Closes#22
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
`account_info` reads `System::Account` and
`ReversibleTransfers::HighSecurityAccounts` at the latest finalized block
through subxt's dynamic storage API, so a number the UI shows cannot be
reorganised away and no generated code has to track the runtime. An account
the chain has never seen is zero and nonce zero, not an error; the live
test reads the dev phrase's account on Planck and gets exactly that. The
next nonce comes from `system_accountNextIndex`, which counts the pool.
Spendable is free less frozen; total is free plus reserved. A high-security
account carries its guardian and its delay, in blocks or milliseconds as
the pallet stores it, so the send path (#22) can route it through the
reversible-transfers pallet instead of a plain transfer the extension
would reject.
The connection manager now publishes finalized heads on a watch channel.
`balances_watch` spawns one task per chain that re-reads every open
account on each head and emits `balances:updated`; `balances` reads once.
Both refuse with `Locked` when no wallet is open.
Closes#21
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
`ChainManager::start(profile)` spawns a supervisor that tries the profile's
endpoints in order, refuses a node whose genesis is not the profile's (by
name, so a typo in an override file cannot quietly sign for the wrong
chain), records the runtime version at connect, follows finalized heads,
applies subxt's runtime updates as the chain upgrades, and on any failure
rotates to the next endpoint after an exponential backoff with full jitter,
capped at a minute. A head that fails to arrive for two minutes counts as
a failure. `status()` reports the endpoint in use, spec and transaction
version, finalized height, head age, the last error and the failure count.
Backoff is a pure type with the schedule pinned in tests. The live test
gives Planck's profile a dead first endpoint and the real second one: it
connects to the second at spec 148 with zero failures, and mainnet's
profile pointed at Planck's node is refused with "is not quantus".
Supervisors start on demand from `chain_connect`, never at boot, because a
profile whose endpoint does not exist yet (#53) would otherwise retry
forever for nobody. `chain_status` reads without starting anything.
Not yet exercised: a stream dropping mid-session and a runtime upgrade
arriving while connected. Both go through the same loop and will be
driven on the dev node in #24.
Closes#20
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
`wallet-core/src/profiles.toml` carries what runtime metadata does not:
genesis hash, SS58 prefix, symbol and decimals, coin type, the endpoint
list, block time, and the rule for when the FIPS 204 signing context
applies. Three profiles ship: Quantus mainnet at spec 152 with QTC, Planck
with PLK (in the wallet only to prove the multi-chain UX), and a local dev
node with an empty genesis hash that means "do not check". Endpoints are
the names decided on #20 and #53, one per chain; until those exist an
override file points a dev run at the public nodes.
The override file is the same shape with every field optional: a known id
has fields replaced, an unknown id must be complete and is appended. A
mainnet endpoint must be wss, a genesis hash must be 66 characters or
empty, a context at most 255 bytes; anything else is refused with the
profile named. Adding a fourth chain is a TOML entry and no Rust, which the
test does.
`signing_context_for(profile, spec)` and `genesis_matches(profile, hash)`
are the two pure functions the adapter (#19, #20) will call at connect.
The app loads `<app data>/profiles.toml` or WALLET_PROFILES and exposes
`chains_list`.
Closes#18
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
`wallet_core::session::Session` is the only place decrypted key material
lives between commands: one wallet open at a time, its signers and, when
the file carried a phrase, its seed. Every signature goes through
`Session::sign`, which applies the idle rule before it looks at the key, so
a stale session refuses rather than signing one last time. Time is passed
in, so the rule is tested at 59, 118 and 119 seconds without waiting.
The seed is now a heap allocation behind a wiping box. Moving an inline
64-byte array through `Some(...)` and a function argument can leave copies
in dead stack frames that no destructor visits; moving a box moves a
pointer. A Linux test unlocks with a runtime-built seed and a signer whose
secret is a second runtime-built pattern, locks, then reads every writable
anonymous mapping in /proc/self/mem and asserts neither pattern is anywhere
in the process. It passes in debug and release. The comparison copies are
XOR-masked so the test cannot find itself.
In the app: `unlock` opens a file, turns its phrase into a seed and drops
the phrase; `lock` and `session_status` do what they say; a ticker thread
applies the idle rule every five seconds and emits `session:locked` so the
lock screen appears without a click. Ten minutes until settings exist.
Closes#17
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
A new wallet's phrase is 24 words from OS entropy through the hdwallet
crate, which wipes both the entropy and the phrase's own working buffers.
Validation runs the same pinned bip39 the hdwallet crate stretches with, so
a phrase that validates here derives there; it normalises case and
whitespace and rejects the wrong count, an unknown word, and a bad checksum
with an error a person can act on.
The checkphrase is Quantus's human-readable address checksum through
qp-human-checkphrase, with the 2048-word list vendored because the crate
loads it from the working directory at runtime, which a desktop app cannot
rely on. The value for the dev phrase's ML-DSA-65 account is pinned to what
`quantus wallet view` prints.
Creation is three commands so the webview sees a phrase exactly once:
begin generates and holds it and returns the words with a four-word backup
challenge; confirm passes the challenge or refuses, then derives at the
scheme's conventional path and writes the file; cancel forgets. No command
returns a phrase that already exists. Import from a phrase and from a CLI
file, and listing, are the other commands. App state is a mutex around the
pending creation; the keystore directory is the platform app-data
directory unless WALLET_KEYSTORE_DIR says otherwise.
Closes#16
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
`wallet_data::keystore` is the CLI's format: AES-256-GCM over the JSON of
the wallet data, keyed by Argon2id under the frozen profile (m=19456, t=2,
p=1) and a 16-byte salt, with the PHC string carrying the profile and salt
and never the digest. A file whose PHC string does embed a digest is refused,
as the CLI refuses it. On open the address is re-derived from the decrypted
key and the file refused if it differs, so a file that lies about its
address cannot be opened, let alone signed with. Legacy files with no
`scheme` are ML-DSA-87; files with no `wallet_type` are hot.
The directory rules are the CLI's too: a wallet name is a plain filename,
writes go through an unpredictable temporary file and a no-clobber rename
at mode 0600, and a symlink where a wallet should be is refused.
Proof in both directions. Eight real files open here: six written by
quantus-cli 2.2.2 for the dev phrase, one per scheme and path, and the two
fixtures the extension fork tests against. A file written here lists in
`quantus wallet list` with the expected address (ignored test, run with
QUANTUS_CLI set). A test greps the written file for phrase words and secret
key bytes and finds neither.
`Keystore` is a port in wallet-core: list, open, create, import a file,
export a file. Opening yields a `Signer` and, when the file carries one, the
phrase in a wiping holder; neither leaves Rust.
Closes#15
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
Commands now return `Result<T, WalletError>`; Tauri rejects the promise with
the serialised error, and `ui/src/api/wallet.ts` is the one place that turns
it back into a thrown `WalletError` with the same `kind` the Rust enum has.
Anything that is not a `WalletError` is a bug on one side of the boundary and
is rethrown untouched so it is loud.
Screens never call `invoke` themselves. React Query holds what Rust knows,
with retries off (a wallet must not silently retry a command that failed)
and refetch-on-focus off. CI counts the files importing Tauri's IPC module
and fails unless the count is exactly one.
The first command is `app_info`, so the window shows a version that came
across the boundary as a typed struct rather than a string.
Closes#13
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
The house shape (architecture/generic.md §1): wallet-entities for the
vocabulary and the IPC wire types, wallet-core for the four ports that are the
architecture (KeyScheme, ChainAdapter, SwapProvider, FiatRamp), wallet-data
for the adapters that will implement them, and wallet-app as a Tauri 2 shell
whose commands are one-line shims. The webview is Vite + React + TypeScript
and reaches Rust only through ui/src/api/wallet.ts, typed by what ts-rs
exports from the entities crate on every `cargo test`.
Amounts are integer base units plus decimals, serialised as strings, because
the webview has no integer wide enough and a wallet that rounds is a wallet
that lies. The webview never receives a secret; readme.md and CLAUDE.md say
why and what will bite.
Two things the scaffold learned: Tauri's `version` field takes a JSON file or
a literal, never Cargo.toml, so the release workflow stamps it; and Prettier
must skip the generated types, which carry ts-rs's own style.
Placeholder icon; the mark comes later. CI targets the rust-gtk3 runner.
Refs #1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW