Skip to content

Port libwallet golang part to rust#2

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

Port libwallet golang part to rust#2
MagicalTux wants to merge 113 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 4, 2026 00:16
Port the transport-independent core of the wltwallet spot broker (broker.go
spotPeer.Send): the target and sender address strings for routing a party's
tsslib messages over the spot network to the WalletSign backend. target =
<peer>/walletsign/<sid>/{broadcast|single}; sender = <self_id>/<sid>/<from> (or
the legacy /<sid>/<from> when self_id is unset). These must match Go
byte-for-byte for cross-device interop.

The spotlib client that carries the bytes is injected via a SpotTransport trait
(as the WalletConnect WS transport was) — the live network client is a thin
deployment impl over this tested layer. The ceremony itself is the same
FROST/DKLs keygen/sign already ported over LocalHub; RemoteKey only swaps the
in-process broker for this spot-routed one.

Tests: target/sender construction matches the Go format (broadcast vs directed,
canonical vs legacy sender); send routes body + addresses to a mock transport.
168 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An endpoint audit against the Go RegisterStatic list surfaced real
compatibility bugs: the Dart client calls Go's plural names, but the Rust
routes used singular ones. Fixed by routing the canonical Go names (with the
singular kept as an alias): Names:resolve, Contracts:lookup, Account:listUTXOs.

Also port Info:setWalletInfo / Info:getWalletInfo (wltbase/info.go): store the
host wallet identity (ClientId/Name/Version/LogLevel) in config and return it
with the resolved effectiveLogLevel (empty -> "info").

Tests: setWalletInfo -> getWalletInfo roundtrip incl. the empty-logLevel ->
"info" default; and that Names:resolve / Contracts:lookup are known endpoints
(not 404). 170 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port storekeyDerivePassword: derive the Ed25519 public key a Password-scheme
WalletKey resolves to — PBKDF2(password, salt = the wkey UUID) via
keystore::password_to_ed25519, returned as its base64url PKIX public key. Lets a
host pre-register the recipient pubkey without unlocking a share.

Endpoint StoreKey:derivePassword {Password, WalletKeyId} -> {Public_Key}.

Test: for a Password wallet, derivePassword reproduces the exact WalletKey.Key
recipient pubkey the wallet stored, and a wrong password derives a different
key. 171 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port wlttoken/curated: the embedded, vetted per-chain token list. The 13 data
files (evm-1/10/56/100/137/250/324/8453/42161/43114/59144 + solana-mainnet +
overlay) are copied to src/token_data and compiled in via include_str!, keyed
by canonical "<type>.<chainId>" (as the contract registry does). Unknown chains
return an empty array (never null, matching Go).

Endpoint Token:listCurated {Network} -> [CuratedToken]. The dynamic ChiefStaker
feed (Solana mainnet live overlay) needs a service and is out of scope.

Test: evm.1 loads all 379 entries with the exact first address/chainKey from
the Go data; polygon/solana lists present; unknown chains empty. 172 tests
pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the import primitive behind Wallet:importPrivateKey: tss::frost_import_key
(Ed25519) and dkls_import_key (secp256k1) wrap a raw secret scalar as a 1-of-1
TSS key via tsslib frosttss/dklstss import_key. The scalar is built from the
big-endian secret (frost::scalar_from_be_mod_l / secp256k1::Scalar::from_bytes_be),
and the resulting share's group public key is the imported key's pubkey.

Tests: frost import derives a deterministic 32-byte group pubkey (distinct per
secret, zero rejected, validate_basic ok); dkls import yields a valid 33-byte
compressed sec1 pubkey and signs a digest. (A 1-of-1 FROST *sign* over LocalHub
deadlocks — single party waits on absent peers — so signing stays covered by
the multi-party tests; the import path verifies key derivation.) 174 tests
pass, zero warnings.

The full Wallet:importPrivateKey endpoint (seal the imported share + persist the
wallet row) builds on this and wallet::create's persistence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete the import: models::wallet::import_private_key wraps a raw 32-byte
private-key scalar as a 1-of-1 wallet — frost/dkls import_key -> single TSS
share, sealed to the one recipient and persisted, reusing create's seal + persist
path. Handler Wallet:importPrivateKey {PrivateKey (32-byte hex), Curve, Name,
Keys(1)} parses the key and broadcasts wallet:created.

Test: importing a secp256k1 key yields a dkls23 1-of-1 wallet (threshold 0, one
key, 44-char compressed pubkey, Data never serialized); import is deterministic
(same key -> same pubkey), distinct keys differ, and a bad-length key errors.
175 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two small endpoints from the audit gap list:
- Transaction:maxSendable routes to the same handler as Account:maxSendable
  (which now accepts an "Account" param alongside "Id") — the Go name the Dart
  client uses for the max-sendable calc.
