A desktop wallet for post-quantum chains, Quantus first.
Linux, Windows and macOS; installable on Fedora with dnf.
The wallet is a Rust cargo workspace with a Tauri shell and a React webview. That split is the security model, not a convenience: every key, signature, decoded transaction and amount lives on the Rust side, and the webview only renders what a Tauri command hands it. Nothing that could serialise a secret is reachable from JavaScript.
crates/
wallet-entities/ domain types and the IPC wire protocol; exported to TypeScript
wallet-core/ wallet logic and the ports it is written against
wallet-data/ adapters behind those ports: keystore, cache, RPC, provider APIs
wallet-app/ the Tauri binary; commands are one-line shims into core
ui/ Vite + React + TypeScript webview
The ports in wallet-core are what make "any post-quantum chain" a profile
rather than a rewrite:
KeyScheme turns a seed and a path into a signer. ML-DSA-65 and ML-DSA-87
now; whatever the next chain uses later.ChainAdapter knows one chain: balances, building and submitting a
transaction, watching it. The Substrate adapter is driven by runtime
metadata plus a small per-chain profile (ss58 prefix, decimals, signature
enum, transaction extensions, signing-context rule, coin type).SwapProvider is a swap venue. NEAR Intents first.NearRpc is NEAR, the chain under the wallet's intents account, routed to
a node that tracks the shard of the account each call is about.FiatRamp builds a provider session through a relay on our infrastructure,
which holds the provider secrets, and opens it in the system browser.The wallet is post-quantum only. Classical chains appear only as transit
legs of a swap or ramp; the wallet never derives a secp256k1 or ed25519 key.
That includes NEAR: the intents account is a NEAR account whose only key is
ML-DSA-65 (NEP-645), derived from the wallet's phrase as NEP-649 specifies.
NEAR verifies it under the empty FIPS 204 context, where Quantus requires
QUANTUS_EXTRINSIC.
Quantus signs extrinsics with ML-DSA, hashes public keys with Poseidon2 for
the account id, and shields mining rewards behind a Plonky2 wormhole whose
prover is minutes of native CPU. All of that exists as Rust crates and nothing
else, so the core is Rust. Tauri is the house desktop convention
(~/git/architecture/generic.md §4) and the only shell that packages rpm, deb,
msi and dmg with a Rust-owned IPC boundary out of the box.
QTC reaches NEAR as qtc.omft.near through a bridge with a deposit address
per account, and its liquidity is in Ref pools rather than with 1Click's
solvers. So swaps run through an intents account the wallet holds (#71),
using only calls intents.near authorises by the calling account. Its
signed intents (execute_intents) accept classical signatures only, and the
wallet never makes one. #72 ran the whole route that way on mainnet.
pnpm --dir ui install
cargo tauri dev --config crates/wallet-app/tauri.conf.json # from crates/wallet-app: cargo tauri dev
cargo test --workspace # also regenerates ui/src/api/generated
The CI gate is cargo fmt --check, cargo clippy -D warnings, cargo test,
and the webview's typecheck, lint and build. Run them before claiming done.
A green gate on main also uploads a debug build of the app as a run artifact
(blackbeard-wallet-<sha>-x86_64-linux-gnu-debug, kept seven days), so a
change can be tried on a machine before a release is cut. It is the dev
profile, so slower than a release, and it needs the GTK/WebKitGTK libraries
the rpm depends on.
Work is filed before it is done. Epics carry the epic label and an external
definition of done; their steps carry child, in dependency order, at
https://git.lair.cafe/blackbeard/wallet/issues. A commit that finishes a
child closes it (Closes #N); when an investigation contradicts an issue, the
correction is a comment on the issue, dated, not a surprise in a diff.
257 activities
Correction, 2026-10-12: the artifact is the debug build, not a release-profile binary.
The gate had no app binary at all before (clippy checks, cargo test builds harnesses), but it has the dev profile's compiled dependencies, so tauri build --debug --no-bundle costs about 20 s rather than a release recompile of minutes; the operator asked for exactly that. The Tauri CLI, not plain cargo build, so the webview is embedded (checked: standalone it serves tauri://localhost); stripped of debug info, 93 MB instead of 498 MB. Artifact name blackbeard-wallet-<sha>-x86_64-linux-gnu-debug.
Plain cargo build --release would also not have produced target/release/blackbeard-wallet: the CLI renames the binary to mainBinaryName.
67bae58 ci: the artifact is the debug build, so the gate stays as fast as it was9757f90 ci: the gate uploads the release binary as a run artifactcaff8a6 feat: remove a wallet that holds nothing20f171b fix(ui): say what the Swap screen is, now that QTC is on NEAR Intents102f659 feat(ui): a dashboard of the whole wallet, first after unlock700850c fix(ui): the lock screen says nothing about what it guards8cfd88b chore(release): 0.4.14ea8546 feat: one password unlocks every wallet038f097 chore(release): 0.4.04f3b446 feat(near): cash out from the intents account to an address on another chain814355c feat(near): swaps inside the intents account, on a Ref pool or 1Click57ac504 fix(build): commit the lock file that wallet-app's bs58 needs4b407de feat(near): one intents account per installation, whichever wallet's QTC movesOperator decision, 2026-10-09: one intents account per installation, not one per wallet. A person has no use for several, so the wallet makes one and never offers a second. Every swap routes through it, whichever wallet's QTC goes in or out. The sponsor does not need to enforce this server-side.
Implemented in 4b407de:
5d5baf1 fix(wormhole): one paced zk-tree scan for every open wallet, under the node's rate limit52ac684 feat(near): QTC into the intents account and back out to Quantus384c794 feat(near): the sponsor creates the intents account from the walletThe sponsor account exists, 2026-10-09:
blackbeard-observer.near was registered from the operator's implicit account (tx At7zrH8TdYbcAmPtbjFM3RnNvSxjAZEvpJYpuvVYmir3), opening balance 0.01 NEAR.blackbeard (/var/lib/blackbeard/near-sponsor.key, mode 600). Handle ml-dsa-65-hash:4ct7C4TKKT7xBXbeBycyypUxgMLt5gxgdevwJvh5sxhu.
44Ex8w8gqBUpC63pddvhR6KyV2E6PtzszZ9ngprosWoF).c36d08b) on the operator's terms:
blackbeard-observer.near;Costs, measured from #72's transactions:
The operator is funding it with about 6 NEAR.
Correction: the deposit in the first example config, 0.002 NEAR, was below a sponsored account's storage, and creation would have failed. It was fixed to 0.0025 before the sponsor was enabled.
friday, october 9, 2026 — 15:36:25 utcShard 13 is live. frankie's second neard instance (neard-s13) state-synced through the new WAN forward (24569 → frankie) and has answered for wrap.near since 14:19 on 2026-10-09, within a few blocks of the public RPC's head.
All six s<id>.near.blackbeard.observer names now answer for their shard's accounts.
f5c214e feat(near): the handle digest of a public key, for the sponsor913155d refactor(near): the sponsored account's name lives in wallet-near7ddab3e feat(near): wallet-near, shared with the sponsor, with NEP-366 delegate actionsf4cc7a1 feat(near): the intents account on the Accounts pageTwo children added after the operator's decision on account creation (2026-10-09):
blackbeard-observer.near creates each wallet's account as b<…>.blackbeard-observer.near, named from its key, with a gas float, through a service on blackbeard.observer's API.appFees on 1Click legs and an atomic mt_transfer on pool legs.#74 derives the name and reads the account; creation goes through #81.
friday, october 9, 2026 — 10:08:28 utccde186b feat(near): the intents account's key (NEP-649) and its transactions1c9467c feat(near): NEAR RPC routed by shard, with our nodes first1c9467c feat(near): NEAR RPC routed by shard, with our nodes firstCorrection (found building #73): view_access_key does not take the ml-dsa-65-hash: form. Nodes refuse it as a parse error (unknown key type 'ml-dsa-65-hash'). They want the full key, ml-dsa-65:<base58 of the 1952-byte key>.
The handle is what state keeps and what view_access_key_list shows. The spike's helper tried the handle first and silently fell back to the full key, which hid this.
A missing key arrives as a result whose error field says "access key … does not exist while viewing", not as an RPC error.
Correction (found building #73): view_access_key does not take the ml-dsa-65-hash: form. Nodes refuse it as a parse error (unknown key type 'ml-dsa-65-hash'). They want the full key, ml-dsa-65:<base58 of the 1952-byte key>.
The handle is what state keeps and what view_access_key_list shows. The spike's helper tried the handle first and silently fell back to the full key, which hid this.
A missing key arrives as a result whose error field says "access key … does not exist while viewing", not as an RPC error.
Live, 2026-10-09: s<id>.near.blackbeard.observer, one public name per shard our nodes track (blackbeard/observer commit 36bea6b, infra-setup.sh --role nodes):
| name | node | answers for |
|---|---|---|
| s4.near.blackbeard.observer | gargravarr | qtc.omft.near, near (the registrar) |
| s7.near.blackbeard.observer | frankie | intents.near |
| s8.near.blackbeard.observer | gargravarr | dclv2.ref-labs.near, bbspike72.near |
| s10.near.blackbeard.observer | quadbrat | native USDC |
| s12.near.blackbeard.observer | quadbrat | v2.ref-finance.near |
| s13.near.blackbeard.observer | frankie | nothing yet: UNAVAILABLE_SHARD for wrap.near |
bl.thgttg.com; one Let's Encrypt lineage near-shards.blackbeard.observer, valid to 2027-01-07.near.blackbeard.observer (shared with it), plus a ceiling of 30 r/s per node across all clients.near-rpc firewalld rule for 10.3.1.101 on frankie and gargravarr, and frankie/gargravarr.hanzalova.internal host overrides on the kosherinata router, which had been missing.Shard 13 still needs a node. Adding wrap.near to frankie's tracked accounts and restarting stalled the node at the restart height: it stopped applying blocks, and the observer's swap indexer stopped with it. It was reverted after 13 minutes and both recovered. Shard 13 needs a fresh neard (an empty data dir syncs its shards at once), and that needs its own inbound P2P port for state sync.
For the wallet: read the shard layout (EXPERIMENTAL_protocol_config) from any of these names, map the account to its shard, and query s<shard>. Judge each leg by state, not tx_status. Fall back to a public RPC for a shard without a name.
Survey, 2026-10-09. There is already a public NEAR name, but it only covers part of what the wallet needs.
near.blackbeard.observer on oolon goes to near.internal on hanzalova, which goes to frankie only:
client_max_body_size 64k;POST /, GET /status and GET /health are allowed;650 | aurora | aurora-0 | earn.kaiching | game.hot.tg | game.hot.tg-0 | kkuuue2akv_1630967379.near | tge-lockup.sweat | wallet.ka.| account | role in the route | shard | our node |
|---|---|---|---|
| intents.near | verifier: balances, every leg | 7 | frankie |
| dclv2.ref-labs.near | pool quotes and swaps | 8 | gargravarr |
| qtc.omft.near, near (registrar) | QTC token, account creation | 4 | gargravarr |
| native USDC 17208628… | USDC token | 10 | quadbrat |
| wrap.near | wNEAR; wrapping, and topping up the gas float | 13 | none |
| eth-…omft.near etc. | other chains' tokens; 1Click's side, not ours | 9 | none (not needed) |
| user accounts | nonce and gas float for every transaction | depends on the name | partial |
Transaction 6hPyBHEEZUjojJJFGtysmk8zwPaYxSUtjjcUrm3j5S5U: bbspike72.near (shard 8) calls wrap.near.storage_deposit (shard 13), submitted to gargravarr (shards 4 and 8).
send_tx with wait_until: FINAL timed out (408).INCLUDED_FINAL, status Started, 0 receipts. It never learns the result.FINAL, SuccessValue, 4 receipts.So for a node to report a transaction's outcome, it needs the sender's shard and the shard of every receipt. The pool leg alone runs 7 → 8 → 4 → 7.
bbspike72.near sorts between aurora-0 and earn.kaiching, so shard 8), user accounts stay on shards we run. PQ implicit accounts (NEP-655, protocol 87) need the same check once their id format is final.{4, 7, 8, 10, 13} plus the user-account shard (nearcore can track by account: intents.near, dclv2.ref-labs.near, qtc.omft.near, near, wrap.near, USDC). Or a small router dispatches by account, which cannot assemble a transaction's outcome across nodes.Operator decision: hardware for a node tracking {4, 7, 8, 10, 13}, or which of the three to grow.
Signer decided by #72: the intents account is a NEAR account whose only key is ML-DSA-65, and the wallet still holds no classical key. Every leg of the route works through direct calls to intents.near, wrap.near and the bridge; nothing needs execute_intents.
Ed25519 remains an import option for an existing intents key (the operator's key.json). Using one is opt-in and labelled; it is never the default.
Consequences for the children, from #72's findings:
view_access_key takes the ml-dsa-65-hash: form of the key.qp-rusty-crystals-dilithium with ctx = None is accepted;ft_withdraw with the Swap msg at 250 Tgas. The 1Click leg is a direct mt_transfer to the quote's deposit account.mt_transfer with recipientType: DESTINATION_CHAIN.ft_transfer_call with msg = the receiving account.Account bbspike72.near, key ml-dsa-65-hash:BGQfipngvvnrNKHEV41FDVPiFy1kEajb9capGbX1uibt. Every transaction after the third was signed with ML-DSA-65 and nothing else, and none of them needed a signed intent (execute_intents). 2026-10-09, all times EEST.
| step | how | NEAR tx | result |
|---|---|---|---|
| fund | native_withdraw 1.6 NEAR from the operator's ed25519 intents account to its own implicit account (relay publish_intent) | intent 61gCQ6m4… | 1.6 NEAR on 42d72e44… |
| create | implicit account calls near.create_account with a temporary ed25519 key, 1.3 NEAR | HmueC2ck1f6K3kdWVxfiuzEAPD9BLkJH6Z7t9rpimYkU | ok |
| add PQ key | AddKey ML-DSA-65 full access, ed25519-signed | D7m6P2tc8aumLVdeNbnThfHpdRQhn863oRVzDNVajAFY | stored as ml-dsa-65-hash: |
| drop classical key | DeleteKey ed25519, ML-DSA-signed | 9HN51j6VSHfkn7Wd6tZEpUB25b3iFoLvuZru2e9JPW2N | the account is PQ-only from here |
| deposit | wrap.near: storage_deposit + near_deposit 1 NEAR + ft_transfer_call to intents.near (msg: ""), one tx | FdtcrSKHg3vEzV4EHWLNf7oh5bpCWn5RQ2Dq7p36ED8J | 1 wNEAR in Intents |
| pool buy | direct intents.near.ft_withdraw to dclv2.ref-labs.near with the Swap msg (client_echo = own account) | F6KHGWDffvMkaqPibW77qiV6CvNk88VBgNjPqLvTiLnF | 1 wNEAR → 0.035923 QTC in Intents |
| bridge out | direct ft_withdraw of qtc.omft.near to itself, memo WITHDRAW_TO:qzoqjLjG…1ZC | 4PGj6GetrQyyjoDrkFCHbGDwXR4wDhsJFiduQ7sjwRXm | 0.033 QTC on Quantus about 60 s later (0.002 fee) |
| bridge in | PoA deposit_address for the named account (qzm76Xye…kqfn), then a quantus send of 0.0315 QTC | Quantus 0x5751f1f4…fc54 | credited in Intents about 6 min later |
| pool sell | direct ft_withdraw QTC → DCL pool, Swap to wNEAR | AyBzNK5A5PdZ7zUA5kzqCuQYd9MPyJ7bDfT2pJdYGM49 | 0.032423 QTC → 0.889664 wNEAR |
| 1Click swap | live quote wNEAR → USDC, INTENTS in and out, funded by direct mt_transfer to the quote's deposit account | 7AQgvJkCniVtmd38MxLa7DVXAr7PhmbkEYTxNjZMUJBR | SUCCESS in about 10 s, 4.278655 USDC |
| cash-out | live quote with recipientType: DESTINATION_CHAIN, funded the same way | B8RavVUVP1i47qpRSP7C4UmiAQt9vMhAuyv6Xv7HamJM | SUCCESS, USDC paid out on chain (8KrSKv7y…) |
| return | ft_transfer_call USDC into intents.near with msg = the operator's account | F88bTA1bQbAKefifbwbbiKbp5hXquDwSECmcw1WsbfVV | credited to 42d72e44… |
The cash-out went to NEAR's own chain, because no EVM address of ours was to hand. From the PQ account's side the mechanism is the same as an Ethereum payout: a 1Click quote funded by mt_transfer. Only 1Click's payout differs.
PqdsaKeyPair::sign). qp-rusty-crystals-dilithium 4.1.1, already in the workspace, produces accepted signatures with ctx = None. It is the opposite of Quantus, where the empty context is refused; the two must never share a code path that picks a context implicitly.TransactionV0, and the transaction hash is still the SHA-256 of its borsh encoding. A public key is tag 2 followed by 1952 bytes; a signature is tag 2 followed by 3309 bytes. A signed transaction is about 5.4–5.7 kB, against about 370 B for ed25519. The access key lives in state as ml-dsa-65-hash: + base58(SHA3-256("near:ml-dsa-65-pubkey-hash:v1" ‖ pk)). view_access_key takes that hash form.NotEnoughBalance at 0.2975 NEAR). Each leg actually cost about 0.001–0.003 NEAR. 250 Tgas was enough for the DCL swap. A PQ account therefore keeps a native NEAR float; the ed25519 path is gasless. #74 has to show that float and top it up, for example by unwrapping wNEAR.token_diff intents. The route above never needed one.m/44'/397'/… for ML-DSA-65; the spike key was random. #74 decides the path.1.6 NEAR left the operator's intents account. Back: 4.278655 USDC credited to it, 0.2925 NEAR on its implicit account 42d72e44…, and 0.2875 NEAR left on bbspike72.near, kept for development.
The round trip cost the difference: 1 NEAR in, 0.8897 wNEAR out. That covers two 1% pool fees, the bridge's 0.002 QTC withdrawal fee (about 6% at this size), and about 0.0015 QTC left as dust on the throwaway Quantus account.
The spike's code (throwaway) and its ML-DSA key are on the operator's workstation under ~/.config/blackbeard-spike/.
Correction, 2026-10-09. Two of this epic's facts no longer hold:
qtc.omft.near through the PoA bridge in early October, with a permanent deposit address per intents account. 1Click lists QTC but, as of today, no solver quotes it; its liquidity is in Ref DCL pools reached from an intents account.So the Quantus leg runs through an intents account the wallet holds, which is #71. This epic stays open as the no-account route (quote, one-time deposit address, status), which suits a one-off conversion and lights up for QTC when solvers quote it.
The line "the wallet never holds a classical key" is now conditional on #72: an ML-DSA-65 NEAR account may be able to run every leg of the route through direct calls to intents.near; if not, #71's intents account is ed25519, which the operator has accepted.
Children in dependency order:
#75–#77 can proceed in parallel once #74 lands; #80 needs only #74.
friday, october 9, 2026 — 08:46:38 utcb059a8d feat(ui): the running version in the sidebar, linked to its releasebfb016d chore(release): 0.3.0fb42138 feat: find the accounts a restored phrase already uses70dc6ea chore(release): 0.2.1b454e44 fix(wormhole): nullifiers never leave the clientA constraint for this child, from the operator and from quantus/extension's experience: nothing that identifies which deposit is being spent may leave the client before the exit is submitted. Nullifiers are the obvious case (see the new leak issue under #9, where the wallet already gets this wrong). Proof generation adds a second one: the proof needs the Merkle path to the leaf, and zkTree_getMerkleProof(leaf_index, at_block) asks the node for that one index, which names the leaf, and a leaf carries its recipient and amount in the clear. Options to weigh before writing code: build the path locally from ZkTree::Nodes read in whole levels or large ranges, or ask for the target's proof among a shuffled set of decoy indices. Whichever is chosen, a test should assert the leaf index being spent never appears alone in a request.
Operator decision, 2026-09-16: the wrong-key case is not worth staging. Refusing a manifest whose signature does not match the configured public key is tauri-plugin-updater's own verification (minisign-verify), not code in this repository; a bug there belongs to the plugin. What this repository owns is the key in tauri.conf.json, the signing in the release workflow and the manifest it publishes, and those are proven by the 0.1.0 → 0.2.0 update above. Closing. Windows and macOS manifest entries arrive with their builders in #34.
The update path works end to end with 0.1.0 → 0.2.0 (release run 7876):
blackbeard-wallet_0.1.0_amd64.AppImage (it reports install appimage, updater on) and unlocked a wallet.--version now prints blackbeard-wallet 0.2.0), the app relaunched to the lock screen, and app_info in the relaunched process reports 0.2.0.The manifest's AppImage signature was also verified independently against the public key in tauri.conf.json for 0.1.0. Still owed for the acceptance: the negative case, a manifest signed with a different key refused. That needs a manifest served at the configured endpoint with a wrong signature, which I have not staged against the live rpm.lair.cafe path. Windows and macOS entries come with #34.
dff158e fix(release): the workspace crates' exact pins move with the version91ec5fb chore(release): 0.2.042d226c feat: add accounts to an existing walletWhich scheme is the default, from Quantus's own clients: quantus-cli (--scheme default_value_t = DilithiumScheme::MlDsa65; README "default scheme: ml-dsa-65, HD path …/1'"), the mobile SDK (DilithiumSchemeExtension.current = mlDsa65, legacy = mlDsa87), the cold wallet ("New wallets stay ML-DSA-65 unless the user opts into ML-DSA-87") and the browser extension (DEFAULT_TYPE = 'dilithium65'; ML-DSA-87 "must remain importable but is never offered as a choice", and it shows the ML-DSA-65, ML-DSA-87 and wormhole accounts together). The chain node's own key command still defaults to 87, and docs.quantus.com gives no guidance. So: ML-DSA-65 current and first, ML-DSA-87 shown beside it labelled legacy.
Not done here, noted for later: the mobile wallet's import goes further, running a BIP-44 gap-limit scan over account indices (m/44'/189189'/<i>'/0'/<scheme>') for both schemes against its indexer, so a phrase that was used for account 2 or 3 shows those too. This wallet derives account 0 under each scheme. A child for discovery can follow if it matters.
cb0fe53 feat: both ML-DSA accounts from every phrase, and several wallets open at onceAdded after the first install (operator feedback, 2026-09-16): #65 (no scheme choice; derive and show both ML-DSA accounts from one phrase, the one Quantus steers people to as default) → #66 (several wallets open at once; no screen assumes one). The epic stays open for these and #31.
wednesday, september 16, 2026 — 15:22:38 utcThe first manifest is live at https://rpm.lair.cafe/blackbeard-wallet/latest.json: version 0.1.0, linux-x86_64 pointing at the release's AppImage. Its signature verifies against the public key in tauri.conf.json (key id 442BF0EADEA536C9, Ed25519 over the BLAKE2b prehash), checked independently of the plugin. The acceptance needs two releases: run the 0.1.0 AppImage, publish 0.1.1, accept the offer, land on 0.1.1. That waits on the next tag. Windows and macOS entries arrive with #34.
Published by release run 36: blackbeard-wallet-0.1.0-1.fc44.x86_64.rpm is in fedora/44/x86_64/, listed in packages.json, and rpm -Kv on the repo host reports the lair.cafe key (aa31689b…8b2023ce) OK. It ships the binary, desktop entry, AppStream metainfo and four hicolor icons.
Install check, in a clean fedora:44 container rather than a VM: dnf config-manager addrepo --from-repofile=https://rpm.lair.cafe/lair-cafe.repo && dnf install blackbeard-wallet succeeded with the repo's gpgcheck=1, and blackbeard-wallet --version printed blackbeard-wallet 0.1.0. Launching from the application grid needs a desktop session and has not been done; that is the one part of the acceptance still owed. Closing on the rest.
Done by the v0.1.0 tag, release run 36. The Gitea release v0.1.0 carries the deb, the rpm, the AppImage with its .sig, the bare binary and SHA256SUMS. The downloaded AppImage matches its checksum and prints blackbeard-wallet 0.1.0.
Getting there took three fixes: the binary's name after the product rename and where the Tauri CLI runs (aa6ad55), and xdg-utils in the rust-gtk3 image (gongfoo b997f73), which the AppImage bundler copies in for the opener plugin. Run 35 failed on the last of these. This Gitea has no rerun API, so the tag was deleted and pushed again at the same commit; nothing had been released by the failed run.
aa6ad55 ci(release): the binary's real name, the CLI run where it finds the project44d32b4 ci: the dependency policy in the gate, and a daily advisory checkSecrets are now in place: RSYNC_SSH_KEY at the blackbeard org level (set by the operator), and on this repository RPM_SIGNING_KEY and RPM_SIGNING_KEY_ID (rpm@lair.cafe), the same key every package on rpm.lair.cafe carries. Checked against the host: rpm -Kv on the published cichlid-0.1.9-1.fc44 reports an EdDSA header signature by fingerprint aa31689b0e504e88f99bf0176620caca8b2023ce, the certify key whose signing subkey A3EFB60854C6DC9D expires 2027-04-24; the private half lives in the operator workstation's ~/.gnupg/lair keyring, where mistralrs-package's script/setup/gpg.sh generated it. The publish job can run as soon as a v* tag exists; the AppImage step in the same run still waits on the rust-gtk3 runner image picking up librsvg2-devel (gongfoo, images workflow).
9fd50f9 feat(ramp): the FiatRamp port with a stub provider, opened in the system browserThe screen exists behind the preview_swaps setting: assets from the registry that 1Click lists, amount, recipient, refund address, slippage, a dry quote, then a deposit card with QR, memo and deadline, and the orders list. Driven live: 0.001 BTC → 75.568313 USDC on NEAR, floor 74.812629, about 805 s. Reserving a deposit address is refused by 1Click without the partner credential (see #39), so the acceptance (USDC on Base to NEAR to SUCCESS) waits on #40's registration. The wording on the screen already says every pair is paid from an external wallet, since no post-quantum asset is on Intents.
c59ba49 feat(swap): orders as a state machine persisted in SQLite, and the preview screenBuilt: wallet_core::orders (the state machine, unit-tested for forward-only moves, expiry without a network call, and the backoff schedule), an OrderStore port on the SQLite file, and the follower in crates/wallet-app/src/swap.rs that resumes every open order at start and polls with backoff and jitter, persisting each move and emitting orders:updated.
What is not yet exercised end to end: a real order. 1Click answers dry quotes without a credential but refuses a wet one (400 Failed to get quote), so no deposit address can be reserved until the partner JWT exists and the relay (#40) attaches it. The "kill the app mid-order" acceptance runs the moment one real order exists; the store and the resume path it needs are in place and tested at the unit level. Leaving this open for that.
14d1a39 feat: the updater, offered never applied; and a test that greps for secrets3db8b05 feat: the wormhole address behind mining rewards, and its shielded balance82df5bf feat(swap): a 1Click client typed from the pinned spec, behind SwapProvider248e1bb feat(core): an asset registry that maps our asset ids to Intents idsBoth children are done: #36 (the asset registry, wallet-core/src/assets.toml, with the Intents ids for the transit legs and none for the post-quantum assets) and #37 (the same accounts on every profile side by side, and a foreign address refused by the name of its chain). The third child, for the second real post-quantum chain, is filed when one exists; until then Planck stands in as the second live chain and the overview already shows mainnet and Planck beside each other. Closing the epic.
34738a8 feat(ui): the same accounts on every other network, side by side9eb0a24 fix(substrate): the lints Rust 1.88 turned on04d6d62 build: a dependency policy: cargo-deny, cargo-machete, and the advisories it found418b709 feat(core): a foreign address is refused by the name of its chainEvery child is closed: #18, #19, #20, #21, #22, #23, #24 and #53. The chain layer as it stands: profiles shipped as TOML with operator overrides; a subxt config for ML-DSA signatures under the FIPS 204 context from spec 148; a connection manager with failover, backoff, a polling runtime-version watch and a measured block interval; balances and pending reversible transfers read at the best block with the finalized height alongside (the PoW finality lag is about 100 blocks); transfers, reversible transfers and cancels prepared for review, priced, signed by the session and watched; history from blackbeard.observer cached in SQLite; a pinned quantus-node --dev in CI; and the public names wss://quantus.blackbeard.observer and wss://planck.blackbeard.observer on oolon, throttled per client. Closing the epic.
cd09e77 ci: package the release binary as a Fedora 44 rpm and publish it to rpm.lair.cafeThe pipeline is in the repo (release.yaml package and publish jobs, rpm/blackbeard-wallet.spec, rpm/rpmmacros, script/generate-packages-json.py), following cichlid's workflow line for line and the claude-desktop spec for the desktop entry, icons and scriptlets. Fedora 44 only, as decided. A local build from the release binary produces blackbeard-wallet-0.1.0-1.fc44.x86_64.rpm with the binary, desktop entry, AppStream metainfo and hicolor icons; rpmlint's remaining warnings are each explained at the top of the spec.
Operator action before the first publish: this repository has no Actions secrets (checked: none on the repo, none on the blackbeard org). The publish job needs the three the other rpm.lair.cafe publishers hold as repository secrets:
RPM_SIGNING_KEY (the armoured private key for 8b2023ce)RPM_SIGNING_KEY_IDRSYNC_SSH_KEY (the gitea_ci@oolon key)Their values are only readable where they were first set, so they cannot be copied over by API. Until they are added, a v* tag still builds the bundles, attaches them to the Gitea release and builds the rpm; the run then fails at publish, and re-running that job after the secrets exist finishes the publication.
Also owed for the acceptance: the clean Fedora 44 VM install through lair-cafe.repo, once a tag has been published.
Done, 2026-09-16. The files live where the issue asked:
54db950: asset/nginx/blackbeard-nodes.conf (upstreams quantus_node → bob:9944 over the mesh, planck_node → a1/a2-planck.quantus.cat:443 with max_fails=3 fail_timeout=30s; limit_req_zone node_rpc 20r/s, limit_conn_zone node_conn), asset/nginx/quantus.blackbeard.observer.conf and planck.blackbeard.observer.conf (LE cert, TLS 1.2+1.3, limit_req burst=40 nodelay, limit_conn 16, 429 on both, 3600 s socket timeouts, 1 MiB bodies; the Planck one is proxy_pass https with proxy_ssl_server_name on, proxy_ssl_name a1-planck.quantus.cat, verification against the system CA bundle, since both hosts sit behind one *.quantus.cat certificate), and a nodes role in script/infra-setup.sh that creates the CNAME to bl.thgttg.com, issues the certificate, installs the vhosts and enables them only after nginx -t passes.404c65a: bob's matrix entry gains rpc_extra_sources: "oolon.kosherinata.internal", resolved on the node into a /32 rich rule for quantus-node-rpc beside the site-subnet rule. Applied by hand on bob with the same command, then codified.Acceptance, as run:
| check | result |
|---|---|
| system_chain over HTTPS | Quantus and Planck |
| system_chain over WSS | both answer; chain_subscribeNewHeads on the quantus name delivered a head |
| throttling | 120 requests from one address, 40 in parallel: 19×200, 101×429; five from bob at the same time all 200 |
| certificates | both to 2026-12-15, certbot-renew.timer enabled and active on oolon, host-wide reload hook present |
| wallet profiles | list only wss://quantus.blackbeard.observer / wss://planck.blackbeard.observer; the ignored planck_accepts_the_signature… test passes against the public name |
Two notes. nginx open source has no active health checks, so "taking one Planck upstream out of DNS" is covered by passive max_fails/fail_timeout (a failing server is skipped for 30 s) rather than tested by pulling a record; and a resolver that looked the quantus name up before the record existed caches the miss for a while, which is what the script's own final curl hit.
3b7089c ci: a release workflow on a version tag, and bundles named for packaging41c3ba7 feat: history from blackbeard.observer, cached in SQLite, on its own screenDecision taken as a working default (overturn here if wrong): history comes from the observer, not a relay.
Why: blackbeard.observer already indexes every chain the wallet has a profile for (quantus, planck; heisenberg too) and already serves GET /v1/chains/{chain}/accounts/{address} with the account's merged event and extrinsic activity, keyset-paginated by before_height, before_source, before_index, 100 rows a page, CORS open for GET, behind the same nginx on oolon the node endpoints will use. Transfers in and out, reversible schedule/cancel/execute and wormhole exits are all rows in that feed with the addresses already mapped to SS58. So the wallet reads that route through a HistorySource port in wallet-core with an observer adapter in wallet-data, filters the rows it cares about, and caches them in SQLite under the data directory so history reads with the network down.
What this leaves for later, on the observer side: a dedicated transfers-only route (accounts/{address}/transfers) that filters on the server and pages by height alone. The existing GIN and (chain, height desc, event_index desc) indexes already serve it; it is an optimisation, not a prerequisite, and it ships as a Rust change alone (no asset or deploy change).
The relay crate stays reserved for swaps (#7) and ramps (#8), where the wallet needs something that is not a public read.
Profiles gain an indexer URL (https://blackbeard.observer/v1/chains/quantus, …/planck); the dev profile has none, and the history screen says so rather than pretending.
736f4d3 feat(app): webkitgtk quirks decided at startup and logged, and the smoke checklistThe countdown is built as specified: blocks remaining times the interval measured over the last 32 best heads, never a hard-coded figure. Measured on the dev node.
| | | |---|---| | window | 60 blocks, listed with 49 remaining | | measured interval at the time | 1.1 s | | estimate | 52 s | | actual arrival of the execute block | 65.6 s | | error | 13.7 s, about 12 blocks at the measured pace |
The dev node's pace over that minute swung between 0.6 and 1.7 s per ten blocks. That is not a dev-node quirk so much as proof of work: block arrivals are close to Poisson, so the error of a countdown over N blocks grows like the square root of N times the mean interval. Over 49 blocks that is about 7 blocks of one standard deviation on any PoW chain, Heisenberg included. "Within one block" is therefore only achievable for the final block or two, not for the whole window.
What the UI does about it: the figure is labelled "about" when measured and "nominally" when it is the profile's block time, and the block number itself is shown alongside, which is exact. The check against Heisenberg is still owed once #53 gives the wallet a public testnet endpoint; the criterion there should be read as "the estimate converges on the actual time as the window closes", which is what a per-head refetch gives.
One more thing surfaced on the way: a cancel's review showed the transaction id as an SS58 address, because the decoder treated any 32 bytes as an account. It now decides by the argument's type in metadata; a unit test pins it.
wednesday, september 16, 2026 — 05:32:12 utcdc3a87c feat(ui): pending reversible transfers with a measured countdown and a cancel52774f1 feat(ui): send: a form, a review of what Rust decoded, then sign and watche4cde9a feat(ui): receive: address, QR, checkphrase and copy; accounts named in place7101ead feat(ui): onboarding: create a phrase, restore one, or open a quantus-cli file2fcc519 fix(app): a dev CSP so the Vite page renders, one data directory, and a way to verify the webviewfbb7c71 feat(ui): the app shell: lock screen, sidebar, network switcher, status bar, settingsbe2644f ci: a pinned quantus-node dev chain in the gate, and a script to run one locallyDone. #14 through #17 are closed by commits on main. Against the definition of done:
quantus wallet view (Indicate-Dew-Undamaged-Axis-Obtain).quantus wallet list with the expected address.Debug prints a secret: every holder redacts, and a Linux test scans the process's writable memory after lock() for a runtime-built seed and secret and finds neither, in debug and release.Deferred as its own follow-up when wanted: the OS keychain for the unlock secret. Password-only with a ten-minute idle lock is what ships.
tuesday, september 15, 2026 — 19:59:12 utcDone. #11, #12 and #13 are closed by commits on main; the definition of done holds: every push runs the gate on rust-gtk3 (first green run 4 min 24 s), a hand-edited generated type went red on PR #64's drift step, a clippy warning fails under -D warnings, the isolation pattern is on with a commented TOML capability file whose test refuses an undescribed permission, and cargo tauri dev shows a version that crossed the boundary as a typed struct. Not done and deliberately deferred: sccache, until wall clock costs a session.
95bcfe9 feat(substrate): transfers prepared for review, priced, signed by the session, submitted and watched26559ee feat(substrate): balances, nonce and high-security status, refreshed on every finalized head78c0612 feat(substrate): a supervised connection per chain, failing over and following upgrades1ca42fd feat(substrate): a subxt config for quantus, and offline signing under the contextf0b3c26 feat(profiles): chains as data, shipped in the binary and overridable by file18caa2b feat(session): password in, signatures out, locked again after idlingde55d1d feat(mnemonic): generate, validate, verify the backup, and the checkphrase5776cfd feat(keystore): read and write quantus-cli wallet files, byte for byteDone, with the runner as it is; no image change was needed.
main): green on rust-gtk3, 4 min 24 s wall clock for a cold Tauri build plus the webview gate.ui/src/api/generated/Amount.ts): red on the generated types are current step, with the diff in the log. PR closed unmerged, branch deleted.-D warnings, verified locally; the workflow runs the same command.ui/src imports Tauri's IPC module (see #13).Not done, and deliberately: sccache. The cold build is most of the 4½ minutes, but it is not yet the bottleneck on anything; revisit when a run's wall clock starts costing a session.
tuesday, september 15, 2026 — 15:13:36 utc8015ca8 feat(keys): ML-DSA key schemes, derived at a BIP44 path, signed under a contextd951ce0 feat(ui): a typed command client, and one file allowed to touch the IPCc9b623c feat(app): isolation pattern on, and a capability file that must say whyThis epic is deferred to the end of the plan, after every other epic. It depends on the wallet being useful first: a swap needs takers who already hold and move QTC in this wallet, and the taker flow (#61) is built on the v1 send review (#28) and the order state machine (#39) in any case. Nothing here starts, including the spike (#55) and the Bitcoin node (#56), until the desktop, packaging and Intents epics have shipped and there are users to swap.
The design stays filed as written so the decisions and corrections above are not lost.
tuesday, september 15, 2026 — 15:01:36 utcThe epic body and the original #62 assumed Bisq-style security deposits from both parties. That does not fit this market: only miners and the team hold QTC, so a QTC bond is a barrier to entry for buyers, and buyers are the side without which nothing works. A seller bond adds nothing either, because the judge already denies a seller any gain from walking away once BTC is paid.
Bonds are removed from the design. What bounds a no-show instead, now specified in #62: a payment deadline measured in minutes and enforced as the refund proposal's expiry, automatic refund approval by the arbiter when the deadline passes unpaid, mempool acceptance extending the deadline into the confirmation window, and maker-declared exposure and per-taker limits enforced at trade creation. The seller's cost of a no-show becomes the payment window plus proposal fees, priced into the maker's spread.
Where the epic body says "bonds and unbonded caps" under the arbiter's griefing concern and lists a bond policy as an operator decision, read #62 instead; the body is left as written so this correction stays legible.
tuesday, september 15, 2026 — 14:54:48 utcChildren, in dependency order: #55 (multisig spike on a dev node) and #56 (bitcoind on our infra) can start now, in parallel → #57 (protocol document and entities) → #58 (wallet-arbiter), #59 (coordinator in wallet-api) → #60 (wallet-maker), #61 (taker flow in the wallet) → #62 (bonds and limits) → #63 (rehearsal on Planck plus signet, then mainnet).
Decisions this epic needs before #58: fee rate and fee account, confirmation tiers, caps and kill-switch holder, hosts for bitcoind and the arbiter. Bond sizes before #62. Recorded as comments on those issues when made.
Sequencing note: #57 depends on #55's measured facts, and #61 depends on #28 and #39 from the desktop and Intents epics, so the taker screens land after the v1 send flow exists.
tuesday, september 15, 2026 — 14:42:30 utcFedora 44 only for v1. Prebuilt-binary spec from the rust-gtk3 runner, packaged on rpm, published to fedora/44/x86_64/ on rpm.lair.cafe. No source build under mock for now.
If the distro/version matrix ever grows, prefer COPR over widening our own pipeline. Two reasons recorded here so the trade-off is not re-litigated later: it is free build infrastructure, and it gives an external guarantee of build-environment hygiene (clean mock chroots per target, nobody's workstation or runner image in the chain) that a self-hosted matrix cannot offer. COPR builds from a source RPM, so that path is the spec-with-vendored-crates variant (option 2 above), and the rpm runner already carries copr-cli; git.lair.cafe/actions/copr-publish exists as the action. The wallet is GPL-3.0-or-later, which COPR's licensing policy accepts. Users would then get the package from COPR rather than rpm.lair.cafe, or rpm.lair.cafe could mirror COPR's output; decide when the matrix actually grows.
Pointer for the desktop-app precedent: the claude-desktop package on rpm.lair.cafe comes from lair/claude-desktop-package on git.lair.cafe. Both lair/vibe-kanban (rpm/vibe-kanban.spec, .gitea/workflows/host-release.yml) and its comments name that repo as the house convention for packaging a desktop application (no debuginfo, prebuilt binary, package-and-publish on the rpm runner). Start from its spec for the .desktop entry, hicolor icons and AppStream metainfo; it is not checked out on the workstation, so clone it first.
Correction (2026-09-15): the testnet profile is Planck, no caveat. planck.blackbeard.observer proxies to Quantus's official a1/a2-planck.quantus.cat; we run no testnet node. Planck is in the wallet only to prove the multi-chain UX, so its profile should be labelled testnet and carry nothing mainnet does not. Heisenberg gets no profile.
The caveat above was wrong on two counts, and the correction belongs here rather than in a diff.
a1-planck.quantus.cat and a2-planck.quantus.cat both answer system_chain → Planck today, as do a1/a2-heisenberg. The observer's deployed config template already lists both pairs; the stale working copy I read did not.Planck's role is narrow and deliberate: it exists in the wallet purely to prove the multi-chain UX works, until a real post-quantum mainnet that is not Quantus is supported. It is not a second product surface.
So: quantus.blackbeard.observer → bob; planck.blackbeard.observer → a1/a2-planck.quantus.cat. Same convention, same throttling, no auth on either. #53 is updated to match.
Added #53 (public node endpoints on oolon, throttled, routed to bob), the infrastructure half of the decision recorded on #20. It has no code dependency and can start now; #20 consumes its result.
tuesday, september 15, 2026 — 13:01:07 utcEndpoint decision from #20 (2026-09-15): each profile's endpoint list is one public name per chain, wss://<chain>.blackbeard.observer, no auth. Mainnet is quantus.blackbeard.observer; the testnet profile is planck.blackbeard.observer unless the Planck-versus-Heisenberg caveat on #20 resolves the other way. The profile's chain id should match the DNS label so the convention is one string.
Node RPC and WebSocket are public, no auth, throttled. One name per chain, so the convention scales to every chain the wallet learns:
quantus.blackbeard.observer for mainnetplanck.blackbeard.observer for the testnet leg, so a two-chain wallet is testable end to end even though Planck is a testnetEach name is TLS-terminated with Let's Encrypt on oolon's nginx, configured as an IP load balancer (an upstream per chain) that routes to the single node on bob for now and gains servers as nodes are added. Fairness comes from nginx throttling (limit_req on the HTTP JSON-RPC path, limit_conn per client on the WebSocket upgrade, since limit_req only sees the handshake), not from credentials.
Consequences for this issue: the endpoint list in the mainnet and Planck profiles is exactly those two names; the wallet does no authentication; failover is nginx's job on the server side and reconnect-with-backoff on the wallet's. The infrastructure itself is filed separately as a child of #3.
The observer's config.toml records that the one node we ran for Planck now mines mainnet, and that Heisenberg is served from a Quantus-team endpoint (a1-heisenberg.quantus.cat), not ours. So planck.blackbeard.observer needs a Planck node stood up on our infra first, and it is worth confirming Planck is still the testnet Quantus keeps alive (the mobile app, the extension fork and the observer all test against Heisenberg today). If Heisenberg is the better-supported testnet, the same convention gives heisenberg.blackbeard.observer and nothing else changes.
Children: #49 (dependency policy in CI) can start as soon as #12 is green; #48 (threat model) and #50 (secret handling audit) follow #11 and #17; #51 (reproducible builds) follows #33; #52 (external review) is last and gates the first tagged release.
tuesday, september 15, 2026 — 12:42:48 utcChildren, in dependency order: #45 (wormhole derivation and shielded balance) → #46 (proof generation, circuit binaries) → #47 (exit). #46 needs the circuit-binary host decided.
tuesday, september 15, 2026 — 12:42:46 utcChildren, in dependency order: #43 (FiatRamp port, relay session, system browser) → #44 (first provider, webhooks). #44 is blocked on choosing and signing with a provider.
tuesday, september 15, 2026 — 12:42:44 utcChildren, in dependency order: #38 (1Click client) → #39 (orders state machine) and #40 (wallet-api relay) → #41 (swap UI, preview) → #42 (the Quantus leg: spike and the memo question). #40 needs the NEAR Intents partner registration first.
tuesday, september 15, 2026 — 12:42:43 utcChildren, in dependency order: #36 (asset registry) → #37 (accounts across profiles, addresses refused by name). A third child for the second real PQ chain is filed when one exists.
tuesday, september 15, 2026 — 12:42:41 utcChildren, in dependency order: #32 (release workflow) → #33 (Fedora RPM and signed dnf repo), #34 (Windows and macOS builders, signing) → #35 (updater). Operator decisions on #33 (repo host, GPG key) and #34 (Apple and Windows signing identities) are needed before those start.
tuesday, september 15, 2026 — 12:42:38 utcChildren, in dependency order: #25 (app shell) → #26 (onboarding) and #27 (accounts and receive) → #28 (send with Rust-decoded review) → #29 (pending reversible transfers); #30 (history) from #25 and #23; #31 (platform smoke) last.
tuesday, september 15, 2026 — 12:42:37 utcChildren, in dependency order: #18 (chain profiles) → #19 (subxt config) and #20 (RPC to our nodes) → #21 (balances and heads) → #22 (transfer with reversal). #23 (history via indexer) and #24 (dev node in CI) run in parallel from #18 and #19 respectively.
tuesday, september 15, 2026 — 12:42:34 utcChildren, in dependency order: #14 (ML-DSA key schemes) → #15 (quantus-cli keystore) and #16 (mnemonic lifecycle) → #17 (unlock session). OS keychain is a follow-up to #17, filed when #17 lands.
tuesday, september 15, 2026 — 12:42:33 utcChildren, in dependency order: #11 (isolation and capabilities), #12 (CI green on rust-gtk3), #13 (typed command client). The scaffold commit on main is the starting point for all three.