27 Commits
Author SHA1 Message Date
grenadeandClaude Fable 5.1 8cfd88b39b chore(release): 0.4.1
ci / gate (push) Successful in 12m21s
release / linux (push) Successful in 14m58s
release / package (44) (push) Successful in 39s
release / publish (44) (push) Successful in 17s
One password unlocks every wallet (#84). 0.4.0 shipped from 038f097
before that landed: its tag run had never started, the retry I pushed
could not be stopped once the operator asked to hold the release, and
a published version is never rebuilt with other contents.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-12 11:24:22 +03:00
grenadeandClaude Fable 5.1 038f09774f chore(release): 0.4.0
ci / gate (push) Successful in 12m16s
release / package (44) (push) Canceled after 0s
release / publish (44) (push) Canceled after 0s
release / linux (push) Canceled after 2m56s
The NEAR Intents account (#71): one per installation, created by the
sponsor blackbeard-observer.near and controlled by one wallet's
ML-DSA-65 key derived from its phrase (NEP-649). The wallet still holds
no classical key. From the Accounts page: QTC in over the bridge from
any account, swaps inside Intents on a Ref pool or 1Click with the
sponsor's 30 bps fee, cash-out to USDC/USDT/ETH/BTC on Ethereum, Base,
Arbitrum, Solana, Bitcoin or NEAR, and QTC back to Quantus. The sponsor
relays every NEAR leg and pays its gas (#73, #74, #75, #76, #77, #81,
#82).

Also: the version in the sidebar links to its release (#70); the
wormhole scan is one paced pass for every open wallet, under the node's
rate limit; QTC maps to nep141:qtc.omft.near in the asset registry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-10 00:38:51 +03:00
grenadeandClaude Opus 5.5 4f3b446cc3 feat(near): cash out from the intents account to an address on another chain
ci / gate (push) Successful in 13m0s
The intents panel can now pay a token it holds out through 1Click (#77):
USDC on Ethereum, Base, Arbitrum, Solana or NEAR; USDT or ETH on
Ethereum; BTC.

- cashout.toml ships the destinations with their 1Click asset ids,
  checked against GET /v0/tokens today, and the address form each
  takes.
- check_address refuses a mistyped payout address before anything is
  priced or signed, because nothing refunds a payout to the wrong
  address:
  - EVM: 0x and 40 hex digits, with the EIP-55 checksum enforced when
    the address is mixed case;
  - Bitcoin: bech32 or base58;
  - Solana: base58 of 32 bytes;
  - NEAR: an account id.
- near_cashout takes a firm 1Click quote with recipientType
  DESTINATION_CHAIN, the fee as appFees, and refunds to the account. It
  checks the echoed recipient, its type, the refund, the assets and the
  amount against what was asked, funds it with one mt_transfer signed by
  the owning wallet's key, and has the sponsor relay it.
- The 1Click request builder now serves swaps and cash-outs: None pays
  the account itself, Some(address) pays out on the destination chain.

Checked live from the dev account:
- 10 wNEAR quoted at 46.38 USDC on Ethereum, Ethereum's withdrawal
  included, with a floor of 45.91;
- the same EIP-55 example address with its last character's case
  flipped was refused on its checksum.

Refs #77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 21:30:54 +03:00
grenadeandClaude Opus 5.5 57ac504b93 fix(build): commit the lock file that wallet-app's bs58 needs
ci / gate (push) Successful in 11m57s
52ac684 added bs58 to wallet-app and staged crates/ and ui/ but not the
root Cargo.lock, and the two commits after it did the same. CI builds
with --locked, so the gate has failed since then on "cannot update the
lock file". This is the missing line; cargo test --locked passes with
it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 20:55:28 +03:00
grenadeandClaude Opus 5.5 913155d48e refactor(near): the sponsored account's name lives in wallet-near
ci / gate (push) Failing after 1m12s
The sponsor (#81) must name a wallet's account exactly as the wallet
does, or it creates one the wallet never looks at. sponsored_account and
its base32 move from wallet-core into wallet-near, which both depend on;
wallet-core re-exports it, so nothing that calls it changes.

Refs #81

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 13:33:18 +03:00
grenadeandClaude Opus 5.5 7ddab3ebda feat(near): wallet-near, shared with the sponsor, with NEP-366 delegate actions
ci / gate (push) Failing after 1m22s
The sponsor (#81) relays each wallet's NEAR legs as meta-transactions, so
it encodes, decodes and verifies the same bytes the wallet signs. Rather
than a second implementation in the observer, the NEP-649 key and the
transaction encoder move out of wallet-data into wallet-near: no I/O, no
dependency on wallet-core, a raw 64-byte seed in and its own error type.

New in it is delegate.rs:
- DelegateAction signs over SHA-256 of NEP-461's prefix (2^30 + 366)
  and its borsh, as nearcore 2.13's classic Delegate action (tag 8)
  wants.
- SignedDelegate::decode is the sponsor's side. It accepts only the
  shapes the wallet sends (function calls and transfers, from an
  ML-DSA-65 key) and refuses anything else, trailing bytes included.
- verify checks the signature under the empty context.
- A relayer wraps the result as Action::Delegate.

An ML-DSA-65 delegate action, relayed, was proven on mainnet once,
outside the repo: 2SVwJsa1seKQHvAJ2JCzaBvAq8cLfJSPJtppxkr9ahK4.

Refs #81, #74

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 13:31:41 +03:00
grenadeandClaude Opus 5.5 cde186b3fd feat(near): the intents account's key (NEP-649) and its transactions
ci / gate (push) Successful in 13m28s
NearKey is an ML-DSA-65 key derived from the wallet's phrase as NEP-649
specifies, so the same phrase gives the same NEAR key in any wallet that
follows it: SLIP-10 over HMAC-SHA512 keyed "ML-DSA-65 seed", hardened
levels only, the node secret as xi for FIPS 204 KeyGen_internal, default
path m/44'/397'/0'. The dilithium crate's Keypair::generate is exactly
KeyGen_internal; all 22 of NEP-649's published vectors derive to their
node secrets and public keys, one of them from the phrase through the
wallet's own seed. It signs under the empty context, which is what NEAR
verifies; a signature checked under Quantus's context fails, and the
test says so. Its handle (SHA3-256 under NEP-645's tag) matches the one
mainnet lists for #72's key.

near::tx encodes a TransactionV0 as nearcore 2.13 does, hashes it with
SHA-256 and signs the hash. Golden bytes pin the encoding. Signing with a
key the transaction does not name is refused.

Proven once on mainnet outside the test suite: a transaction signed and
encoded by this code, from #72's account, was accepted and reported
under the hash computed here. No test sends a transaction; nothing in
the suite can spend real money. The secrets audit now covers the NEAR
key's Debug output too.

Refs #74

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 13:00:20 +03:00
grenadeandClaude Opus 5.5 1c9467c38d feat(near): NEAR RPC routed by shard, with our nodes first
ci / gate (push) Canceled after 3m50s
The intents account (#71) lives on NEAR, where a node knows nothing of
shards it does not track. NearRpc is the port: every call names the
account it is about. HttpNearRpc works out that account's shard from the
layout and asks our node for it (s<shard>.near.blackbeard.observer),
then the public RPCs. The endpoints are in near.toml, which an operator
file in the data directory replaces.

Our nodes are trusted only while synced. A fresh node answers from the
height it has reached, so the shard-13 node still syncing today said
wrap.near does not exist. The adapter therefore checks each of our
names' /status at most once a minute and skips one that is syncing or
more than two minutes behind. A node that disowns its shard drops the
cached layout, which is how a reshard shows up. Nothing waits for a
transaction's outcome, which no single shard node can assemble; send
returns at INCLUDED_FINAL.

Two of #72's notes were wrong and are corrected here:
- view_access_key wants the full key (ml-dsa-65:<base58>); the
  ml-dsa-65-hash: handle that key listings show is refused as a parse
  error.
- A missing key comes back as a result carrying `error`, not as an RPC
  error. It is read as UNKNOWN_ACCESS_KEY.

Live tests (ignored by default) read accounts on shards 4, 7, 8 and 13,
find #72's ML-DSA key and not its deleted ed25519 one, and make a view
call. Shard 13 fell through to the public RPC past the syncing node.
The app wires it in with #74, the first thing that reads it.

Closes #73

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 12:55:01 +03:00
grenadeandClaude Opus 5 bfb016d76f chore(release): 0.3.0
ci / gate (push) Successful in 12m58s
release / linux (push) Successful in 14m33s
release / package (44) (push) Successful in 36s
release / publish (44) (push) Successful in 13s
deps / advisories (push) Successful in 1m0s
Restoring a phrase finds the accounts it already uses (#69), and any open
wallet can be rescanned from the accounts page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-18 09:47:21 +03:00
grenadeandClaude Opus 5 70dc6ea650 chore(release): 0.2.1
ci / gate (push) Successful in 11m35s
release / linux (push) Successful in 14m34s
release / package (44) (push) Successful in 37s
release / publish (44) (push) Successful in 13s
deps / advisories (push) Successful in 55s
Nullifiers never leave the client (#68): the wormhole spent check reads
padded, shuffled buckets of UsedNullifiers instead of looking each
nullifier up by key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 21:01:08 +03:00
grenadeandClaude Opus 5 dff158e457 fix(release): the workspace crates' exact pins move with the version
ci / gate (push) Successful in 12m17s
release / linux (push) Successful in 14m25s
release / package (44) (push) Successful in 40s
release / publish (44) (push) Successful in 12s
91ec5fb bumped the workspace to 0.2.0 but left wallet-entities,
wallet-core and wallet-data pinned to each other at =0.1.0, so Cargo.lock
no longer resolved. The pins are now =0.2.0, the lockfile follows, and
script/stamp-version.sh rewrites the pins from the workspace version so
the next bump cannot leave them behind. Gate run with --locked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 19:24:11 +03:00
grenadeandClaude Fable 5.1 c59ba49e90 feat(swap): orders as a state machine persisted in SQLite, and the preview screen
ci / gate (push) Waiting to run
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
2026-09-16 10:37:36 +03:00
grenadeandClaude Fable 5.1 14d1a39519 feat: the updater, offered never applied; and a test that greps for secrets
ci / gate (push) Successful in 11m44s
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
2026-09-16 10:24:55 +03:00
grenadeandClaude Fable 5.1 248e1bb2c9 feat(core): an asset registry that maps our asset ids to Intents ids
ci / gate (push) Has been cancelled
assets.toml beside the chain profiles: what the wallet holds (QTC, PLK,
DEV; none on NEAR Intents yet, which is why the Quantus leg of a swap is
its own child) and the transit legs it only passes value through (BTC,
wNEAR, USDC on NEAR, XLM), each with its Intents asset id from
GET /v0/tokens. AssetRegistry parses it, refuses a duplicate asset or an
Intents id claimed twice, and answers intents_id with
SwapError::UnsupportedPair naming the asset for anything it cannot map,
rather than guessing. A saved token list pins that every mapped asset
resolves to exactly one live id with the same symbol and decimals.

Closes #36

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 09:28:12 +03:00
grenadeandClaude Fable 5.1 04d6d62f27 build: a dependency policy: cargo-deny, cargo-machete, and the advisories it found
ci / gate (push) Has been cancelled
deny.toml allows the GPL-compatible licenses this wallet may link
(MIT-0 for qp-poseidon-core among them), fails on advisories and yanked
crates, bans a vendored OpenSSL and the chrono soundness range, and
refuses unknown registries. Running it: time 0.3.45 carried a stack
exhaustion advisory and is updated to 0.3.55, which needs Rust 1.88, so
the workspace's rust-version moves from 1.85; quick-xml 0.38's two
advisories are ignored with the reason recorded, since it is reached
only through plist at build time on this repository's own files and
plist 1.8 pins it. cargo-machete found serde and serde_json unused in
wallet-app; both are dropped. The CI steps follow once the rust runner
image carries the two tools (gongfoo bd936d3).

Refs #49

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 09:22:42 +03:00
grenadeandClaude Fable 5.1 41c3ba7073 feat: history from blackbeard.observer, cached in SQLite, on its own screen
ci / gate (push) Successful in 11m41s
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 #23
Closes #30

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 08:51:50 +03:00
grenadeandClaude Fable 5.1 e4cde9a3db feat(ui): receive: address, QR, checkphrase and copy; accounts named in place
ci / gate (push) Waiting to run
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
2026-09-15 23:36:46 +03:00
grenadeandClaude Fable 5.1 7101ead194 feat(ui): onboarding: create a phrase, restore one, or open a quantus-cli file
ci / gate (push) Waiting to run
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
2026-09-15 23:29:27 +03:00
grenadeandClaude Fable 5.1 2fcc519215 fix(app): a dev CSP so the Vite page renders, one data directory, and a way to verify the webview
ci / gate (push) Successful in 9m16s
Running the shell for the first time showed a blank white window and no
error anywhere: Vite's dev page injects an inline React Refresh preamble,
the production CSP forbids inline scripts, and every component module then
throws on `$RefreshReg$` before React mounts. `tauri.conf.json` now carries
a `devCsp` for the dev server's origin; the production CSP is unchanged.

`WALLET_DATA_DIR` relocates wallets, settings and profile overrides
together, so a dev run lives in a throwaway directory instead of the real
app data; `WALLET_KEYSTORE_DIR` still moves only the wallets, for sharing
them with quantus-cli in place.

Verified end to end against the local dev node by driving the webview
through WebKitGTK's remote inspector (`script/webview-inspect.py`, kept
with the reason in CLAUDE.md): the lock screen lists both dev accounts;
unlocking crystal_alice shows 99,957.786510548 DEV at block 1832, 100 from
final, with the status bar on ws://127.0.0.1:9944 at spec 152; the block
number advances on its own; a settings change lands in settings.json; the
idle rule locks the UI without a click after the configured minute and the
Rust log says so; the Lock button locks; the console shows nothing but
Vite connecting. GNOME refuses D-Bus screenshots and ImageMagick captures
WebKit's GPU surface as flat grey, so the DOM was the evidence.

Refs #25

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-15 23:18:03 +03:00
grenadeandClaude Fable 5.1 95bcfe9ca4 feat(substrate): transfers prepared for review, priced, signed by the session, submitted and watched
ci / gate (push) Waiting to run
`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
2026-09-15 22:58:39 +03:00
grenadeandClaude Fable 5.1 1ca42fdbdf feat(substrate): a subxt config for quantus, and offline signing under the context
ci / gate (push) Waiting to run
`QuantusConfig` is quantus-cli's `ChainConfig` with the signature enum
defined here: `DilithiumSignature { Dilithium87, Dilithium65 }`, variant
indices 0 and 1, each carrying signature-then-public-key as one raw array
because the account id is a hash of the key and the verifier needs the key
itself. Defining it locally keeps sp-core out of a desktop wallet.

subxt 0.44, deliberately: it is the version quantus-cli signs mainnet with,
and its extension resolver skips any extension whose type is empty, which
is how the chain's ReversibleTransactionExtension and
WormholeProofRecorderExtension are tolerated with the default set. A test
pins that both names are in the metadata and that building still works.

`sign_call` builds the v4 extrinsic offline from captured chain state,
takes the signer payload from subxt (blake2-hashed past 256 bytes, as the
runtime hashes when it verifies), signs it with a `wallet_core::Signer`
under the profile's FIPS 204 context, and hands the signature back to
subxt to assemble. The test decodes the result byte by byte: 0x84, Id
address equal to the signer's account id, the variant byte, the public key
riding with the signature, the extras, the call data as the tail.

Live acceptance against a1-planck.quantus.cat, from an unfunded account:
signed under QUANTUS_EXTRINSIC the node answers "Inability to pay some
fees", so the signature passed; signed under the empty context it answers
"Transaction has a bad signature". Ignored test, run by hand.

Metadata fixtures are live `state_getMetadata` from mainnet (spec 152) and
Planck (spec 148), 101 KB each, v14.

Closes #19

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-15 22:16:27 +03:00
grenadeandClaude Fable 5.1 f0b3c26714 feat(profiles): chains as data, shipped in the binary and overridable by file
ci / gate (push) Successful in 6m18s
`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
2026-09-15 18:31:25 +03:00
grenadeandClaude Fable 5.1 de55d1dfb9 feat(mnemonic): generate, validate, verify the backup, and the checkphrase
ci / gate (push) Has been cancelled
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
2026-09-15 18:24:38 +03:00
grenadeandClaude Fable 5.1 5776cfd218 feat(keystore): read and write quantus-cli wallet files, byte for byte
ci / gate (push) Successful in 4m56s
`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
2026-09-15 18:19:27 +03:00
grenadeandClaude Fable 5.1 8015ca8212 feat(keys): ML-DSA key schemes, derived at a BIP44 path, signed under a context
ci / gate (push) Successful in 4m38s
`wallet_data::keys::MlDsa` implements the `KeyScheme` port for ML-DSA-65 and
ML-DSA-87 on Quantus's own crates: hdwallet for the all-hardened
`m/44'/189189'/account'/change'/index'` derivation, dilithium for FIPS 204
signing, poseidon for the account id the runtime derives from a public key.
Signing is hedged with fresh OS randomness, and an empty context means "no
context", the pre-spec-148 rule, rather than a zero-length one.

The test vectors are six addresses produced by quantus-cli 2.2.2 for the
polkadot-js dev phrase, three paths per scheme; two of them are also the
addresses inside the extension fork's CLI-wallet fixtures. A signature made
under `QUANTUS_EXTRINSIC` verifies, and the same signature under the empty
context does not, which is the mistake that would otherwise reach mainnet as
a cryptographically valid, chain-rejected transaction.

`Signer` now requires `Debug`, so every implementation has to write one, and
the one here redacts. The seed is a 64-byte wiping holder with a `Debug` that
prints nothing. SS58 lives in `wallet_core::ss58`, blake2 and base58 only;
decoding returns the prefix so a foreign address can be refused by name.

Closes #14

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-15 18:13:17 +03:00
grenadeandClaude Fable 5.1 c9b623c438 feat(app): isolation pattern on, and a capability file that must say why
The IPC isolation pattern is on from the first command: every message from
the main window passes through the sandboxed isolation application and is
encrypted before Rust sees it, so a script injected into the webview can read
what the UI shows but cannot forge a command. The hook passes everything
through today; the point of shipping it now is that every later command is
born inside the boundary. Both `tauri` and `tauri-build` need the `isolation`
feature, and the build script refuses to proceed if either lacks it.

Capabilities are TOML rather than JSON so each permission can carry the
reason it exists on the line above it. `tests/capabilities.rs` makes that
comment mandatory, requires a real description on every capability, and
refuses to grant anything to a window other than `main`. A capability is the
one place a plugin's reach into the system is widened, and it should not be
possible to widen it quietly.

Closes #11

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-15 18:07:48 +03:00
grenadeandClaude Fable 5.1 257b021066 feat: scaffold the workspace, the tauri shell and the webview
ci / gate (push) Successful in 4m24s
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
2026-09-15 15:42:28 +03:00