- Asset:invalidateCache drops the cached quote table (the byte-compatible
  "rest:Crypto/DataCache:quotes" key) so the next fiat conversion refetches.

Test: Asset:invalidateCache reports {invalidated:true}; Transaction:maxSendable
is a known route (handler-level 400 with no account, not a router 404). 175
tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real compatibility bug: Go derives a StoreKey's Ed25519 via storeKeyToEd25519
(walletkey.go) — a 64-byte base64url store key split and run through
PBKDF2-HMAC-SHA256(store[:32], salt=store[32:], 4096, 32). The Rust unlock was
treating the material as a raw 32-byte seed, so a StoreKey wallet created by Go
(or by StoreKey:create) could never be unlocked. Fixed:
keystore::store_key_to_ed25519 + resolve_unlock_key StoreKey path.

Also the signMessage/signTransaction/bitcoin/solana/swap handlers built the
unlock list from Password keys only, dropping StoreKey shares — broadened to
Password | StoreKey.

New endpoint StoreKey:create -> {private (64-byte base64url store key), public
(derived PKIX pubkey)} via keystore::create_store_key.

Test: StoreKey:create yields an 86-char private + a public key; a StoreKey
wallet built with that public unlocks and signs with the private end to end.
The existing StoreKey wallet test now uses the store-key derivation. 176 tests
pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port apiImportMnemonic: import a BIP-39 mnemonic as a 1-of-1 wallet. New bip39
module (embeds the 2048-word English wordlist):
- mnemonic_to_entropy: 11-bit word decode + SHA-256 checksum validation.
- mnemonic_to_seed: PBKDF2-HMAC-SHA512(mnemonic, "mnemonic"+passphrase, 2048, 64).
- master_from_seed: BIP-32 (HMAC-SHA512 "Bitcoin seed") for secp256k1,
  SLIP-0010 (HMAC-SHA512 "ed25519 seed") for ed25519.
models::wallet::import_mnemonic derives the master scalar + chain code and
imports it (refactored import_scalar shared with import_private_key, which keeps
its random chain code); the mnemonic wallet uses the derived chain code so HD
accounts derive correctly. Endpoint Wallet:importMnemonic {Mnemonic,
Passphrase?, Curve, Name, Keys(1)}.

