Skip to content

Port libwallet golang part to rust#2

Open
MagicalTux wants to merge 148 commits into
masterfrom
rust-port
Open

Port libwallet golang part to rust#2
MagicalTux wants to merge 148 commits into
masterfrom
rust-port

Conversation

@MagicalTux

Copy link
Copy Markdown
Member

This is a major rewrite of the whole go code to rust, using our own crates

It will allow us to interact with native elements of the system while not walking around with go's runtime

MagicalTux and others added 30 commits July 3, 2026 11:31
Start the Rust backend port on a dedicated branch. This lands the C-ABI
boundary that replaces cshared/ffi.go, exporting the exact five symbols the
Dart client resolves (LibwalletInit/Request/SetEventCallback/Destroy/Free,
plus ShowDebug) with a matching JSON request/response envelope.

apirouter/pobj/typutil are dropped: requests are dispatched by a plain match
on the path string (handlers::route) instead of reflection-based routing.

Included:
- rust/ cargo workspace; ffi crate builds cdylib/staticlib/rlib
- handle registry with shutdown-vs-inflight ordering (mirrors the Go
  WaitGroup barrier so no callback fires after Destroy returns)
- panic guards on every extern "C" body; CString ownership handed to the
  consumer and reclaimed via LibwalletFree
- Info:ping and Info:version handlers as the first working endpoints
- env stub (creates data dir); grows into the graphitesql-backed env in Phase 1
- 5 end-to-end FFI roundtrip tests (all passing)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the wltbase crate: the SQLite-backed environment and config/cache/current
accessors, ported from wltbase/{db,current,env}.go with byte-identical schema.

Schema ground truth was captured from a Go-produced sql.db: table names and
PascalCase column names match psql exactly (KvConfig/Cache/CurrentItem), so an
existing database opens and round-trips unchanged. Timestamps are RFC3339 text
(read flexibly, written at nanosecond precision); first_run is seeded as the
16-byte wltobj.TimeId layout; version is {0,0,0,4}.

graphitesql's Connection is single-threaded (not Send), and the global handle
registry requires Send+Sync, so the connection lives on a dedicated actor
thread and is reached over a channel — Env is Send+Sync, safe to share across
the FFI request workers, and access is naturally serialized (as SQLite wants).

The ffi crate now uses wltbase::Env instead of its stub, and Info:paths /
Info:first_run are wired as the first DB-backed endpoints.

Tests: 6 wltbase (config/cache-TTL/current + opening a real Go sql.db) and
7 FFI roundtrip (incl. the two DB-backed endpoints). All passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the universal value types used across Asset/Transaction/Quote/Token and
all IDs. Verified byte-for-byte against the Go wltobj by generating reference
vectors from the real implementation and asserting the Rust port reproduces
them exactly.

Amount: arbitrary-precision decimal (num-bigint significand + exponent) with
the MAX sentinel, exact String() rendering, the {v,e,f} JSON shape (accepting
string/number/object on input, "MAX" round-trip), and the versioned binary
encoding (v0 magnitude-only, v1 signed). set_exp reproduces Go's half-away-
from-zero rounding; add/sub/mul/div/cmp are the pure-integer paths. The
big.Float helpers (from_float, reciprocal, fixed-decimals string parse) are
deferred with an explicit error rather than a silent mis-parse.

TimeId: text form "<type>:<unix>:<nano>:<index>" (empty type -> "nil"), the
16-byte big-endian sortable encoding, and string/JSON round-trips.

Both implement serde Serialize/Deserialize so the object models (next) can
derive. Tests: 7 vector/round-trip tests, all passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Establish the reusable object-model pattern that the ~12 Go pobj-registered
types will follow, and land the first model end-to-end.

wltbase: add a cross-crate generic query layer — a SqlValue enum plus
Env::query/exec/ensure_table over the DB actor — so model crates run
parameterized SQL and map rows<->structs without depending on graphitesql's
own Value type. Expose now_rfc3339() as the shared timestamp helper.

wltcontact: the Contact model (serde field renames matching the Go JSON keys),
its psql-compatible table DDL, and fetch/list/create over the generic layer.
xuid-rs generates the "ct"-prefixed ids. Address normalization via outscript
(the Go validate) is deferred to the address pass; the type is validated now.

ffi: verb-aware object dispatch — GET Contact with an Id fetches, without lists;
POST creates. Model tables are created at LibwalletInit (init_models), mirroring
the Go per-package InitEnv.

Tests: 3 wltcontact CRUD + an FFI create/list/fetch roundtrip. 24 workspace
tests pass, zero warnings, release cdylib exports all 6 symbols.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second model following the Phase 2b pattern, confirming it reuses cleanly:
the wltcrash crate (Crash struct with Go-matching JSON keys, psql-compatible
DDL, fetch/list + an internal log() that writes rows with a UUIDv4 id), plus
the Crash GET (fetch/list) route and init_models wiring in ffi.

Tests: 1 wltcrash log/fetch/list + an FFI Crash:list roundtrip. 26 workspace
tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the read surface of the central wallet package: the Wallet and WalletKey
models, their psql-compatible tables, and fetch/list (a Wallet embeds its
Keys). Wallet creation is TSS keygen and is deferred to Phase 3 — POST Wallet
returns 501 for now.

WalletKey.Data (the encrypted share) is #[serde(skip)]: loaded for internal
use but never emitted to the host, matching the Go json:",protect" tag. The
Dart client reads only Id/Wallet/Type/Key/Gen; a test asserts the share bytes
never appear in the serialized JSON.

Note: the full-column DDL matches the current Go struct (incl. Protocol/Schema,
which the stale test fixture predates). Old-DB column migrations (ALTER TABLE
ADD COLUMN) are a separate concern for the compatibility pass.

