58 Commits
Author SHA1 Message Date
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
v0.4.0
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 814355c9f3 feat(near): swaps inside the intents account, on a Ref pool or 1Click
ci / gate (push) Canceled after 5m23s
The intents panel can now swap any token the account holds into QTC,
wNEAR or USDC (#76). Rust prices it both ways and takes the larger
output.

- Pool: best_pool lists Ref's DCL pools for the pair and skips any
  that are not running or whitelist other accounts (they refuse with
  E407). It quotes each on chain.
  - pool_swap_calls is one delegate action: first the fee's
    mt_transfer, ceil(amount × bps) in the input token to the sponsor's
    recipient; then the ft_withdraw to the pool, with Ref's Swap
    message, a floor (default 1% slippage), and the account as
    client_echo.
  - The fee is computed exactly as the sponsor's rule (observer#51)
    checks it, so the relay accepts it.
- 1Click: a quote inside Intents (recipient and refund are the account)
  carrying the fee as appFees. When it wins, a firm quote is taken, its
  echoed recipient, refund, assets and amount checked against what was
  asked, and it is funded by one relayed mt_transfer.
- near_swap prices again at confirm time, checks the balance and the
  sponsor's terms, signs with the owning wallet's key, and relays it.

Measured while building this: without a partner key, 1Click adds its
own 20 bps and passes on half of an app fee (30 asked, 15 recorded).
The Distribution Channel key is what makes the 1Click leg pay the full
fee.

Checked live with quotes from the dev account:
- 1 QTC → 24.75 wNEAR on the QTC/wNEAR pool, with a 0.003 QTC fee; no
  1Click quote;
- 10 wNEAR → 46.83 USDC on a pool, against 46.78 on 1Click;
- 10 wNEAR → 0.393 QTC.
Executing one needs a funded account, which the release test (#78's
route on a real wallet) will do.

Refs #76, #82

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 21:25:22 +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 4b407de277 feat(near): one intents account per installation, whichever wallet's QTC moves
ci / gate (push) Canceled after 25s
The operator's call: a person has no use for several intents accounts,
so the wallet makes one and routes every swap through it. The sponsor
cannot tell one installation from another and does not need to; the
wallet simply never offers a second.

- Settings record intents_account: the owning wallet, the account id and
  its public key, none of it secret. settings_set keeps it Rust's to
  change, as it does the account numbers.
- near_intents replaces near_account:
  - recorded: it is read, with its owning wallet named and marked open
    or locked. Balances read either way; signing waits for the owner.
  - not recorded: an open phrase wallet's existing sponsored account
    with that wallet's key is adopted and recorded. That covers a
    restore, or a second machine.
  - otherwise the first phrase wallet's account is described, with the
    other phrase wallets offered as owner, and creation if the
    sponsor's terms are acceptable.
- near_create_account refuses when an account is recorded or any open
  wallet's account already exists.
- Deposits take QTC from any account; withdrawals pay any open wallet's
  account. Both use the recorded account, and a withdrawal is signed by
  the owner's key.
- The Accounts page shows one NEAR Intents panel, not one per wallet.

Checked in a dev run: with nothing recorded, the dev-phrase wallet's
existing bffbod6n6ey7jdk4j.blackbeard-observer.near was found, recorded
in settings.json and shown as the one active account, with nothing
created.

Refs #71, #74, #81

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 20:54:37 +03:00
grenadeandClaude Opus 5.5 5d5baf1c21 fix(wormhole): one paced zk-tree scan for every open wallet, under the node's rate limit
ci / gate (push) Failing after 1m33s
With three phrase wallets open, the Accounts page showed "RPC rate limit
exceeded (-32999)" on every wormhole panel and on the pending reversible
transfers.

The Quantus node limits each connection to 300 calls a minute
(--rpc-rate-limit 300 in lair/quantus). The wallet keeps one WebSocket
per chain, so everything it asks shares those five calls a second. Each
wallet's first wormhole scan reads mainnet's zk-tree 500 leaves a call,
about 950 calls, and the three scans ran at once at full speed.

- The tree is public and the same for every wallet, so one scan now
  matches the wormhole accounts of every open phrase wallet in a single
  pass. It starts from the lowest cursor among them and records each
  wallet's leaves and frontier.
- Scans run one at a time behind a lock; a wallet whose turn comes after
  another's scan finds itself caught up.
- Bulk pages are paced at three a second, leaving room for balance and
  transfer reads on the same connection.
- under_rate_limit retries a call refused with -32999, backing off 1 to
  16 seconds, rather than putting the node's refusal on screen. The scan
  pages, the nullifier bucket reads, the leaf count and the pending
  reversible transfers all go through it.

Checked from an empty cache: the first scan of 481,540 leaves took 396 s,
with no error shown and no backoff needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 20:38:20 +03:00
grenadeandClaude Opus 5.5 52ac684661 feat(near): QTC into the intents account and back out to Quantus
ci / gate (push) Failing after 1m35s
The intents panel now moves QTC across the PoA bridge (#75).

In: the bridge's permanent Quantus deposit address for the account,
checked as a Quantus address before it is shown. "Send QTC here" opens
Send with it filled in (Send takes ?to= now), so a deposit is an
ordinary transfer reviewed and signed like any other.

Out: a withdrawal to one of the wallet's own Quantus accounts. It is the
ft_withdraw #72 proved, qtc.omft.near to itself with the WITHDRAW_TO:
memo:
- qtc_withdraw_call in wallet-core builds it and refuses anything under
  the bridge's 0.01 QTC minimum.
- The command checks the Intents balance and the sponsor's terms, signs
  a delegate action with the phrase's NEAR key (nonce one past the key's,
  valid for about 120 blocks), and hands it to the sponsor, which relays
  it and pays the gas.
- The bridge keeps 0.002 QTC, and the panel says so.

NearRpc::final_block now returns the height with the hash, for the
delegate's expiry. QtcBridge is the new port and PoaBridge its adapter.

Checked in a dev run with the dev-phrase account:
- the deposit address appeared (qzkgR6o1…);
- Send opened with it filled in;
- a 0.05 QTC withdrawal was refused because the account holds no QTC.
A live test (ignored) confirms the bridge gives #72's account the
address it used on mainnet. A full round trip with real QTC is still to
do before #75 closes.

Refs #75

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 20:23:03 +03:00
grenadeandClaude Opus 5.5 384c79446b feat(near): the sponsor creates the intents account from the wallet
ci / gate (push) Failing after 1m27s
The Accounts page now offers "Create account" when the wallet's NEAR
Intents account does not exist yet. The sponsor (#81) creates it as
b<…>.blackbeard-observer.near with the wallet's ML-DSA-65 key, and pays
its NEAR fees from then on, so the panel no longer shows gas.

- NearSponsor is the port. HttpSponsor reads the terms, asks for an
  account and relays a signed delegate, keeping the sponsor's own reason
  when it refuses.
- The wallet acts only on terms it accepts: the sponsor is running, it
  names the account the wallet expects, and its fee is at most
  MAX_FEE_BPS (50). Otherwise the panel says why and offers nothing.
- The account the sponsor reports must be the one the wallet derived
  from its key, or the wallet refuses to use it.
- The sponsor answers once its transaction is in a final block, but the
  account is made by a receipt a block or two later. The command waits
  for it to be readable before reading the summary back.

Verified in a dev run with the dev-phrase fixture: Create account made
bffbod6n6ey7jdk4j.blackbeard-observer.near (0.0025 NEAR deposited, 182
bytes, the sponsor logged "created"). It read back as active once
refreshed, which is the race the wait now covers. The live tests
(ignored by default) read the terms and send a malformed key, which the
sponsor refuses without creating anything.

Closes #74

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 20:18:59 +03:00
grenadeandClaude Opus 5.5 f5c214e4bf feat(near): the handle digest of a public key, for the sponsor
ci / gate (push) Failing after 1m25s
The sponsor reads a key from a delegate action, where it has the public
bytes and no keypair, and must name the account from it exactly as
NearKey does. handle_digest is now a free function on the public key,
and NearKey's method calls it.

Refs #81

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 13:34:55 +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 f4cc7a1605 feat(near): the intents account on the Accounts page
ci / gate (push) Failing after 1m25s
Every open wallet with a phrase now shows its NEAR Intents account. Its
NEP-649 key is derived from the seed on each look and dropped; only the
public side leaves Rust. The account's name is a function of that key,
b<base32 of the handle's first 10 bytes>.blackbeard-observer.near, so the
sponsor (#81) creates it there, a restored phrase finds it again with
nothing stored, and the leading b sorts it onto shard 8, which our nodes
serve. The panel says whether the account exists and whether this
wallet's key is on it, and shows its NEAR for gas and every token it
holds inside Intents (one mt_batch_balance_of over the registry's
Intents ids).

The logic is wallet_core::near::account_summary, written against the
NearRpc port and tested with a fake. The command is a shim, and the app
now holds the shard-routed client from #73 and near.toml, which a file
in the data directory can replace.

Corrected on the way: the asset registry said no post-quantum asset is
on Intents. QTC has been, as nep141:qtc.omft.near, since early October.
Quantus's native QTC now maps to it, the token-list fixture gains 1Click's
QTC entry, and the "unmapped asset" tests use Planck's token instead. The
readme no longer says the swap module waits for Quantus's MPC fork.

Verified in a dev run with the dev-phrase fixture: the panel showed
bffbod6n6ey7jdk4j.blackbeard-observer.near as not created yet.

Refs #74

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 13:23:13 +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.5 b059a8d5d0 feat(ui): the running version in the sidebar, linked to its release
ci / gate (push) Successful in 13m48s
The sidebar now reads "wallet v0.3.0" under the mark, and the version
opens its release tag on git.lair.cafe in the system browser. The number
is the binary's own, from app_info, so a packaged build always names the
tag it was cut from. The opener's allow-list gains this repository's
URLs and nothing else on that host.

On a dev run with crystal_alice unlocked: the tagline rendered with the
version in mono, not uppercased; opening the tag URL resolved, and
git.lair.cafe/other/repo was refused by the opener.

Closes #70

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 10:47:05 +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
v0.3.0
2026-09-18 09:47:21 +03:00
grenadeandClaude Opus 5 fb42138651 feat: find the accounts a restored phrase already uses
ci / gate (push) Has been cancelled
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
2026-09-18 09:47:01 +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
v0.2.1
2026-09-16 21:01:08 +03:00
grenadeandClaude Opus 5 b454e4424f fix(wormhole): nullifiers never leave the client
ci / gate (push) Has started running
The spent check for wormhole deposits looked each nullifier up in
Wormhole::UsedNullifiers by key. That map is Blake2_128Concat, so the key
is blake2_128(n) ‖ n and every request handed the node the nullifier
itself. An exit publishes its nullifier on chain, so whoever runs the
node could match a later exit to the connection that asked, and that
connection also asked for the wallet's ordinary balances. The accounts
page ran it automatically for every open wallet with a phrase, so 0.1.0
and 0.2.0 sent unspent nullifiers to our mainnet endpoint.

spent_among now does what quantus/extension does: it reads whole buckets
of the map (keys sharing the first byte of blake2_128(n)) with
state_getKeysPaged, the buckets it needs padded with random others to at
least 16 and asked in shuffled order, and decides membership in Rust. The
node learns which buckets were read, never which entry mattered. The per-
key lookup is gone. Tests pin the rule: the plan covers ours, pads to 16
without repeats and does not lead with ours; no request carries a
nullifier and each is the map's root plus one byte. Against mainnet, a
real used nullifier read from a bucket hashes to that bucket and the
bucket read reports it spent and a random one unspent.

doc/threat-model.md gains the wormhole section: nullifiers and the
wallet's leaves are never named to a node, and spending (#46, #47) must
not request the Merkle path for the spent leaf alone.

Closes #68

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 21:00:32 +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
v0.2.0
2026-09-16 19:24:11 +03:00
grenadeandClaude Opus 5 91ec5fbadb chore(release): 0.2.0
ci / gate (push) Failing after 40s
Both ML-DSA accounts from every phrase with no scheme choice (#65),
several wallets open at once (#66), and accounts added to an existing
wallet (#67).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 19:22:38 +03:00
grenadeandClaude Opus 5 42d226cf9e feat: add accounts to an existing wallet
ci / gate (push) Waiting to run
"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
2026-09-16 19:22:24 +03:00
grenadeandClaude Opus 5 cb0fe536cd feat: both ML-DSA accounts from every phrase, and several wallets open at once
ci / gate (push) Successful in 12m43s
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 #65
Closes #66

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 18:35:04 +03:00
grenadeandClaude Opus 5 aa6ad55181 ci(release): the binary's real name, the CLI run where it finds the project
ci / gate (push) Successful in 12m38s
release / linux (push) Successful in 14m46s
release / package (44) (push) Successful in 39s
release / publish (44) (push) Successful in 13s
Three faults a first tag would have hit. The version check and the
collect step looked for target/release/wallet-app, but the binary has
been blackbeard-wallet since the product was renamed for packaging. The
bundle step ran the Tauri CLI with --dir ui, where there is no Tauri
project to find; it now runs from crates/wallet-app, checked with
`tauri info`, and the CLI is pinned to 2.11.4 as doc/reproducibility.md
said it would be once a release was cut. The publish job's `rpm -K`
right after signing would exit non-zero on a runner whose rpm database
does not hold the lair.cafe key; the check now runs on the repo host
after the rsync, where gitea_ci verifies cichlid-0.1.9 as OK today.

Refs #32
Refs #33

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
v0.1.0
2026-09-16 14:25:27 +03:00
grenadeandClaude Opus 5 44d32b4432 ci: the dependency policy in the gate, and a daily advisory check
ci / gate (push) Has been cancelled
The rust gate builds and tests with --locked, so a change that would
move Cargo.lock fails instead of resolving something new. A dependency
policy step runs cargo deny check (licenses, advisories, bans, sources
from deny.toml) and cargo machete, both now in the runner image
(gongfoo bd936d3, images run 284). deps.yaml runs the advisory check
daily on the rust runner, so an advisory published against a
dependency the lockfile already pins turns red within a day whether or
not anyone pushes.

Closes #49

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 14:24:03 +03:00
grenadeandClaude Fable 5.1 9fd50f9904 feat(ramp): the FiatRamp port with a stub provider, opened in the system browser
ci / gate (push) Successful in 10m55s
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
2026-09-16 10:42:04 +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 3db8b05032 feat: the wormhole address behind mining rewards, and its shielded balance
ci / gate (push) Waiting to run
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
2026-09-16 10:14:40 +03:00
grenadeandClaude Fable 5.1 82df5bf2b4 feat(swap): a 1Click client typed from the pinned spec, behind SwapProvider
ci / gate (push) Successful in 11m41s
crates/wallet-data/spec/1click-v0-openapi.yaml pins NEAR Intents'
1Click API (0.1.10); swap/oneclick.rs types tokens, quote (with dry),
deposit/submit and status from it, camelCase as the spec has them, with
every documented execution status round-tripping through serde. The
wallet's SwapRequest maps both assets through the asset registry and is
refused by name for anything it cannot map; a status answer maps to the
wallet's states with the destination tx hash on success and the refund
reason on failure. The JWT is a bearer the relay sets at run time, never
a constant; without it the API answers tokens and dry quotes, which is
all a preview needs.

Live, ignored test: a dry EXACT_INPUT quote of 10 USDC on Base to USDC
on NEAR at 100 bps came back 9.974166 with a floor of 9.874424, within
the slippage asked for, and reserved no deposit address.

Closes #38

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 09:31:48 +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 34738a8e32 feat(ui): the same accounts on every other network, side by side
ci / gate (push) Waiting to run
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
2026-09-16 09:26:42 +03:00
grenadeandClaude Fable 5.1 9eb0a24d9a fix(substrate): the lints Rust 1.88 turned on
ci / gate (push) Waiting to run
let-chains and is_multiple_of, in the runtime-version poll.

Refs #49

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 09:23:15 +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 418b709035 feat(core): a foreign address is refused by the name of its chain
A table of the SS58 prefixes a person is likely to paste by mistake, from
the SS58 registry, so a Polkadot or Kusama address pasted into the send
form is refused as "a Polkadot address, not a Quantus address" rather
than as a bare prefix number; a prefix nothing knows still says which
prefix a Quantus address has. Profiles the wallet carries take
precedence over the table, so a Quantus-family prefix names mainnet.

Refs #37

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 cd09e774f5 ci: package the release binary as a Fedora 44 rpm and publish it to rpm.lair.cafe
ci / gate (push) Has been cancelled
rpm/blackbeard-wallet.spec is the house prebuilt-binary spec: the bare
binary from the release, the desktop entry rendered from the same
template Tauri's bundlers use, AppStream metadata and four hicolor icons;
no strip, no debuginfo; validators run where they exist; every rpmlint
warning that stands is explained at the top. The release workflow gains a
package job on the rpm runner (changelog entry from the tag through
actions/rpm-changelog, rpmbuild with dist .fc44, rpmlint) and a publish
job under the shared rpm-publish concurrency group that signs with the
repository key, rsyncs as gitea_ci to oolon, runs createrepo_c and
regenerates packages.json, exactly as cichlid does. rpm/rpmmacros and
script/generate-packages-json.py are cichlid's.

Built here from the local release binary: blackbeard-wallet-0.1.0-1.fc44
installs /usr/bin/blackbeard-wallet, the desktop entry, the metainfo and
the icons. The publish job needs RPM_SIGNING_KEY, RPM_SIGNING_KEY_ID and
RSYNC_SSH_KEY on this repository, which only the operator can add; until
then a tag builds and attaches the release but stops at publish.

Refs #33

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 09:15:53 +03:00
grenadeandClaude Fable 5.1 3b7089c8d1 ci: a release workflow on a version tag, and bundles named for packaging
ci / gate (push) Waiting to run
Pushing vX.Y.Z runs .gitea/workflows/release.yaml on rust-gtk3: it
refuses a tag that disagrees with the workspace version
(script/stamp-version.sh, which also stamps tauri.conf.json and
ui/package.json from Cargo.toml), builds the webview and the deb, rpm and
AppImage bundles through the npm Tauri CLI, checks the binary answers
--version with the tag, and attaches the bundles, the bare binary and
SHA256SUMS to a Gitea release for the tag using the repository's own
Actions token.

The product is now `blackbeard-wallet`, no space: that name is the
binary, the rpm and deb package, and every release asset, so
`dnf install blackbeard-wallet` reads right. The window title and the
desktop entry keep "blackbeard wallet" through a desktop template. The
binary answers --version and --help before touching the display.

Bundled locally on Fedora 44: deb, rpm (name, license and URL right,
desktop entry installed) and a 114 MB AppImage that prints
"blackbeard-wallet 0.1.0". The AppImage step needs librsvg2-devel for
linuxdeploy's GTK plugin, which the rust-gtk3 runner image does not carry
yet; that is a one-line change in gongfoo, without which the workflow's
bundle step fails on the AppImage.

Refs #32

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 09:10:40 +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 736f4d3f65 feat(app): webkitgtk quirks decided at startup and logged, and the smoke checklist
ci / gate (push) Successful in 11m36s
On Linux, before Tauri, tokio or any thread exists, the app reads what
the machine says (NVIDIA module loaded, Wayland session, an explicit
WALLET_WEBKIT_DMABUF=on|off) and sets __NV_DISABLE_EXPLICIT_SYNC=1 for
NVIDIA on Wayland and WEBKIT_DISABLE_DMABUF_RENDERER=1 for NVIDIA at all,
each logged with its reason so a blank-window report says which path was
taken. A variable already set in the environment is left alone. The
decision is a pure function with tests for the paths this Intel machine
cannot take.

doc/platform-smoke.md carries the v1 checklist and today's Linux pass
(Fedora 44, GNOME 50 on Wayland, webkitgtk 2.52.5). Windows 11 and
macOS 15 are not run: no such machine here; #31 stays open for them.

Refs #31

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 08:35:21 +03:00
grenadeandClaude Fable 5.1 dc3a87c0e6 feat(ui): pending reversible transfers with a measured countdown and a cancel
ci / gate (push) Waiting to run
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
2026-09-16 08:32:04 +03:00
grenadeandClaude Fable 5.1 52774f1d88 feat(ui): send: a form, a review of what Rust decoded, then sign and watch
ci / gate (push) Successful in 9m49s
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
2026-09-15 23:44:42 +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 fbb7c71db9 feat(ui): the app shell: lock screen, sidebar, network switcher, status bar, settings
ci / gate (push) Successful in 9m14s
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
2026-09-15 23:08:01 +03:00
grenadeandClaude Fable 5.1 be2644f31b ci: a pinned quantus-node dev chain in the gate, and a script to run one locally
ci / gate (push) Waiting to run
`script/dev-node.sh` fetches the v1.0.1 release binary once into `.cache/`,
checks it against the sha256 the release publishes, and runs
`quantus-node --dev` on a throwaway base path. The workflow starts it in
the background, waits for `system_chain` to answer `Quantus DevNet`, and
runs every ignored test whose name says `dev_node`: the send, reverse,
cancel and named-failure path, and the scheme-by-era probe that found the
mortality bug. Public-node tests stay ignored in CI.

Rehearsed locally on a second node on port 9955: both tests pass in 15 s.

Closes #24

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-15 23:00:42 +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 26559ee148 feat(substrate): balances, nonce and high-security status, refreshed on every finalized head
ci / gate (push) Successful in 6m43s
`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
2026-09-15 22:22:51 +03:00
grenadeandClaude Fable 5.1 78c06120f6 feat(substrate): a supervised connection per chain, failing over and following upgrades
ci / gate (push) Waiting to run
`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
2026-09-15 22:19:44 +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