Byte-verified against canonical vectors: the Trezor all-"abandon" mnemonic ->
entropy 0 and the exact seed with passphrase "TREZOR"; BIP-32 vector-1 master +
chain code; SLIP-0010 vector-1 ed25519 master. FFI: import is deterministic,
a passphrase changes the key, a bad checksum errors. 179 tests pass, zero
warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port apiWalletBackup / apiWalletRestore: export a wallet (or all wallets) as
backup entries and restore them. backup_entry serializes the full wallet
including the encrypted key shares (Data as base64, as Go marshals []byte —
unlike the FFI response which #[serde(skip)]s it), wraps it in
{Filename: "wallet_<uuid>.dat", Data: base64url(json)}. restore_entry decodes,
parses, and persists (idempotent — skips a wallet that already exists).

Endpoints Wallet:backup {Id?} and Wallet:restore {Files:[{Data}]}.

Test: back up an ed25519 wallet, restore it into a fresh environment, and
confirm the restored wallet has the same pubkey and still signs with its
password shares — proving the encrypted shares survive the roundtrip. 180 tests
pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three more endpoints from the audit:
- Account:createView {Type, Address, Name?} — a watch-only account (no wallet,
  no derivation) from a bare address; curve ed25519 for solana else secp256k1
  (Go CreateViewAccount address path; the xpub path needs BIP-32 decode and is
  deferred).
- Info:onboarding -> {has_account, has_wallet} so the host picks create-flow vs
  main UI (Go infoOnboarding).
- WalletConnect:sessions routes to the active-session list (the Go name for the
  Wc:listSessions logic already ported).

Test: fresh env reports no wallet/account; createView makes a wallet-less
ethereum watch account; onboarding then reports has_account; WalletConnect:sessions
returns the (empty) active list. 181 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port apiMultiCreateWallet: create both a secp256k1 (dkls23) and an ed25519
(frost) wallet with the same key set, returning {secp256k1, ed25519}. Reuses
wallet::create.

Test: FFI roundtrip creates both curves (dkls23 44-char / frost 43-char
pubkeys) and both persist. 182 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the structural half of Transaction.Validate: per-type required-field
checks — a positive amount for value transfers, asset for transfer/erc20, a
recipient for erc20/bitcoin transfers; raw "evm" needs nothing; unknown types
are rejected. The account-resolution + nonce/gas population half needs a live
node and stays with signAndSend.

Endpoint Transaction:validate {Transaction | fields} -> {valid:true, type}.

Test: a well-formed transfer validates; missing asset / zero amount / bad type
/ erc20 without recipient all 400; erc20 with asset+to and raw evm pass. 183
tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the wltwc Manager session lifecycle over the RelayTransport abstraction,
tying together every WalletConnect piece already built (pairing store, envelope
crypto, relay protocol, inbound dispatch, namespace builder):
- pair: parse the URI, generate the wallet's X25519 proposal keypair, persist
  the pairing session, subscribe to the pairing topic.
- pump: poll one relay frame, look up the owning session, decrypt+classify it
  (with the type-confusion guard active).
- approve: derive the session key from the proposer's X25519 pubkey
  (X25519+HKDF), subscribe + persist the active session, publish wc_sessionSettle
  on the session topic and the proposal response on the pairing topic — matching
  Go ApproveProposal byte-for-byte.
- respond: publish a JSON-RPC result for a wc_sessionRequest.
Adds walletconnect helpers (new_x25519_keypair, seal_type0 random-nonce,
hex_lower, decode_pubkey32) and wc_session::create_active.

Verified end to end over a mock relay+dApp: pair -> receive wc_sessionPropose ->
approve, then the dApp derives the SAME session key and decrypts the wallet's
sessionSettle — proving the mutual key agreement, and the settle carries the
right namespaces + controller pubkey. (The real WebSocket transport is
loopback-verified separately; wiring a persistent WcManager into the FFI Env
with a background reader is the deployment step.) 184 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the five WalletConnect: route arms (start/stop/pair/approveSession/
respond) driving the WcManager stored in the Env. The relay connection
lives in a single reusable Env (reader thread pumps inbound frames and
broadcasts proposals/requests as host events); no per-request connection.

Verify end-to-end over a loopback WS relay: LibwalletRequest start →
pair → server pushes a Type-0 sessionPropose → reader thread broadcasts
the wc_sessionPropose event → approveSession publishes a sessionSettle
the server confirms. Full suite green, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete the WalletConnect endpoint surface (Go wltwc api.go): reject a
pending proposal, publish a JSON-RPC error for a session request, push a
wc_sessionEvent (chainChanged/accountsChanged), and tear down a session
with wc_sessionDelete. The manager tracks pending proposal ids by pairing
topic (Go proposalsByPairing) so reject can address the right proposal.

Verified over the mock relay: respondError/emitEvent/disconnect each
publish the expected frame under the session key (decrypted by the dApp
side), disconnect flips local state to "disconnected", and a fresh
pairing's reject publishes {code:5000,"User rejected"} on the pairing
topic and is single-shot. Full suite green, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build the webview provider shim (EIP-6963 / window.solana / window.mpurse)
by embedding wltbase/injection/provider.js and substituting the
__LIBWALLET_CONFIG__ placeholder with the per-install config. Pre-seed
initialChainId / initialNetworkVersion from the current EVM network so the
provider answers eth_chainId on first paint (best-effort, as in Go).

The Host/initialAccounts pre-seeding rides on the connected-site
permission store, which lands with the full Web3:request router.

FFI-tested: required-field validation (400), placeholder replaced, config
JSON injected, and the default ephemeral EVM chain seeds "0x1".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Read a token's on-chain metadata (Go wlttoken/discover.go). EVM: four
eth_calls (name/symbol/decimals/totalSupply) with defensive ABI-string
decoding — the offset/length words are attacker-controlled, so every
slice bound is range-checked (a bogus length would otherwise panic the
handler). Solana: getAccountInfo(jsonParsed) on the mint, distinguishing
spl-token vs spl-token-2022. Untrusted decimals is bounded to 0..=36 and
names/symbols are sanitized (control/bidi/zero-width stripped, capped).

Verified over an inline mock RPC: ERC-20 metadata decodes (name/symbol/
decimals/totalSupply), out-of-range decimals is rejected 422, and an SPL
mint parses to {spl-token, decimals, total_supply}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port walletKeyRecrypt (wltwallet/wkapi.go): decrypt a WalletKey's share
with the Old key descriptor and re-encrypt it under New, in place. The raw
bottle payload is re-sealed as-is, so Schema and the underlying share are
preserved regardless of type (frost/dkls23/mnemonic/raw). Local schemes
(Password / StoreKey / Plain) are supported; RemoteKey re-encryption needs
the backend fleet keys and is rejected.

Add object-scoped path routing for the Dart client's addressing form:
  Wallet/Key                     -> list keys
  Wallet/Key/<id>       (GET)    -> fetch one key
  Wallet/Key/<id>:recrypt (POST) -> recrypt
The id is parsed out of the path (the flat router previously only matched
static path strings).

FFI-verified: recrypt passwordone->newpassword1 on a 2-of-3 Password
wallet; the old password then fails to unlock the share and the new one
produces a verifying Ed25519 signature. Encrypted Data never crosses FFI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Accept the host app-lifecycle status and echo it (wltbase/lifecycle.go).
In Go this pauses/resumes the balance poller; that poller isn't ported
yet, so pause/resume is a documented no-op and only the echo contract is
honored. FFI-tested (background echoes; empty status accepted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the EVM branch of Go Transaction.SignAndSend: resolve the From
account (by id or address — new account::find, mirrors wltacct.FindAccount)
and network, RPC-backfill nonce (eth_getTransactionCount pending) / gas
(eth_estimateGas) / fee (eth_gasPrice, or the EIP-1559 maxFee+tip pair)
when the caller didn't pin them, DKLs-sign, broadcast via
eth_sendRawTransaction, then persist the Transaction row and return it.
Supports native transfer, raw "evm", and erc20_transfer (pre-encoded Data).

Adds models::account::find (id-or-address) and models::transaction::persist.
Solana/Bitcoin signAndSend for the Transaction object is deferred (501 with
a pointer) — those chains are already fully covered by
Account:signAndSendTransaction.

Verified over a mock node: with no nonce/gas/gasPrice supplied, all three
are backfilled from the node, the tx signs + broadcasts (hash returned),
and the persisted row is fetchable via Transaction:GET.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the EVM branch of wlttx/simulate.go. Decode the top-level call
(native_transfer / erc20_transfer / erc20_approve / unknown), then simulate
against the node: prefer debug_traceCall (callTracer, withLog) for the full
effect tree — every value-carrying CALL/CREATE and every ERC-20
Transfer/Approval log becomes an Effect — plus revert detection
(Error(string) decode) and gas used; fall back to eth_call +
eth_estimateGas with a single synthesized effect when the node has no
debug_*. A second prestateTracer(diffMode) pass yields native
balanceChanges. Non-blocking approval warnings (recipient_is_contract via
eth_getCode, erc20_approve_unlimited for >2^255 allowances) are appended.

Solana/Bitcoin simulation is deferred (returns {chain, decodedMethod:
"unknown"}); their raw-tx decoders follow.

Verified over a mock node: callTracer path decodes an ERC-20 transfer +
its Transfer-log effect + gasEstimate; an Error(string) revert surfaces as
willRevert + "Boom"; the eth_call fallback synthesizes the effect, reads
estimateGas, and fires the unlimited-approve warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port simulateSolana: run simulateTransaction on the already-built `raw`
bytes (accepted as base64 or 0x-hex), surfacing logs, unitsConsumed, and
revert status (err non-null → willRevert + reason), plus a native_transfer
decode from the tx shape. Missing raw → 400 ("build/validate it first"),
matching Go. Bitcoin simulate still deferred.

Unit-tested over a mock RPC: logs + unitsConsumed + native decode on
success; missing-raw errors 400.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port simulateBitcoin's decode path: parse the built `raw` tx via
outscript BtcTx and surface bitcoinInputs (txid display-order + vout),
bitcoinOutputs (sats + script hex), bitcoinFee (from the carried fee, as
per-input amounts need prev-tx lookups), and bitcoinVSize, plus the
native_transfer decode. The UTXO dry-run preview (no raw) needs the build
machinery and is deferred.

Unit-tested: a hand-built 1-in/1-out raw tx decodes to the expected
input/output/fee/vsize. Transaction:simulate now covers all three chains
(EVM full trace, Solana simulateTransaction, Bitcoin decode).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The approval queue is fully local (no spot network): run() persists a
pending Request row, broadcasts a "request" host event, and blocks the
worker thread on an in-memory waiter channel until Request:approve/reject
resolves it (2-minute timeout). Ports wltbase/request.go's run/respond/
claim/releaseClaim; the claim step atomically flips pending→processing so
side effects run at most once and a resolved request can't be re-approved.

Adds: models::request (Request table + fetch/list/save), Env waiter
registry (request_register/resolve/pending/take), handlers::request
(run/test/approve/reject + object fetch/list). The `test` type is fully
wired; connect/transaction_sign/message_sign/chain_switch approvals layer
on top and land with Web3:request.

FFI-verified end to end: Request:test (async) surfaces a "request" host
event; Request:approve resolves the blocked call as accepted; Request:reject
resolves it as rejected and a second approve then fails.

This is the foundation the whole Web3:request dApp-provider builds on —
it was the piece that looked like it needed live infra but doesn't.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build the EIP-1193 provider on the Request approval queue + a new
connected-site store. Wired methods: eth_chainId, net_version,
web3_clientVersion, web3_sha3 (keccak), eth_accounts, eth_requestAccounts,
wallet_requestPermissions, wallet_getPermissions. The connect flow raises a
`connect` approval request (rich ConnectRequestValue: method/family/
availableAccounts/alreadyConnected/requestedPermissions); on approval the
Request:approve connect arm persists ConnectedSite rows and emits
js:accountsChanged. eth_accounts/collectEVMAccountAddresses filters to
secp256k1 0x-addresses; wallet_*Permissions emits the EIP-2255 wire shape.

Adds models::connected_site (ConnectedSite table, host-scoped, current
account first) and info::web3_client_version. Signing/send methods
(personal_sign, eth_sendTransaction, eth_signTypedData, solana_*, mpurse_*)
return 501 and land next on the transaction_sign/message_sign approvals.

FFI-verified end to end: read methods (chainId 0x1, net_version, client
version, keccak256("")); eth_requestAccounts raises a connect request, and
after Request:approve{Accounts} it resolves with the account address and
eth_accounts reflects the persisted connection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Web3:request personal_sign raises a `message_sign` approval request (rich
Value: method/chain/account/origin/messageBytes), blocks, and returns the
0x signature the approval produced. The Request:approve message_sign arm
decodes the message, runs evm::personal_sign (EIP-191 prefix + keccak +
DKLs → R‖S‖V, V 27/28) with the host-supplied Keys, and persists the
signature into the request Result. Account selection honours the optional
params[1] signer address, else the first connected account.

Typed-data / solana_signMessage / mpurse_signMessage return 501 and land
next. (personal_ecRecover deferred — needs an ecrecover glue helper.)

FFI-verified end to end: connect the account, then personal_sign("hello")
raises a message_sign request; after Request:approve{Keys} it resolves with
a well-formed 65-byte EIP-191 signature (V ∈ {27,28}).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Web3:request eth_sendTransaction normalizes the dApp's hex-quantity tx
(value/gasPrice/maxFee* → decimal, gas/nonce → u64), authorizes that
`from` is a connected EVM address for the origin, then raises a
`transaction_sign` approval carrying the normalized Transaction. The
Request:approve transaction_sign arm reuses transaction::sign_and_send
(RPC-backfill → DKLs sign → broadcast → persist) with the host-supplied
Keys/RPC and stores the tx hash in the request Result, which Web3 returns.

FFI-verified end to end: connect the account, then eth_sendTransaction
raises a transaction_sign request; after Request:approve{Keys,RPC} it
builds/signs/broadcasts and resolves with the broadcast tx hash. 204 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
solana_connect / solana_requestAccounts raise a `connect` approval (solana
family) and return {publicKey:[addrs]}; solana_accounts lists them;
solana_disconnect drops the host's connections. solana_signMessage raises a
`message_sign` approval whose approve arm now branches on method: personal_sign
(EVM EIP-191, existing) vs solana_signMessage (FROST-sign the raw message via
sign_frost_local → {signature: base58, publicKey}).

These reuse the connect/message_sign approval arms built for the EVM path —
the Solana methods are mostly wiring on top. solana_signTransaction/
signAndSendTransaction (transaction_sign) and mpurse_* follow.

FFI-verified: solana_connect → approve → {publicKey:[address]}; then
solana_signMessage → approve{Keys} → {signature(base58), publicKey}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Parse the chainId ({chainId:"0x…"} or bare hex), resolve the target
"evm.<dec>" id, and 4902 if the chain is neither stored nor in the static
chains registry. Otherwise raise a `chain_switch` approval; the
Request:approve chain_switch arm sets the current network to the target
(EVM→EVM, so the account address is chain-independent — no re-derivation).

Also fixes network::fetch("@") to resolve a current selection set to an
ephemeral "type.chainId" id (previously only stored rows resolved, so a
switched-to ephemeral chain read back as the default). Adds
network::by_id_opt.

FFI-verified: unknown chain → 4902; switch to Polygon (0x89) raises a
chain_switch request whose Value.targetNetwork is evm.137, and after
Request:approve eth_chainId returns 0x89.

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