Tests: 4 wltwallet read tests + an FFI Wallet list/501 roundtrip. 31 workspace
tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review feedback: one crate per Go package was over-structuring. Fold the
member crates (wltbase, wltobj, wltcontact, wltcrash, wltwallet, wltacct) into
a single `libwallet` crate with modules:

  src/{db,env,error}.rs      (was wltbase)
  src/{amount,timeid}.rs     (was wltobj)
  src/models/*.rs            (was the per-object crates)
  src/{lib,handle,dispatch,response}.rs + src/handlers/*  (FFI + dispatch)

Cross-crate `wltX::` paths become intra-crate `crate::` / `crate::models::X`.
The crate root re-exports Env/Error/Result/SqlValue/now_rfc3339/Amount/TimeId.
Build is now one Cargo.toml producing the cdylib; no workspace.

No behavior change: all 31 tests pass, zero warnings, release cdylib exports
the same 6 symbols.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Account model (src/models/account.rs) and its route landed with the
consolidation; add the tests that were pending: model fetch/list/for_wallet
(IL passed through as JSON, Dart-key JSON shape) and an FFI Account list/501
roundtrip. 34 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the Asset model: lowercase JSON keys (Go json tags) with Created/Updated
staying PascalCase, Amount persisted and read back as its {v,e,f} JSON via the
ported wltobj::Amount. Fetch/List only (balances are discovered, not created);
POST returns 405. Unique index on Key matches the psql schema.

Tests confirm a 1-ETH amount (1e18/exp18) round-trips through the DB to
"1.000000000000000000" and the JSON uses lowercase keys with the amount object.
37 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the Network model with its custom JSON (Go Network.MarshalJSON): omits
CurrencyDecimals/Priority, adds computed ResolvedBlockExplorer /
TxHistoryProvider and, for EVM, EVM_Info from the ethrpc-rs chain registry
(ethrpc_rs::chains::get). Fetch supports the "@" current-network shortcut and
the "type.chainId" ephemeral form; list is Priority-ordered. Creation runs
check()+RPC and default-network seeding — deferred (POST 501).

ChainInfo only derives Deserialize upstream, so EVM_Info is assembled from its
public fields (name, nativeCurrency, explorers). "auto" block explorers resolve
via the registry. Adds the ethrpc-rs 0.3.0 dep — the KarpelesLab crate whose
chains module matches Go's chains.Get (ethereum-lists).

Tests: ephemeral evm/solana/bitcoin resolution against the live registry +
stored-network fetch/list. 40 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round out the object-model read surface with the last two collection types.
Token: plain PascalCase struct, fetch/list (create discovers metadata via RPC
-> 501). Nft: lowercase JSON keys with Created/Updated PascalCase, attributes
stored as JSON and parsed into NftAttribute {trait_type,display_type,value};
DB columns are the PascalCase field names. Fetch/list only.

With this, all eight object collections (Contact, Crash, Wallet, Account,
Asset, Network, Token, Nft) plus Info are ported for reads. 42 tests pass,
zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the Transaction model — the richest one: lowercase JSON keys with
omitempty, three Amount fields (fee/amount/value), and the Raw BLOB emitted as
base64 (matching Go's []byte JSON). Fetch/list, Created-desc ordered. Build/
sign/broadcast is deferred to the tx pass (POST 501).

With this, every stored entity now has a working read path: Contact, Crash,
Wallet+WalletKey, Account, Asset, Network, Token, Nft, Transaction, plus the
Info endpoints — all behind the same FFI the Dart client uses, on the byte-
compatible schema. 44 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Break ground on the crypto core with the compat-critical envelope: a keystore
module that seals a payload into a bottlers Bottle encrypted to recipient
public keys and CBOR-encodes it (the WalletKey.Data wire format), and opens it
back with the private keys. bottlers is byte-compatible with the Go
cryptutil/gobottle format, so shares written by the Go build will open here.

Includes password_to_ed25519 (PBKDF2-HMAC-SHA256, 4096 iters, salt = WalletKey
UUID) matching Go passwordToEd25519, and ed25519_from_seed for the StoreKey
path. purecrypto::ec::Ed25519PrivateKey backs both.

Tests: password seal/open round-trip, wrong-password rejection, short-password
guard, raw-seed round-trip. 48 tests pass, zero warnings, release cdylib
exports the 6 symbols.

Next: the TSS layer (tsslib keygen/sign) and wiring WalletKey decrypt to real
shares.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The crypto-signing center, working in Rust via tsslib. tests/tss.rs implements
an in-memory hub (replicating tsslib's pub(crate) test hub: per-party broker
with peer list; outbound routes to the destination's inbound, inbound
dispatches to the connected handler or buffers as pending). The ceremony runs
single-threaded — brokers wired first, then all Keygens constructed, so
messages to not-yet-connected parties buffer and flush on connect and the
rounds cascade to completion with no cross-thread locking (no deadlock).

Proven:
- 3-party FROST DKG: all shares validate_basic() and converge on one group
  public key; distinct secret shares; Go-compatible Key JSON round-trips.
- 2-of-3 signing: the committee produces one agreed 64-byte Ed25519 signature
  (R||S consistent, over the given message).

tsslib is a dev-dependency for now (the production broker is spotlib-backed and
lands with wltwallet signing), so the shipped cdylib stays lean — still exports
the 6 symbols. 50 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Promote the proven FROST ceremony from a test into a real library capability:
src/tss.rs exposes frost_keygen_local(n, threshold) and frost_sign_local(
committee, threshold, msg) over an in-process LocalHub broker. This is the
all-on-device path (StoreKey/Password/Plain shares, no RemoteKey party) that
an all-local wallet uses to generate shares and produce a 64-byte Ed25519
signature; cross-device signing plugs a spotlib-backed broker into the same
tsslib APIs.

tsslib moves to a real dependency (the cdylib now carries the TSS code).
tests/tss.rs is slimmed to drive the production API: 3-share keygen converging
on one group key, 2-of-3 signing over two different committees, small-committee
rejection, and Go-compatible Key JSON round-trip. 51 tests pass, zero warnings,
release cdylib exports the 6 symbols.

Next: wire WalletKey.Data decrypt (keystore) -> load tss Key -> frost_sign_local
into Account signing, and frost_keygen_local into Wallet:create.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Compose the two crypto pillars into the flow that Wallet:create +
Account:signAndSend perform for on-device wallets: generate FROST shares,
encrypt each into the WalletKey.Data form (a bottlers bottle over the Key JSON,
sealed to a per-share password-derived key), then later decrypt a committee and
produce a signature.

tests/wallet_lifecycle.rs proves keygen -> encrypt -> store -> decrypt -> sign
yields a 64-byte Ed25519 signature, that plaintext shares never survive in the
stored blob, that each decrypted share matches the group key, and that a wrong
password can't unlock a share. 53 tests pass, zero warnings.

The remaining Wallet:create/Account:sign work is now DB + KeyDescription
plumbing around this proven crypto core.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The share-protection descriptor for Wallet:create. src/sign.rs ports
KeyDescription {Type, Key, Id} (Go-matching JSON) with resolve(salt) ->
Recipient: StoreKey parses a base64url PKIX pubkey (keystore::
public_key_from_pkix_b64 over bottlers::pkix), Password derives via
passwordToEd25519, Plain stores unencrypted, RemoteKey is a remote party.
Added keystore public_key_{from,to}_pkix_b64 helpers.

Tests: StoreKey and Password shares resolve to a recipient and round-trip
(seal -> open); Plain/RemoteKey/unknown resolve correctly; JSON parses from the
Wallet:create params shape. 57 tests pass, zero warnings.

With this, every crypto/parsing building block Wallet:create needs is ported
and tested (KeyDescription, keygen, seal, decrypt, sign, full lifecycle); the
endpoint itself is assembly + DB persistence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add tss::frost_keygen_with_parties(party_keys, threshold): keygen with explicit
per-party keys. Production Wallet:create derives each party's tss id from its
WalletKey UUID bytes (matching Go, which sets the PartyID key from
WalletKey.Id.UUID), so shares are keyed consistently for later signing.
frost_keygen_local now delegates to it with index keys.

Test: keygen with three 16-byte UUID-style party keys converges on one group
key and a 2-of-3 committee signs. 58 tests pass, zero warnings.

This is the last crypto piece Wallet:create needs; remaining is group-key
compression (curve25519-dalek, version-matched to tsslib) + DB persistence of
the Wallet/WalletKey rows, then the reverse for Account signing. Exact recipe
recorded in memory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…create)

Add tss::frost_group_pubkey(key) -> [u8;32]: the compressed Ed25519 group key
(Wallet.Pubkey), via purecrypto's EdwardsPoint::compress() — no new dep, since
tsslib and this crate share the one purecrypto version (the group key IS a
purecrypto edwards25519 point). Add tss::ed25519_verify for standard-verifier
checks.

Capstone test: a 2-of-3 FROST aggregate signature verifies under the derived
group pubkey exactly like any Ed25519 signature (and a tampered message fails).
This proves both that frost_group_pubkey yields the correct Wallet.Pubkey bytes
and that the whole ceremony produces chain-valid signatures.

With this, every crypto operation Wallet:create / Account:sign need is ported
AND verified: keygen (party-keyed), group pubkey, share encrypt/decrypt,
threshold sign, external verification. Remaining for the endpoints is DB
persistence + serialization (INSERT the Wallet/WalletKey rows; the reverse for
signing). 59 tests pass, zero warnings, release cdylib exports the 6 symbols.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the Wallet POST 501 stub with a real create, assembling the ported +
verified crypto into the write path. models::wallet::create: generate FROST
shares party-keyed by each WalletKey's UUID (matching Go), derive the group
pubkey (base64url), random chaincode, encrypt each share per its KeyDescription
(Password/StoreKey seal, Plain wrap) into WalletKey.Data, then INSERT the Wallet
+ WalletKey rows. Added keystore::wrap_plain. The FFI POST Wallet handler parses
{Name, Curve, Keys[]} and returns the created wallet; the encrypted share never
crosses the boundary.

Tests: model create (well-formed + persisted + share protected + reload) and an
FFI create->list roundtrip that builds an ed25519 wallet from three password
shares. secp256k1/DKLs23 and RemoteKey shares are still rejected (next).
62 tests pass, zero warnings, release cdylib exports the 6 symbols.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add models::wallet::sign_frost_local(env, wallet_id, unlock, msg): load the
wallet's Password-protected shares named in `unlock`, re-derive each unlock key
from its password + WalletKey UUID salt, decrypt (keystore::open) to the FROST
Key, reconstruct the committee (PartyId keyed by the same UUID), and run
frost_sign_local. The result is verified against the wallet's stored group
public key before returning (defense in depth) — this is the crypto behind
Account:signAndSend for on-device wallets.

Test: create a 3-share ed25519 wallet, then sign with two different 2-share
committees (both 64-byte sigs that self-verify), and confirm a wrong password
fails. 63 tests pass, zero warnings.

The full on-device wallet lifecycle now works end-to-end through real code:
Wallet:create (FFI) -> keygen -> encrypt -> persist, then unlock -> decrypt ->
threshold-sign -> verify. StoreKey/RemoteKey unlock, secp256k1/DKLs23, and the
Account/Transaction endpoints build on this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the Account POST 501 with real derivation. models::account::create:
for a solana account on an ed25519 wallet, the account key IS the wallet group
pubkey (path "m", no HD — TSS can't do hardened ed25519 children), base58 the
32 bytes for the address, set "solana:<addr>" URI, persist the Account row, and
mark it current — matching Go CreateAccount/init. Adds the bs58 dep. The FFI
POST Account handler parses {Wallet, Name, Type, Index}.

Tests: model create (address/path/uri/pubkey, persisted + current) and an FFI
wallet->account create flow. ethereum/bitcoin (secp256k1 HD via outscript) still
rejected. 65 tests pass, zero warnings.

Two write endpoints now work end-to-end (Wallet:create, Account:create) on top
of the verified crypto core.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire Account:signMessage: decode the base64 Message, take the Password unlock
material from Keys[], sign via wallet::sign_frost_local, and return the Solana
signature base58-encoded (matching Go accountSignMessage's canonical encoding).

This closes the first full user journey end-to-end through the real FFI:
  POST Wallet   -> keygen + encrypt + persist
  POST Account  -> derive Solana address from the group pubkey
  Account:signMessage -> unlock shares -> threshold-sign -> verify -> base58

An FFI test drives all three calls and checks the returned signature. 66 tests
pass, zero warnings, release cdylib exports the 6 symbols.

Remaining: signTransaction/signAndSend (build tx bytes then this same path),
live RPC (ethrpc-rs) for balances/broadcast, secp256k1/DKLs23, swap/wc/quote/
names, cross-device (spotlib), cutover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the second threshold-signature family. tss::dkls_keygen_local runs
tsslib's DKLs23 DKG — a synchronous local function (no broker), seeded with
purecrypto::rng::OsRng (the same RngCore tsslib expects, no new dep) — keyed by
the WalletKey UUIDs. tss::dkls_group_pubkey returns the 33-byte SEC1-compressed
secp256k1 key (Wallet.Pubkey) via ecdsa_pub.to_affine().to_sec1_compressed().

wallet::create now branches on curve: ed25519->FROST, secp256k1->DKLs23,
producing per-share JSON + the group pubkey through a shared shares_json helper;
the encrypt/persist path is unified (Schema = protocol). A secp256k1 wallet
persists with Curve=secp256k1, Protocol=dkls23, a 44-char pubkey, and dkls23
shares.

Tests: secp256k1 create + persist alongside the ed25519 path. 66 tests pass,
zero warnings. Remaining for EVM: eth address derivation (keccak over the
uncompressed pubkey) at Account:create, and DKLs signing for signTransaction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add src/hdderive.rs: BIP32 non-hardened public-key derivation (TSS wallets have
no private material, so accounts derive publicly from the group pubkey + chain
code) and EIP-55 address encoding, built on purecrypto (keccak256, secp256k1
point ops, from_sec1) + hmac/sha2 for HMAC-SHA512. Both verified against
standard vectors: BIP32 test vector 2 (m/0) and the EIP-55 spec addresses.

account::create now derives ethereum accounts at m/44/60/0/{index} from a
secp256k1 wallet (matching Go account.go's non-hardened path), producing the
EIP-55 checksummed 0x-address; the child pubkey is stored compressed. Solana
(ed25519) and ethereum (secp256k1) account creation both work; bitcoin
(outscript) still pending.

Tests: EVM account create (address shape, per-index divergence, persistence)
plus the derivation unit tests. 69 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add tss::dkls_sign_local(sorted_keys, threshold, hash) -> (r, s, v): DKLs
signing is synchronous (no broker), like keygen — the first threshold+1 parties
of the full sorted key set form the committee, producing standard ECDSA
scalars + recovery parity, seeded by purecrypto OsRng.

Test: DKLs keygen (UUID-keyed) yields a 33-byte compressed group key, and
signing a 32-byte digest returns 32-byte r/s and a recovery bit. Both secp256k1
threshold-signature operations (keygen + sign) now work in Rust.

Wiring into Account:signTransaction (build the tx digest, then this) needs the
all-shares unlock nuance for DKLs (sign indexes the full key array); that plus
tx serialization is next. 70 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The full EVM signing path, cryptographically verified. src/evm.rs builds a
legacy EIP-155 transaction, signs its keccak digest with the wallet's DKLs
shares under the account's HD derivation tweak, normalizes to low-s (EIP-2),
sets the EIP-155 v, and RLP-serializes.

Key pieces:
- hdderive::derive_pub_tweak returns the accumulated BIP32 tweak (Σ IL_i mod n)
  alongside the child pubkey; Account:create(ethereum) stores it as the IL.
- tss::dkls_sign_local_tweaked wraps dklstss::sign_with_tweak so the signature
  verifies under group_key + tweak·G = the derived account key.
- wallet::dkls_sign_digest unlocks all DKLs shares and signs with the tweak.

Gold-standard test: a DKLs-signed tx built for a derived ethereum account
recovers (outscript ecrecover) to that account's own address — proving tweak
derivation, threshold signing, low-s, EIP-155, and RLP assembly are all correct.
Both chain families now sign: Solana messages (FROST) and EVM transactions
(DKLs). 71 tests pass, zero warnings, release cdylib builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Thin glue over the verified evm::sign_legacy_tx: parse {Id, Transaction{nonce,
gas,gasPrice,to,value,data,chainId}, Keys[]}, unlock the DKLs shares, sign, and
return the raw signed tx as 0x-hex. Registered as the static
"Account:signTransaction" endpoint.

FFI test drives the full EVM journey: create secp256k1 wallet -> derive
ethereum account -> sign a legacy transaction -> get 0x raw. Both Account
signing endpoints now work end-to-end through the FFI (signMessage for Solana,
signTransaction for EVM). 72 tests pass, zero warnings.

Remaining: broadcast (signAndSend via eth_sendRawTransaction over ethrpc-rs),
EIP-1559, Bitcoin, live balance RPC, swap/wc/quote/names, spotlib, cutover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the network layer. src/rpc.rs is a blocking JSON-RPC 2.0 client (ureq —
the FFI runs each request on a worker thread, so no async runtime needed):
call(url, method, params), plus eth_get_balance (hex-wei -> decimal) and
eth_send_raw_transaction helpers. ethrpc-rs's own client is async; this fits the
sync FFI model.

Account:signAndSendTransaction signs the EVM tx (verified path) then broadcasts
via eth_sendRawTransaction and returns the hash. RPC endpoint comes from the
param for now (network resolution lands with wltnet).

Tests: RPC client (call/balance/broadcast/error) against a local mock server,
and a full FFI flow — create secp256k1 wallet -> ethereum account -> sign and
broadcast to a mock node -> tx hash. No external network. 77 tests pass, zero
warnings, release cdylib exports the 6 symbols.

The complete EVM transaction lifecycle now works end to end through the FFI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generalize evm::sign_legacy_tx -> sign_tx over EvmTxRequest{...,eip1559}: type-2
uses gas_tip_cap (maxPriorityFeePerGas) + gas_fee_cap (maxFeePerGas) and v =
y-parity (no EIP-155), legacy keeps gasPrice + v = chain_id*2+35+parity. The
Account:signTransaction handler auto-detects type-2 (type==2 or maxFeePerGas
present).

Test: both a legacy and an EIP-1559 tx, signed via DKLs for a derived account,
ecrecover to that account's address. Both EVM transaction formats now work and
are verified. 77 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MagicalTux and others added 30 commits July 6, 2026 11:32
Replace the Web3:request 501 default with Go's open-relay behaviour: any
JSON-RPC method not handled as a wallet method is forwarded to the current
network's resolved RPC (or a params RPC override). This makes the provider
usable for the reads dApps depend on — eth_call, eth_getBalance,
eth_getLogs, eth_estimateGas, vendor extensions.

Also wire the trivial mpurse methods: mpurse_getAddress (connect a
bitcoin-family account, return address), mpurse_sendRawTransaction (broadcast
pass-through), mpurse_sendAsset (501, Counterparty out of scope). The mpurse
signing methods (signMessage / signRawTransaction) need new Bitcoin
message/raw-tx signing crypto and stay 501 for now.

FFI-verified: eth_getBalance forwards to the node and returns its result,
while eth_chainId is still answered locally. 214 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New bitcoin::sign_message ports Account.SignBitcoinMessage: digest =
double_sha256(prefix || varint(len) || message) with the per-chain magic
prefix (bitcoin/cash/litecoin/dogecoin/monacoin allow-list), DKLs-signed;
the recovery id is brute-forced against the account's compressed pubkey
(purecrypto recover_prehash) and packed as the 65-byte compact form
[31+recid][r][s]. Wired as Web3:request mpurse_signMessage → message_sign
approval → base64 signature. mpurse_getAddress connects a bitcoin account.

FFI-verified: connect a bitcoin account, mpurse_getAddress returns it, and
mpurse_signMessage produces a 65-byte compact signature with a valid
compressed-address header byte (31–34). 215 tests.

Only mpurse_signRawTransaction (raw-tx parse + per-input sign) remains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port derivePrivkeyFromSeed/derivePubkeyForPath (wltwallet/wallet_raw.go) —
the foundation for the mnemonic-share flows (probeActivity/promoteMnemonic).
Adds parse_bip32_path (' / h / H hardened notation), BIP32 CKDpriv (secp256k1:
Bitcoin-seed master, hardened 0x00||priv and normal compressed-pubkey steps,
child = (IL+priv) mod n), and SLIP-0010 ed25519 (ed25519-seed master,
all-hardened 0x00||key||ser32 steps; empty path = Sollet seed[:32]).
derive_pubkey_for_path yields the 33-byte compressed secp / 32-byte ed25519
pubkey.

Known-answer verified: BIP32 test vector 1 (m/0'/1/2'/2/1000000000 =
471b76e3…, and the m/0'/1 intermediate) and SLIP-0010 ed25519 vector 1
(m/0' = 68e0fe46…) both match exactly; non-hardened ed25519 steps are
rejected. This primitive is standalone; the probeActivity handler +
mnemonic-keep wallet type build on it next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y probe)

New src/probe.rs ports the reusable core of wltwallet/probe.go: the
candidate list (Bitcoin/Litecoin/Monacoin P2WPKH, Ethereum, Solana
sollet+phantom), derive_address (seed → derive_pubkey_for_path → chain
address via evm_address / bitcoin::hd_address / base58), and probe_balance
(EVM eth_getBalance, Solana getBalance, Bitcoin modchain_assets) + probe_one
(per-candidate result with partial-error capture) + candidates_for filter.

Verified: the Ethereum candidate for the canonical "abandon … about"
mnemonic derives exactly 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 (BIP44
m/44'/60'/0'/0/0) — an end-to-end proof of mnemonic→seed→hardened-derive→
address; the Solana/Bitcoin candidates derive well-formed addresses (bc1/
ltc1/base58, sollet≠phantom); probe_one over a mock node reports the balance
and hasActivity. Legacy P2PKH variants + the FFI endpoint (which needs the
mnemonic-keep wallet type to decrypt the seed) build on this. 222 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the Wallet/<id>:probeActivity endpoint on the probe core + derivation
primitive: decrypt the mnemonic-keep wallet's MnemonicKeyShare
(models::wallet::decrypt_mnemonic_seed — resolve the unlock key, open the
bottle, base64-decode entropy, reconstruct the mnemonic via new
bip39::entropy_to_mnemonic, re-derive the seed), then derive each candidate
chain's address and probe its RPC for activity. Read-only; requires exactly
one Schema="mnemonic" key. Per-network RPC override in params; absent nodes
derive the address but skip the probe. Object-scoped path Wallet/<id>:probeActivity.

Verified: a hand-inserted mnemonic-keep wallet (encrypted MnemonicKeyShare
for the all-zero-entropy "abandon … about" mnemonic) probes to exactly
0x9858EfFD232B4033E47d90003D41EC34EcaEda94 with the mock node's balance +
hasActivity; a wrong password fails 400. entropy_to_mnemonic round-trips
both ways vs mnemonic_to_entropy. 224 tests.

NOTE: this reads a mnemonic-keep wallet; the WRITE path (import_mnemonic
currently stores a TSS scalar, not the MnemonicKeyShare Go stores) + the
direct-key signing path for such wallets remain a separate compat refactor
(see rust-port-plan memory).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Go-format mnemonic wallet stores a MnemonicKeyShare, not a TSS share, so
the Rust signing path couldn't use it. Fix (secp/EVM+Bitcoin path): in
dkls_sign_digest, when the WalletKey's Schema is "mnemonic", decrypt the
share → reconstruct the seed → BIP32 master scalar → import it into a 1-of-1
dkls Key at sign time, then sign exactly as for a TSS wallet (same key, same
tweak, same low-S / R‖S‖V machinery). The protocol guard now also accepts a
mnemonic secp wallet. Factored out mnemonic_share_seed (shared with
decrypt_mnemonic_seed).

Verified end to end: a hand-inserted mnemonic-keep wallet (encrypted
MnemonicKeyShare for "abandon … about") derives an ethereum account and
signs a legacy tx that ecrecovers to exactly that account address — proving
Rust can open + sign with a Go-created mnemonic wallet.

ed25519 (Solana) mnemonic signing (direct EdDSA, since 1-of-1 FROST
deadlocks) + the import WRITE path (store MnemonicKeyShare) remain. 225 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete mnemonic-wallet signing for the ed25519/Solana path: a 1-of-1 FROST
sign over the LocalHub deadlocks, so sign_frost_local now detects a
mnemonic-keep ed25519 wallet and signs directly with the SLIP-0010 master
key (Ed25519PrivateKey::from_bytes(master).sign — the master is treated as an
ed25519 seed, matching Go ed25519.NewKeyFromSeed), self-verifying under the
stored group pubkey. The Solana account uses path "m", so no tweak.

Adds hdderive::master_pubkey (Go masterPubFromShare): the compressed secp
master pubkey or the ed25519 pubkey of the SLIP-0010 master — the value an
imported wallet's Pubkey holds (also the WRITE-path building block).

Verified: a mnemonic-keep ed25519 wallet's Solana account signs a message
that ed25519-verifies under its account key. With the secp path (prior
commit), Rust can now open + sign with Go-created mnemonic wallets on both
curves. 226 tests. Only the import WRITE path (store MnemonicKeyShare)
remains for full mnemonic round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…RITE path)

Complete the mnemonic subsystem: Wallet:importMnemonic now keeps the mnemonic
(Go buildImportedWallet) instead of converting to a TSS scalar — it seals the
MnemonicKeyShare {curve, entropy(base64), language, passphrase} as the
WalletKey Data with Schema="mnemonic", and stores the wallet with
Pubkey/Chaincode = the BIP-32/SLIP-0010 master (hdderive::master_pubkey). For
secp the Pubkey is byte-identical to the old TSS-import value (both are the
master's compressed pubkey); for ed25519 it now correctly uses the seed-based
master pubkey (matching Go, fixing a latent address divergence). Factored out
seal_share (shared with import_scalar).

Signing already handles Schema="mnemonic" (prior commits: secp via dkls import
at sign time, ed25519 via direct EdDSA). So the full loop now round-trips:
FFI-verified import(mnemonic) → ethereum account → Account:signMessage →
Web3 personal_ecRecover returns exactly the account address. Wallet:promoteMnemonic
(mnemonic → MPC) can now build on this type. 227 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate a mnemonic-keep wallet into fresh N-of-M MPC wallets, one per chain
(Go PromoteMnemonic). The DKLS reshare is synchronous (no broker, no
LocalHub deadlock), so the secp256k1 path is fully local: decrypt the
mnemonic once, then per chain derive the privkey+chaincode at its BIP32 path
(hdderive::derive_secp_privkey_and_chaincode), import it as a 1-of-1 DKLS
source, and dklstss::reshare it into the New committee (tss::dkls_reshare).
The source wallet is left intact. ed25519 (FROST broker reshare) deferred.

Adds tss::dkls_reshare, hdderive::derive_secp_privkey_and_chaincode,
models::wallet::{ChainMigration, promote_mnemonic}, and the object-scoped
Wallet/<id>:promoteMnemonic route.

FFI-verified: import a mnemonic wallet → promoteMnemonic its ethereum chain
into a 2-of-3 (threshold 1) DKLS wallet → derive an account and DKLS-sign a
message that ecrecovers to exactly that account — a real working MPC wallet.
228 tests. This is the first TSS reshare ceremony ported (unblocks the
local-committee path); RemoteKey committees + ed25519 reshare still need
spot / a broker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New src/txhistory.rs ports the EVM tx-history provider (wltbase/tx_history.go):
backfill_evm_modchain paginates modchain_historyByAddress (newest→oldest, up
to 40 pages, stops at the first already-known tx), parses each modchain EVM
summary into a Transaction row (from/to/value/gas/gasPrice/timestamp,
hex-or-decimal quantities, .NATIVE asset, explorer URL) and upserts via
transaction::persist, skipping duplicates by (hash, network).

Transaction:backfill handler resolves the current account + network, runs the
sweep synchronously on the worker thread (RPC override in params for
tests/routing), emits tx:history_updated when new txs land, and returns
{started, provider, count}. Solana getSignaturesForAddress + Bitcoin providers
follow. Adds db::unix_to_rfc3339 and Default for Transaction.

FFI-verified over a mock modchain node: a one-page history sweeps into a
persisted, fetchable Transaction (hash/from/type). 229 tests. This was the
last locally-portable endpoint; the rest need spot infra, legacy TSS, or the
Go oracle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port backfillSolanaFromSignatures + fetchAndBuildSolanaTx (tx_history_solana.go):
paginate getSignaturesForAddress (before cursor, stop at first known sig),
and for each signature getTransaction(jsonParsed) and condense to one
Transaction row — prefer an SPL transfer (spl-token/2022 transfer/
transferChecked touching the owner as authority/source/dest → mint asset +
tokenAmount decimals), else a system SOL transfer (9-dec lamports), else the
owner's net SOL balance delta labelled "other" (covers swaps/mints). The
backfill handler now dispatches evm→modchain / solana→signatures.

FFI-verified over a mock RPC sequence (getSignatures → getTransaction →
empty page): a system transfer sweeps into a persisted Transaction
(hash/type=transfer/from=owner). Transaction:backfill now covers EVM +
Solana; only the Bitcoin modchain provider remains. 230 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete the last non-spot Web3 method. New bitcoin::sign_raw_tx (Go
SignRawBitcoinTx): parse the caller's raw tx (outscript BtcTx), fetch the
account's UTXOs via modchain to resolve each input's derivation path +
amount + script, derive each input's key (m/chain/index + accumulated
tweak), and DKLS-sign every input under its own key (self-verifying, with
bitcoin-cash SIGHASH_FORKID). Wired as Web3 mpurse_signRawTransaction →
transaction_sign approval → approve_mpurse_sign_raw_tx (resolves current
bitcoin network's RPC + host Keys) → signed tx hex.

FFI-verified over a mock modchain node: a P2WPKH raw tx whose input the
account owns is signed into a valid segwit tx (witness added, 0001 marker/
flag). 231 tests. This completes every locally-portable endpoint; only the
spot-network family, legacy TSS (upstream), ed25519 promote (broker reshare),
and the Go-oracle diff-cutover remain — all requiring infra unavailable here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
spotlib (crates.io) is available, so the Spot client side is not blocked.
Env now holds a lazily-started spotlib::Client (Client::builder().meta(
"project","libwallet").build() — spawns the connection thread and returns
immediately), closed on Destroy. Spot:status reports {online, target_id
("k.<b64url>", derived from the identity key), connections:{total, online}}
(Go spot_status.go).

FFI-verified: Spot:status starts the client and returns its state (target_id
+ connection counts) without blocking, regardless of relay reachability.

This is the foundation for the cross-device ceremonies (RemoteKey /
initiateKeygen / joinSign / export/importFromDevice) — a spotlib-backed
MessageBroker over this client replaces LocalHub for the remote parties; the
device↔device paths are then testable with two in-process clients.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oopback)

Port the remote-party side of wltwallet/broker.go: a tsslib MessageBroker
(SpotBroker) that carries a ceremony over a SERIALIZED transport instead of
in-process channels. Outbound JsonMessages (from this party) are serialized
and handed to a `send` closure (spotlib SendToWithFrom in production); inbound
bytes are deserialized and dispatched to the tsslib handler, buffered by type
until it connects (same pending/flush model as LocalHub) so async delivery
across two parties still drives the rounds to completion.

Resolves the core cross-device unknown — VERIFIED: a full 2-party FROST
keygen runs to completion with the two parties exchanging messages ONLY as
serialized bytes over in-memory channels (each on its own thread, reader
threads pumping inbound), and both derive the SAME group public key. The
crypto+broker layer of every cross-device ceremony (RemoteKey / initiateKeygen
/ joinSign / device transfer) therefore works; what remains is the spotlib
send/reader wiring + the pairing handshake + endpoint orchestration on top of
this tested core (end-to-end needs the live relay).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The spot relay is reachable, so cross-device ceremonies are verifiable, not
just theoretical. Added the spotlib transport wiring for SpotBroker — send via
client.send_to_with_from("<peer>/tss", bytes, "/tss") + inbound via
client.set_handler("tss", |msg| broker.deliver_inbound_bytes(&msg.body)) —
and a live end-to-end test (gated behind SPOT_LIVE=1): two real spotlib
clients on the public relay run a full 2-party FROST keygen, exchanging tsslib
rounds ONLY over the encrypted Spot network, and both derive the SAME group
public key.

Verified: SPOT_LIVE=1 → the ceremony completes in ~5.6s over the live relay.
Every cross-device building block is now proven: the Spot client (Env +
Spot:status), the serialized ceremony broker (loopback + live), and the tss
ceremonies. The 15 spot endpoints (initiateKeygen/joinSign/export/import/
RemoteKey/pair) are orchestration + pairing on top of this proven transport.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port wltwallet/transfer_crypto.go + the pairing-code helpers: seal/open a
device-transfer payload with a token-derived key (HKDF-SHA-256(ikm=token,
salt=sid, info="libwallet-device-transfer") → AES-256-GCM, purecrypto), wire
format [nonce:12][ciphertext][tag:16] with the session id bound as GCM AAD —
byte-compatible with Go's gcm.Seal(nonce, pt, sid). Plus build/parse the
tibane://device-transfer?spot&token&sid&v=1 pairing URL (Go url.QueryEscape
semantics).

Unit-verified: seal↔open round-trip; wrong token / wrong sid (AAD) / a
single-bit tamper all fail the GCM tag; pairing URL round-trips and rejects a
non-transfer scheme or a missing field.

This is the self-contained foundation for Wallet:exportToDevice /
importFromDevice; the session registry + global Spot handler + endpoint
orchestration (drivable end-to-end over the live relay, now reachable) build
on it next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l/import)

Port wltwallet/transfer.go: the QR-pairing device→device wallet transfer
that runs as a single Spot Query round-trip. New device sends
{v,sid,token,newSpotID} to old/<sid>/transfer; old device waits for the
host's confirm, then ships back the AES-256-GCM sealed wallet backup +
device shares (HKDF-SHA256 token-derived key, AAD=sid).

- handlers/spot.rs: Spot:status + Wallet:exportToDevice[/Confirm/Cancel]
  + Wallet:importFromDevice, wired into the router (bare + object-scoped).
- env.rs: spot client lifecycle (spot_start installs the "transfer"
  handler), transfer_sessions registry, transfer_register/resolve, and
  handle_transfer_query (claim → confirm → build+seal → reply).
- models/wallet::build_transfer_payload assembles the backup blob.

Verified end-to-end over the live Spot relay (device_transfer_over_live_spot,
gated SPOT_LIVE=1): source ed25519 wallet is sealed, queried by the new
device, decrypted, and restored with an identical group pubkey.

Note: the sealed payload rides one Spot message; the relay caps messages at
~200 KB, so a secp256k1 DKLs23 wallet (~365 KB of Paillier material) does
not fit a single round-trip — this limit applies to the Go design equally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two more spot endpoints, both fully verifiable without a proprietary backend.

- Wallet:buildNewAgentBody (create_agent.go): purely local — validate
  name/agent_spot_id/policy and fill in this device's Spot target id for
  the host's Crypto/WalletSign:newAgent POST. FFI-tested (echo + missing
  policy rejected).
- ClawdWallet:pair (pair.go): parse tibane://pair?agent&token, one Spot
  Query to <agent>/pair carrying {v,token,mobile_spot_id}, dispatch the
  agent's response into a verified identity or a typed wire code
  (url_malformed/token_invalid/token_expired/token_consumed/bad_request/
  identity_mismatch/agent_unreachable). New src/clawdpair.rs holds the
  pure parse/dispatch helpers (unit-tested).

Verified end-to-end over the live Spot relay (clawd_pair_over_live_spot,
gated SPOT_LIVE=1): a mock agent peer answers the pair query; the success
path returns the matched identity + capabilities and the wrong-token path
surfaces token_invalid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port wltwallet/remote.go: the three RemoteKey endpoints are thin authed
POSTs to the Crypto/WalletSign backend. libwallet's only auth is the
Sec-ClientId header (from Info:setWalletInfo); the backend owns 2FA and
session state, and we pass its response through verbatim (res.data).

- rest::do_post — POST <host>/_special/rest/<path> with a JSON body and an
  optional Sec-ClientId header, unwrapping the {result,data} envelope
  (mirrors Go rest.Do + withClientID).
- handlers/remotekey.rs: RemoteKey:new {number|email}, RemoteKey:reshare
  {key} (fixed threshold=1/count=3, curve stays server-side),
  RemoteKey:validate {session,code}. Backend param overrides the host.

Verified against a capturing mock backend (remotekey_endpoints_post_to_
walletsign_backend): asserts POST path, Sec-ClientId header, request body
shape, and envelope passthrough for all three, plus 400 on missing params.
(Live backend verification needs the proprietary WalletSign service.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Upgrade src/remotekey.rs (SpotPeer) from mock-tested to live-verified: two
spotlib clients run a real 2-party FROST keygen routed through the exact
production walletsign/<sid>/{broadcast,single} target paths + the
<self>/<sid>/<party> sender format that Wallet:initiateKeygen / joinSign
ride on. This is the deepest verification possible without the WalletSign
backend (which holds the wdrone party's share).

Key spotlib compat finding (probed + encoded in the test): the relay
AUTO-PREPENDS a client's authenticated id to a relative "/<sid>/<party>"
sender, yielding Go's identical wire sender "k.<self>/<sid>/<party>".
Passing the absolute form ourselves double-prefixes and the message never
routes — so SpotPeer's relative (empty self_id) form is the correct input
for spotlib, matching Go byte-for-byte on the wire.

Also adds a target_and_sender_wire_format unit test for the routing strings.
frost_keygen_over_live_spot_walletsign_routing is gated behind SPOT_LIVE=1
and completes in ~5s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the RemoteKey key type into Wallet:create (ed25519/FROST). The share
is generated locally like any other, then sealed to the wdrone fleet's
decrypt keys and uploaded to the backend so a wdrone can pull it later to
co-sign (Go WalletKey.encrypt RemoteKey arm + selectPeer key discovery).

- src/walletsign.rs: fetch_decrypt_keys (GET Crypto/WalletSign:keys →
  parse each signed IDCard → collect "decrypt" subkeys) + upload_generated_key
  (POST Crypto/WalletSign:setGeneratedKey {data,key,curve,protocol}).
- keystore::seal_json — bottle with ct="json" header, matching Go
  cryptutil.MarshalJson(share) so the sealed payload round-trips wdrone-side.
- rest::do_get_with_client_id — GET with the Sec-ClientId header.
- models::wallet::create RemoteKey arm: fetch fleet keys, seal_json, upload,
  store the session as WalletKey.Key + keep the sealed copy in Data.

Verified end-to-end against the LIVE backend + wdrone fleet
(remotekey_wallet_create_over_live_backend, SPOT_LIVE=1) using the documented
test account (+14045551234 / 000000, ClientId com.ellipx.walletapp): 2FA
new→validate yields a real RemoteKey, then an ed25519 wallet with a
[Plain,Plain,RemoteKey] committee is created — the share uploads and the
wallet persists with its group pubkey. ~3.7s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-end)

A RemoteKey wallet's wdrone-held share is a backup; signing uses the local
threshold of shares (Go subSign opens local shares — its opener has no
RemoteKey arm, and joinSign/initiateKeygen are untested Stage-1 code whose
decryptFrost-on-RemoteKey can't open a wdrone-sealed share). So "usable"
means: create the wallet (share backed up to the wdrone) and sign with the
local Plain shares.

- sign_frost_local: open Plain shares with an empty keychain (Go EmptyOpener)
  instead of resolving an unlock key — Plain shares are stored unencrypted.
- Broaden the handler unlock filters (account/swap/transaction/wallet/request)
  to include "Plain" so Plain WalletKey ids reach the signing committee.

The live RemoteKey test now also creates a Solana account and signs a message
with the two Plain shares (2-of-3, threshold 1), proving the wdrone-backed
wallet is fully usable. Full suite green, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port wltwallet/reshare.go (FROST path) + broker.go: Wallet:reshare where the
old committee includes a RemoteKey, so a wdrone co-participates as a live TSS
party over the Spot walletsign transport. This is the interactive-wdrone
ceremony TestRemoteWallet exercises — the one operation where a RemoteKey
share is used without ever being decrypted locally (the wdrone holds it).

- src/reshare.rs: the tssHub (Hub + LocalBroker + WdronePeer), selectPeer
  (ping the fleet), the walletsign/<remotekey>/init handshake carrying the
  old/new committees, and reshare_frost() orchestrating frosttss::Resharing
  parties (new committee + local-old locally; RemoteKey old over Spot).
- models::wallet: seal_share_full (factored create's per-share seal incl. the
  RemoteKey upload) + persist_reshared_frost (verify each new share still
  derives the group pubkey, replace WalletKey rows, bump generation).
- Wallet:reshare endpoint (static + object-scoped Wallet/<id>:reshare).

Critical fix: the hub dispatched with the map MutexGuard still held through
delivery (`if let Some(lb) = self.local.lock()...get().cloned()`); since a
party's round callback runs inline and re-enters the hub, the non-reentrant
std Mutex deadlocked. Release the guard before delivering.

Verified end-to-end against the LIVE WalletSign backend + wdrone fleet
(remotekey_reshare_over_live_wdrone, SPOT_LIVE=1, ~15s): create an ed25519
[Plain,Plain,RemoteKey] wallet, RemoteKey:reshare→validate for a fresh
session, then reshare a [Plain + RemoteKey] old committee → new
[Plain,Plain,RemoteKey]; the group pubkey is preserved and the committee
rebuilt. Full flow: selectPeer → init → 5-round FROST reshare (27 hub
dispatches, 6 msgs to / 12 from the wdrone) → all 4 parties complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…drone

Extend the RemoteKey/wdrone family to secp256k1 (DKLs23), the EVM/Bitcoin
curve — matching the FROST (ed25519) path already shipped.

- seal_share_full: DKLs RemoteKey uploads seal `json.Marshal(saveBytes)` —
  the dkls Save-JSON base64-std-encoded as a JSON string — inside a ct="json"
  bottle, matching Go WalletKey.encrypt (cryptutil.MarshalJson turns the raw
  []byte Save form into a base64 string; the wdrone's loadShare reads it back
  into []byte → dklstss.Load). tsslib dklstss::Key::to_json is byte-compatible
  with Go dklstss.Save. Removes the old ed25519-only guard, so Wallet:create
  now accepts secp256k1 RemoteKey shares.
- reshare.rs: factor the transport/committee scaffolding into setup_ceremony
  (shared by both protocols), add reshare_dkls using dklstss::ResharingParty
  (binds every party to the wallet's group pubkey as a ProjectivePoint via
  AffinePoint::from_sec1; requires exactly T+1 old signers).
- persist_reshared_dkls + replace_wallet_keys (factored from the FROST persist).
- Wallet:reshare dispatches on the wallet curve (ed25519→FROST, secp256k1→DKLs).

Verified end-to-end against the LIVE wdrone fleet (remotekey_reshare_dkls_
over_live_wdrone, SPOT_LIVE=1, ~24s): create a secp256k1 [Plain,Plain,
RemoteKey] wallet (DKLs share uploads), RemoteKey:reshare→validate, then
reshare a [Plain + RemoteKey] old committee → new [Plain,Plain,RemoteKey];
group pubkey preserved. Both curves' RemoteKey lifecycles now work end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ttee

Port Go Wallet.Promote: convert an imported 1-of-1 secp256k1 wallet into a
real DKLs23 committee in place, preserving the master pubkey + chaincode.
The imported key lives only on-device, so the reshare runs fully locally
(tss::dkls_reshare); new RemoteKey shares upload to the wdrone via the now-
shared seal_share_full.

- models::wallet::promote: recover the source 1-of-1 DKLs key (from a
  mnemonic-keep share via decrypt_mnemonic_seed→master privkey→dkls_import_key,
  or from a raw-import 1-of-1 DKLs share directly), reshare to the new
  committee, verify the group pubkey is unchanged, seal each new share, and
  swap the committee (replace_wallet_keys + Protocol=dkls23 + Threshold).
- Wallet:promote endpoint (static + object-scoped Wallet/<id>:promote).
- Un-defer seal_share_full for secp256k1 RemoteKey (done in the DKLs commit).

Verified (promote_imported_wallet_to_mpc_in_place, fully local): import a
mnemonic secp256k1 wallet → promote to 2-of-3 (Password) in place → master
pubkey preserved, Protocol=dkls23, and the promoted wallet signs. ~4s.
ed25519 promote (FROST import scalar) deferred, as with promoteMnemonic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port wltwallet/join.go apiInitiateKeygen: the mobile leads a cross-device
FROST keygen. It builds the committee from the caller-supplied `peers`
(PartyId.key = base64url Ed25519 pubkey, sorted), fires the canonical
InitPayload {sid,type:keygen,curve:ed25519,protocol:frost,threshold,peers}
at each peer's <peer>/walletsign/<sid>/init, runs its own FROST keygen party
over the shared hub, and uploads its resulting share to the wdrone as a
RemoteKey. Returns {wlt_id, solana_address, pubkey}.

Reuses the reshare transport (Hub + WdronePeer) in a new joiner mode
(start_joiner: pre-set peer spot id from the committee, no selectPeer; init
via send_to_with_from). New helpers: reshare::{JoinPeer, sid_from_remote_key,
build_party_ids, initiate_keygen}, models::wallet::persist_agent_keygen.
Wired as Wallet:initiateKeygen.

Unit-tested the deterministic wire pieces (sid extraction; buildPartyIDs
sort-by-key + me-location by moniker/spot; error cases). The full multi-device
ceremony needs a live agent device + the phplatform newAgent session
orchestration, so it's exercised in the field, not here — but the transport it
rides on is proven by the reshare + walletsign-routing live tests.

251 tests, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port wltwallet/join.go apiJoinSign: the mobile joins an agent-led FROST
signing ceremony over the walletsign transport. Locates the wallet's local
FROST share, arms handlers for the committee peers (joiner mode — the agent
leads, so no init send), runs its signing party over the shared hub, and
returns the 64-byte Ed25519 signature.

Faithful to Go including its limitation: the standard ClawdWallet mobile
share is Type=RemoteKey (wdrone-sealed) and cannot be opened on-device — the
decrypt errors, matching Go's opener (which has no RemoteKey arm). A locally
held FROST share (Plain/Password/StoreKey) signs normally. The full
multi-device ceremony needs a live agent, so like initiateKeygen it's
exercised in the field; the transport is proven by the reshare tests.

With this, every Go RegisterStatic endpoint is now routed in Rust:
initiateKeygen/joinSign/promote were the last three. clawd_keygen_and_sign_
endpoints_are_wired asserts both validate input (400, not 404).

252 tests, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0.2.4 adds the pieces the legacy GG18/EdDSA port was blocked on: Key gains a
public subset_for_parties + a public group-pubkey field (ecdsa_pub/eddsa_pub),
and each legacy protocol now exports KeygenParty/SigningParty/ResharingParty/
import_key. No API breakage — all 252 tests still pass unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tsslib 0.2.4 exposes the pieces the legacy GG18-style Ed25519 path needed
(public Key::subset_for_parties + Key::public_key + KeygenParty/SigningParty),
so Rust can now open + sign ed25519 wallets created before FROST.

- tss::{eddsa_keygen_local, eddsa_sign_local, eddsa_group_pubkey} — mirror the
  FROST helpers over the LocalHub. SigningParty::new narrows a full share to
  the committee via subset_for_parties (the t-of-n fix), so 2-of-3 threshold
  signing works and no longer fails final verification.
- models::wallet: resolve_protocol (Go resolveProtocol: empty→curve legacy) +
  sign_eddsa_legacy; sign_frost_local now dispatches Protocol="eddsa" (or an
  empty-protocol ed25519 wallet) to the legacy path. Factored
  open_committee_share (shared share-decrypt).
- create_eddsa_legacy — build a legacy wallet (Protocol="eddsa", Schema="") for
  migration/round-trip testing (Go retired legacy keygen; only FROST is minted).

Verified: eddsa_keygen_sign_verifies (3-party keygen → 2-of-3 sign → external
Ed25519 verify, both subsets) and create_and_sign_legacy_eddsa_wallet
(create → sign via the standard path → verify → wrong-password rejected).
No regression — 254 tests, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…0.2.5

tsslib 0.2.5 adds ecdsatss::SigningParty::new_with_kdd (the IL/KDD tweak
variant Go uses for HD-derived accounts) + eddsatss::Key::with_kdd, closing
the last legacy-TSS gap. Rust can now open + sign secp256k1 wallets created
before DKLs23 — including their derived EVM/Bitcoin accounts.

- tss::{ecdsa_keygen_local (broker-based over LocalHub; per-party Paillier
  LocalPreParams), ecdsa_group_pubkey=key.public_key(), ecdsa_sign_local_tweaked
  (SigningParty::new_with_kdd applies the BIP32 IL so the sig verifies under
  group+tweak·G; returns (r,s,v))}.
- models::wallet::sign_ecdsa_legacy; dkls_sign_digest dispatches Protocol="gg18"
  (or empty+secp256k1, per resolve_protocol) to it — threshold scheme, needs
  only T+1 shares (unlike DKLs which needs all). create_ecdsa_legacy builds a
  legacy fixture (Protocol="gg18", Schema="").

Verified (create_and_sign_legacy_ecdsa_wallet, release, ~12min): create a
2-of-3 GG18 wallet → derive an ethereum account (IL tweak) → personal_sign →
the 65-byte sig ecrecovers to the derived account address, for two different
signing subsets. #[ignore]'d in the default suite (real ≥2048-bit Paillier
keygen is minutes-slow); run with `cargo test --release -- --ignored`.

Both legacy protocols now work: eddsatss (ed25519, 0.2.4) + ecdsatss (secp256k1,
0.2.5). 254 tests + the ignored GG18 e2e, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants