Conversation
- Changed import paths from `crate::bindings::exports::component::threshold::types::*` to `crate::exports::component::threshold::types::*` in multiple files for consistency. - Updated the WASM target path from `wasm32-wasip1` to `wasm32-wasip2` in all relevant end-to-end test files to reflect the new build configuration.
…content server - Introduced new RPC methods for eVTXO programmable key generation in `mpc_wallet.proto`, including steps for key generation and resharing. - Added a new Python script `warg_content_server.py` to serve as a minimal content-addressed store for Warg registry components, supporting PUT and GET requests for content management.
… creation
Make a contract-gated eVTXO a first-class Ark VTXO: it is minted by a normal
off-chain send to its Ark address, tracked by arkd, and spent through arkd's
SubmitTx/FinalizeTx so the ASP genuinely co-signs the cooperative
(ConditionMultisigClosure) leaf — no forged key. The 2-of-2 {wallet, cosigner}
V′ leg is gated by the cosigner, which releases its share only if the committed
WASM contract returns `allow`.
Contracts are now delivered to the cosigner once, at eVTXO creation
(EvtxoKeygenStep1Request.contract_wasm), validated, and stored in its own KV
(contract_wasm tree, keyed by contract_id); the gate resolves them from there.
The external Warg/local-dir registry is removed.
Ark / on-chain
- crates/ark: eVTXO taproot tree with an ark-lib-compatible exit leaf
(CSVMultisigClosure: <bip68_seq> CSV DROP <owner> CHECKSIG) so arkd accepts the
taptree; add bip68_sequence + evtxo_exit_script. Default VTXO scripts unchanged.
- ffi/ark/send.rs: per-input eVTXO branch — V′ signer key, the `condition`
(contract_id preimage) and EVTXO/0x01 args PSBT fields, and gate_tx_bytes (the
checkpoint PSBT) for the cosigner. Default sends unaffected.
- ffi/ark/evtxo_address.rs: derive the eVTXO's Ark address for minting.
- app-core: createEvtxoSpend/signEvtxoSpend sign the cooperative leaf with V′.
- cosigner-runtime/ark_send: carry the client's `condition` PSBT field through the
checkpoint counter-sign — arkd drops it on re-serialize but re-evaluates the
condition at FinalizeTx.
Contract delivery / validation
- proto: EvtxoKeygenStep1Request.contract_wasm.
- cosigner-runtime: KvRegistry (KV-backed, sha256-verified) replaces Warg/LocalDir;
step3_finalize requires the wasm, checks sha256==contract_id, statically validates
it (compiles to a component importing only the bitcoin:contract package — no WASI),
and stores it. Correctness/termination stay fail-closed at evaluate time.
Tests / infra
- e2e: evtxo_arkd_e2e_test (full mint+spend through arkd: allow co-signed and
confirmed, over-limit + bad-arg refused); evtxo_contract_e2e_test updated to the
in-creation delivery (no Warg); contract_engine_test covers static validate.
- remove warg_content_server.py, the mpc_warg docker service, and Makefile warg-up.
- Adjusted MpcClient to support a 2-of-2 DKG process without a hardware signer. - Updated doDkg method to handle the new DKG logic, ensuring both wallet and cosigner hold shares. - Modified createEvtxoKey to register an eVTXO that reuses the main key without resharing. - Enhanced REST API to return eVTXO scriptPubKey upon registration. - Updated protobuf definitions to include evtxo_script_pubkey in responses. - Added end-to-end tests for the new 2-of-2 DKG and eVTXO registration process.
…e imports - Updated imports in various handler files to use `crate::cosigner::cosigner::CosignerInstance` instead of `crate::cosigner::wasm::CosignerInstance`. - Removed the `wasm.rs` file as it is no longer needed. - Introduced new helper functions for group authorization checks and persistence in `helpers.rs`. - Updated `evtxo.rs` to reflect changes in participant handling for the DKG process. - Added tests for the new 2-party reshare functionality in `frost_test.rs`.
…vice - Deleted RefreshStep1, RefreshStep2, RefreshStep3, GetPolicyId, UpdatePolicy, and DeletePolicy RPC methods from the MPCWallet service. - Removed corresponding request and response message definitions from the proto file. - Updated generated Dart files to reflect the removal of these methods and messages.
…res for multi-user onboarding
- Implemented EvtxoOnboard RPC for onboarding participants to contract eVTXOs. - Added EvtxoPendingShares RPC to allow participants to fetch contract shares held for them. - Introduced EvtxoAckShare RPC for participants to acknowledge receipt of shares. - Updated protocol buffers and generated Dart files to include new message types and service methods.
- Added DKG handlers in `handlers.rs` to manage the DKG ceremony steps (1, 2, and 3) directly in Rust, replacing the previous WASM-mediated approach. - Introduced a new `DkgSession` struct in `session.rs` to maintain per-user DKG state, including round packages and pending responses. - Created a `mod.rs` file to organize onboarding-related modules, including the new DKG coordinator and session management. - Updated the REST API to utilize the new onboarding module for DKG coordination.
…n mpc_wallet.proto - Updated RPC methods for contract creation, replacing the previous eVTXO key generation steps. - Introduced new methods: ContractCreateStep1, ContractCreateStep2, ContractCreateStep3, and ContractCreateStep4. - Removed legacy eVTXO methods and adjusted message structures to reflect the new contract creation process. - Enhanced comments for clarity on the contract creation flow and the roles of user, cosigner, and service.
- Updated `new_nonce` to return a tuple of commitments JSON and the signing nonce. - Modified `frost_sign` to accept a reference to the signing nonce instead of consuming it. - Removed unused utility functions from `util_ops.rs` related to identifier derivation, polynomial evaluation, and random coefficient generation. - Simplified the `world.wit` interface by removing unnecessary resource handles and consolidating session state management. - Enhanced `refresh_to_receiver` to return a refreshed pairing structure containing the receiver's half, key package, and public key package. - Updated DKG resharing logic to support key-preserving refresh operations for new participant identifiers. - Added new fields to the `ContractContext` message in the protobuf definition to accommodate the cosigner group key.
…contract refresh-not-reshare (cosigner side)
Guest is now the long-lived, stateful, sole WASM component that owns all signing keys
and per-user state; the host does I/O + stores an opaque sealed snapshot.
- Fold cosigner-guest into the `cosigner` crate (one cosigner.wasm); delete the legacy
in-WASM component; move pure-public-crypto (pubkey tweak, schnorr verify) host-side.
- Merge CosignerInstance into CosignerState (one actor Arc; dispatch is (state, shared, req)).
- All signing in the guest: FROST script-path + key-path tweak + contract spends behind the
async contract-gate enforce.
- Plan A: SeedPolicy actor command (onboarding seeds keys into the guest + seals);
OnboardingManager::with_registry; cold-spawn restore-first (skip plaintext InstallPolicy
when the sealed snapshot restores).
- Contracts: replace the V->V' reshare with a key-preserving REFRESH of V onto
{service,cosigner}; eVTXO coop leaf = V; ContractPolicy = gate allowlist + service share;
4 ContractCreate steps -> 1; stateless ContractManager.
- Cleanup: delete handlers/sign.rs + contract/session.rs; move `ceremony` into onboarding.
Remaining work tracked in HANDOFF.md (Dart client contract change + e2e verify;
service-driven spend into guest; plaintext policy-store removal; Phase 6 enclave).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rk_tx for service-driven eVTXO spends; update contract_group_id description
…upport - Introduced `BoardingSettleInFlight` struct to manage in-flight boarding settle state. - Enhanced `GuestState` to include boarding signer and settle state. - Added methods for handling boarding nonces and signing in the guest. - Updated `SettleSession` to delegate tree-signing to the guest, managing public keys and nonces. - Implemented new commands for generating nonces and signing tree transactions. - Refactored event handling to accommodate the new boarding settle workflow.
- Removed Sled and Enclave persistence backends. - Introduced RespStore for Redis-based key-value storage. - Updated ServerConfig to use Redis URL for connection. - Modified handlers and services to eliminate references to removed persistence methods. - Updated Docker Compose to include Redis service with password authentication.
…up model - Changed contract creation to use a peer model, removing the need for service-side assembly. - Introduced EvtxoPendingShares and EvtxoAckShare for receiver-side inbox operations. - Removed obsolete messages related to broadcasting transactions and fetching history. - Updated comments for clarity on the new flow and responsibilities in the contract creation process.
The FROST wallet share is now gated by an Android passkey: the share is persisted blinded (δ = share − b(PRF seed)) and reconstructed only at signing time, one biometric gesture per operation burst. Request auth moves off the share onto cosigner-minted session tokens. Cosigner: - Run a WebAuthn Relying Party in the runtime (webauthn-rs): register/ assert ceremonies, credentials in the RESP KV, deterministic uuid-v5 user handles. residentKey=required so Android routes to the platform passkey flow, plus an android:apk-key-hash allowed origin for Credential Manager assertions (WEBAUTH_ANDROID_ORIGIN). - Mint 30-day Ed25519 session tokens (seeded by WEBAUTH_TOKEN_SECRET), verified centrally at the REST boundary before actor dispatch. An empty Schnorr signature now reports "missing or invalid session token" instead of a confusing length error. - vtxo_received push is now a visible "Funds received" notification — a gated share can't silently sign a delegate from a background push. App: - Onboarding: welcome → server → DKG → passkey setup screen (replaces the create-PIN screen; PIN threading removed). Assert-first retry so a cancelled first attempt doesn't wedge on excludeCredentials; skippable with an un-gated Schnorr fallback. - PasskeyAuthenticator: Credential Manager channel with PRF eval; single-flight assertions (concurrent requests share one gesture); 2-minute seed cache so a settle's sequential FROST signs need one prompt; token persisted across restarts with a one-shot re-auth when the server rejects it. - Ark tab: delegate banner + button when re-delegation would prompt; auto-delegate runs silently only when promptless, with a 5-minute failure cooldown (was: a biometric prompt every poll tick). Ark: - Per-input exit delays in delegate settle and off-chain send: a wallet legitimately holds mixed delays (a boarded VTXO keeps the boarding delay; received/refreshed ones use the unilateral delay), so spend info is rebuilt per distinct delay instead of one template for all inputs. Removes the "mixed exit_delays" rejection. - Regtest arkd config: boarding 172032 / unilateral 86016 — arkd requires boarding to be the largest delay in the VTXO script.
Registration failed on the first attempt with Google's "Sign in another way": enablePasskey asserted the just-created credential before Google Password Manager had indexed it. Retry the post-register assertion with backoff (2–5s) so the PRF seed and session token are still minted at setup, and show a "Securing your wallet…" state on the setup screen during the wait. Supporting build + signing changes needed to run passkeys on-device: - Toolchain: Gradle 7.6.3->8.12, AGP 7.3.0->8.7.3, Kotlin 1.9.0->2.3.20 (Flutter 3.44.3 won't build under Gradle 7.x; androidx.credentials 1.3.0 needs Kotlin >=1.9.0), plus kotlinOptions jvmTarget 1.8. - Commit a shared debug keystore and sign debug builds with it, so every dev/CI shares one cert fingerprint for Digital Asset Links. - cosigner: accept a comma-separated WEBAUTH_ANDROID_ORIGIN so debug and release builds validate against one server; wire the debug origin into the software-ark Make target. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The wallet is now a pure 2-of-2 {wallet, cosigner}; the RP2350 TrustZone
hardware signer and the in-app hardware/recovery-signer abstraction are no
longer used. Real restore is handled server-side (restore-from-seal).
Removed:
- Firmware crates: hwsigner/, hwsigner-secure/, crates/embassy-rp-fork/
(plus host tooling: scripts/test_hwsigner.py, 99-hwsigner.rules,
docs/HW_SIGNER.md)
- Flutter/Android USB HID layer: app/lib/usb/, app-core/lib/usb/,
UsbHidPlugin.kt, device_filter.xml + AndroidManifest USB entries
- Legacy signer layer: HardwareSignerInterface, TcpHardwareSigner,
SoftwareSigner, MpcClient.doRestore(), the RecoveryPolicy cluster, and
the e2e/signer-server test harness
Changed:
- MpcClient/MpcService collapsed to the software-only DKG path (doDkg is
self-contained; no external signer attached)
- e2e tests/bins updated to drop the vestigial TcpHardwareSigner
- Makefile: dropped hw-* + signer-server targets, the duplicate flutter
target, and the deprecated FFI aliases; hardware/hardware-ark now alias
the software targets
- Docs reframed 2-of-3 -> 2-of-2; removed stray committed logs
Verified: flutter/dart analyze clean across app, app-core, e2e; the DKG /
sign / Ark path passes in e2e-ark. (Pre-existing Ark auto-settle/FCM test
failures are unrelated and tracked separately.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two pre-existing failures in the Ark e2e suite, unrelated to the
hardware-signer removal:
- Auto-settle tests (drives stored delegate, cold-spawn from restart,
survives restart) never fired: the tick settles when
`now >= expires_at - AUTO_SETTLE_SAFETY_MARGIN_SECS`, but arkd's
ARKD_VTXO_TREE_EXPIRY (~4.3h) far exceeds the test's 3600s (1h) margin,
so the threshold stayed hours in the future and the delegate never
settled within the test window. Raise the margin to 9999999999 so the
threshold clamps to 0 (always crossed) regardless of arkd expiry, in
ark_e2e_test, evtxo_arkd_e2e_test, and dkg_2of2_test.
- FCM "push fires on VTXO receive" asserted a silent data-only push, but
push_vtxo_received sends a user-visible notification ("Funds received" /
"Tap to activate auto-settle protection") via send_notification. Update
the payload-shape assertions to match the visible notification (notification
block + apns alert, apns-priority=10) — the visible banner is the intended
behavior.
make e2e-ark now passes 10/10.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tlinks First, build-safe stage of the com.example.ap -> com.vtxos.app rename: - Move src/main/kotlin/com/example/ap -> com/vtxos/app and update the package declarations in MainActivity.kt and PasskeyPlugin.kt - build.gradle: namespace -> com.vtxos.app - assetlinks.json: package_name -> com.vtxos.app (template only) applicationId stays com.example.ap for now so it keeps matching google-services.json's package_name (the google-services gradle plugin requires them equal). The applicationId flip + a fresh google-services.json + firebase_options.dart appId are a console-gated trio to land together once the com.vtxos.app app is registered under Firebase project vtxos-7afb3. Do NOT deploy the updated assetlinks.json to vtxos.com/.well-known/ until the applicationId also becomes com.vtxos.app, or passkeys for the current com.example.ap build break. Verified: flutter build apk --debug succeeds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the com.example.ap -> com.vtxos.app rename now that the com.vtxos.app app is registered under Firebase project vtxos-7afb3: - build.gradle: applicationId -> com.vtxos.app - google-services.json: fresh download containing the new com.vtxos.app app (appId 1:575541915148:android:03d0cade16cd0393378829); the old com.example.ap entry is retained harmlessly. API key rotated to the value in the new download. - firebase_options.dart: appId + apiKey updated to match the new app so Firebase.initializeApp targets the com.vtxos.app registration. FCM server (cosigner-runtime) unchanged: same project + service account; device tokens re-register against the new appId on next launch. WebAuthn env unchanged (apk-key-hash is cert-derived, not package-derived). ACTION REQUIRED (out of repo): deploy the updated app/assetlinks.json to https://vtxos.com/.well-known/assetlinks.json so Credential Manager honors passkeys for com.vtxos.app — ideally also adding the release/Play-signing SHA-256 fingerprint (assetlinks currently lists only the debug cert). Verified: flutter build apk --debug succeeds (google-services package validation passes against the new applicationId). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sync the repo's Digital Asset Links template with what vtxos.com serves (the MPCLanding Cloudflare worker): package_name com.vtxos.app, both the debug (BB:5A:...:E2) and release (2D:FD:...:CB) SHA-256 fingerprints, and both the handle_all_urls (legacy FIDO2) and get_login_creds (passkey) relations. The authoritative copy is the worker's ASSET_LINKS constant; this file is the reference source of truth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…app id `make release` distributed to FIREBASE_APP_ID 1:575541915148:android:5fbaa581... (the old com.example.ap Firebase app). After the com.vtxos.app rename, update it to the new app's mobilesdk_app_id 1:575541915148:android:03d0cade16cd0393378829 so App Distribution uploads under the correct registration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
route_tick_auto_settle recorded the consolidated VTXO's exit_delay by guessing it from the first input VTXO. The delegate self-refresh output is always built with the ASP's unilateral_exit_delay, so when the first input carried a different delay (e.g. a boarding VTXO with boarding_exit_delay), the host stored the wrong exit_delay. A VTXO's scriptPubKey is re-derived from exit_delay, so the client then built a spend against a wrong script and arkd rejected it (INVALID_PSBT_INPUT) — a user who boarded funds and then used auto-settle could not spend the consolidated VTXO. Plumb the settle's real unilateral_exit_delay through ActorResponse::SettleSubmitted (mirroring the boarding-settle and submit_ark_send paths, which already record the correct value) and use it in the host projection instead of guessing. Add an e2e regression: ark_e2e_test ping-pongs Ark VTXOs between two boarded wallets, spending a just-auto-settled VTXO every cycle (the exact failing path) — fails pre-fix, passes post-fix. Configurable via DELEGATE_STRESS_CYCLES (default 10). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The wallet now automatically falls back to on-chain Bitcoin only whenever the
Ark ASP is unreachable, and recovers automatically when it returns. Users can
also force offline mode from a new Settings screen.
MpcService gains a dynamic availability state machine: the existing VTXO poll
now detects an ASP outage (a failed refresh confirmed by a getArkInfo probe, so
a transient hiccup doesn't flap the flag), flips _arkAvailable false, and
re-probes every tick to auto-recover via initArk(). offlineMode =
offlineModeForced || !arkAvailable; the forced toggle is persisted (Hive
'offlineMode').
In offline mode the UI hides the Ark and Services tabs (both Ark-layer), shows
an "on-chain Bitcoin only" banner, blocks Ark (tark1) addresses on the send
screen, and a router redirect bounces any Ark/Services route back to Home.
On-chain receive/balance/send are unaffected (wallet-alone, no ASP).
- services/mpc_service.dart: offline state, persisted toggle, dynamic detection
- widgets/app_bottom_nav.dart (new): shared route-based nav, hides Ark/Services offline
- widgets/offline_banner.dart (new)
- screens/settings_screen.dart (new): offline toggle + live Ark status
- main.dart: /settings route + offline redirect guard (refreshListenable)
- screens/{home_screen,ark/ark_screen}.dart: shared nav; Home settings icon + banner
- screens/spending/send_screen.dart: offline gating of Ark addresses
Verified: flutter analyze clean; debug APK builds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Client: - passkey: strip clientExtensionResults.prf before the assert/finish POST so the device-only PRF seed (which unblinds the FROST share) never leaves the device (CL-1). - attestation: fail closed when the appKeyHash binding is empty/unextractable instead of silently trusting the server-supplied response-signing key (EC-3). threshold / ffi: - enforce single-use FROST signing nonces at the FFI via a one-shot atomic spent flag; a re-signed handle now fails closed instead of leaking the secret share (TH-1). - redact Debug on secret-bearing structs (KeyPackage, SigningNonce, DKG secret packages) so shares/nonces can't reach logs or panic output (TH-3). - replace the hand-rolled ark hex decoders with hex::decode — no panic on odd-length / multi-byte-UTF-8 ASP-supplied strings (TH-5). - box FFI handles as Box<dyn Any> so free/borrow resolve the concrete type via the vtable — kills the free_handle type-confusion / wrong-Layout heap corruption and returns None on a type mismatch instead of UB (TH-8). Verified: ffi builds; 38 threshold tests + 4 handle tests pass; cosigner-runtime checks clean; app-core/app analyze clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-user WASM cosigner guest was replaced by native per-user actors, leaving CI and docs stale. Workflows: - delete cosigner.yml (built the non-existent cosigner/ dir; triggered on crates/threshold + crates/ark, so it would fail CI on this branch's changes). - drop the cosigner.wasm build + the --wasm flag from e2e.yml and flutter-integration.yml (cosigner-runtime is native; its CLI takes only --port). - pin verify.yml's enclave CLI @latest -> @v0.0.79 (matches release-eif.yml). README: rewrite to match the codebase — FROST/DKG/Schnorr run natively in per-user actors (not a WASM guest); two isolation layers (Nitro enclave + native actor); and the Ark VTXO + wallet-alone on-chain/offline value layers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…h, unify types The per-user CosignerActor runs natively in-process but was still driven through a serialized ActorCommand/ActorResponse message layer left over from the removed WASM guest — the registry built a command variant only to match it right back into the method it named. Collapse that indirection: the registry now calls CosignerActor methods directly, each returning its own typed result, and the dead Err/unexpected-variant arms go away. Core - Delete ActorCommand/ActorResponse, command()/handle(), and the wire module. The request/response payload structs and durable domain types now live in cosigner::types; wire.rs is removed. - Consolidate the three ContractPairing representations (state / actor / wire) into a single cosigner::types::ContractPairing ([u8;32] fields, hex serde); drop the pairing_to_wire / pairing_from_wire converters. - Drop the "*Wire" suffix from the payload structs; move SnapshotState + payloads into cosigner::types. - Move the contract gate into sign_step2 and the boarding phase-selection into a single boarding_settle method; the async methods acquire the ASP client themselves. - Remove now-unreachable methods (get_ark_tree_cosigner_pubkey, boarding_gen_nonces, boarding_tree_sign) whose only entry point was a command the registry never built. Tests - Delete the 5 WASM-guest integration tests (removed architecture). - Modernize sign_flow / seed_policy / onboarding against the current registry API, behind a Redis + lazy-ASP skip guard (tests/common). Add a cold-spawn test that restores the sealed snapshot and signs, covering the SnapshotState roundtrip. - Remove 2 onboarding tests asserting the old "step1 parks" model (step1 now responds immediately with the server's round1 package). Tooling - ark: add AspClient::connect_lazy so SharedServices can be constructed without a reachable ASP for paths that issue no ASP RPC. - .vscode: drop removed crates (cosigner, e2e/signer-server) from rust-analyzer linkedProjects. - README: remove a stale code comment.
A wallet can now ask another to pay it. The payer allowlists a contact (one-way,
no consent from the contact); that contact may then send payment requests, which
the payer reviews and either signs or declines.
Not a pre-signed transaction: FROST is 2-round interactive and Ark checkpoints
need an ASP counter-signature, so nothing can be signed ahead of approval. The
cosigner instead records who asked, how much, to which address and until when —
and derives the payee address itself from the requester's group key, so a
contact can never redirect the payment.
cosigner-runtime
- Sealed Contact + PaymentIntent in SnapshotState (serde(default), so existing
seals restore unchanged); bounded by per-contact/global caps, expiry and
pruning, since every request re-seals the snapshot.
- Six REST endpoints under /u/{group_key}/{contacts,payment-request}/*. Only
payment-request/create is cross-user: signed by the requester, routed to the
payer's actor. verify_auth proves key possession but does not bind a signer to
the actor addressed, so the allowlist is checked before any state is touched.
- Ids are canonicalised to the wallet's group key via policy_owner_idx, so a
caller may address by any of its ids and the allowlist still compares.
- Fulfilment on both send paths: the cosigner-built one matches payee+amount,
the client-built one (SubmitArkSend) recognises the payment from the tx's own
outputs. The stored intent stays the authority in both.
- PaymentRequest event (SSE) + FCM push, fire-and-forget: the intent is already
sealed, so a failed notification must not fail the request.
Fixes found while verifying end to end
- Ark send derived the VTXO owner key from the share key instead of the group
key, so the ASP rejected the inputs (INVALID_PSBT_INPUT). The app's approve
path now uses the same MpcArkWallet route the Send screen already used.
- Authorize the signer in sign_step1/step2 against the sealed key package.
Previously any keypair could start a ceremony on another user's actor, wiping
their in-flight ceremony and burning the cosigner's single-use nonce.
- Drop cached VTXOs the ASP reports spent (queried by outpoint, removed only on
explicit evidence, never re-added). Without it a missed stream event leaves a
spent VTXO that is picked as a send input, failing every send thereafter.
app / app-core
- MpcClient.groupKeyHex — the wallet's public identity, distinct from userId (a
share key) and groupXOnlyPubKey (parity-stripped, for taproot).
- Contacts and Requests screens under the Ark tab, with a pending badge. The
inbox shows only actionable requests. Approve pays the amount and payee from
the stored intent, never from the UI — that link is what marks it fulfilled.
cli/ (new)
- Regtest REPL driving the runtime end to end: DKG onboarding, one-shot `fund`,
board, send, contacts, request/approve/decline. Plaintext keystore, regtest
only.
tests
- ark_e2e: request → pay → fulfilled against a funded wallet, and a stale-VTXO
test that spends out-of-band, re-poisons the cache, restarts the runtime and
asserts the spent entry is dropped while the change survives.
- payment_request_e2e_test: allowlist gate, payee derivation, decline, revoke.
37/37 Rust tests, Ark e2e 11/11, analyze clean across app-core/app/e2e.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ttestation
Replaces the Redis KV backend with an embedded SQLite store so the cosigner can
run on an EC2 host with its state on an EBS volume, and adds the OpenTofu stack
to deploy that to mutinynet. The Nitro enclave is not ready, so the app now
waives attestation for that one hostname while still demanding it everywhere
else.
Persistence
- `resp_store` -> `kv_store`: the `KvStore` trait is unchanged, so all ~87 call
sites are mechanical import renames. `SqliteStore` stores (tree, key) as real
columns instead of flattening to "{tree}:{key}", so a key containing ':' can
no longer alias into a neighbouring namespace and glob metacharacters in a
tree name are literal -- both were live hazards in the SCAN MATCH backend.
- WAL + synchronous=FULL: this file holds each actor's sealed FROST share, so
durability wins over write throughput.
- Config: REDIS_URL/REDIS_HOST/REDIS_PORT/REDIS_TLS/REDIS_PASSWORD are gone,
replaced by SQLITE_PATH (default data/cosigner.db).
BREAKING CHANGE: deployments must set SQLITE_PATH; the REDIS_* vars are ignored.
Infrastructure
- New self-contained stack at infrastructure/mutinynet/, kept apart from
infrastructure/mutiny/ (which the external `enclave` CLI generates and owns).
- EC2 + encrypted EBS data volume + Caddy for TLS, SSM for config, no SSH.
Both the SQLite KV and Caddy's ACME storage live on the data volume: certs
there because Let's Encrypt rate-limits duplicates, so replacing the instance
a few times would otherwise exhaust the quota.
- Caddy is pinned by version + SHA-512, not @latest (cf. finding IN-4).
Client
- New server_host.dart holds one copy of the host rules, replacing a duplicate
that had drifted into push_service.dart.
- Attestation is a WAIVER list, not an opt-in list: required by default, waived
only for local dev and mutiny.vtxos.network. Spelling it the other way round
("attest only when host == mainnet") fails OPEN -- serverHost is persisted in
Hive and rewritten by setHost, so any other value would silently downgrade to
unattested plain REST.
- The WebAuthn RP ID stays vtxos.com and is NOT the deployment hostname:
passkeys are bound to the RP ID, so moving it would orphan every existing
credential, and Android resolves association via the RP ID's assetlinks.json
(already served there), not the API host.
Tests
- Folded payment_request_e2e_test.dart and restore_from_seal_e2e_test.dart into
ark_e2e_test.dart. The first was wired to neither the Makefile nor CI; the
second could never have passed, since `make e2e-restore` started no arkd and
set no ASP_URL, which the runtime requires at boot.
- Kept only their unique coverage: allowlist/intent group-key canonicalisation,
the decline path, the post-revoke gate, and a cold FROST sign proving the
share is restored from the seal alone.
- Integration tests now use an in-memory store, so each gets an isolated
namespace instead of sharing one Redis.
CI
- Drop the e2e/signer-server build and startup steps; that crate was removed in
18d7de5 and the missing manifest had been failing the build job since.
Verified: cargo test 45/45, clippy clean, flutter analyze 0, flutter test 7/7,
dart analyze clean, tofu validate, and `make e2e` green end-to-end (13/13).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers the emergency-exit question instead of leaving it implicit, and names the weaknesses a reviewer would find anyway. The problem, stated plainly: every VTXO and boarding output is owned by the FROST group key, not the phone's key. The stock Ark taptree's exit leaf is `<delay> OP_CSV OP_DROP <owner_pk> OP_CHECKSIG` — its timelock controls WHEN you may leave, not WHO. With owner_pk being a 2-of-2 group key, a permanent cosigner outage means the phone cannot sign the exit path at all, so the unilateral exit that normally protects Ark users does not protect ours. Ark balances freeze permanently. The on-chain single-key layer is unaffected and stays self-custodial. Picked: pre-signed exit transactions held by the phone, minted at each settlement while the cosigner is provably alive and paying to the wallet's own on-chain address. No protocol change, no ASP cooperation, no trust in us at exit time — it works against the stock taptree and the ASP we already use. Rejected, with reasons in the README: a time-locked escape leaf (better protocol answer, but needs a non-standard taptree the ASP must accept, and hands a stolen phone the balance after the delay — belongs upstream in Ark, not in a local fork); and a published cosigner-share recovery procedure (a share the user can recover is one an attacker can recover, dismantling the 2-of-2, and it assumes we still exist to publish it — precisely the failure being defended against). Documented as designed-not-implemented, with the costs named: exit txs are per-VTXO and must be reissued on every settle/send/receive, VTXOs received after an outage begins have none, and the tree branch data must be retained and backed up. Also added Trust assumptions & failure modes covering the AWS trade (PCR0 sealing is what stops us extracting shares, and equally what makes a lost KMS key unrecoverable by anyone), the ASP as a liveness dependency we do not run, and what Ark's privacy does not give you — the ASP is a full observer of the transaction graph, our cosigner persists much of it too, and exiting is a public, linking on-chain event. Ark here is cheap custody-minimised payments, not anonymity. Fixed the opcode order in the boarding-address doc comment, which had the CSV leaf backwards relative to ark-core's csv_sig_script. Replaced the placeholder licence line with what MIT actually buys the reader: every component needed to run their own cosigner is in this repository. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…moted to fallback
Corrects the previous commit, which rejected a recovery/escape leaf on the
grounds that it "needs the ASP to accept a non-standard taptree". That reason
was wrong, and this repository already disproves it.
An Ark address is just (server_key, vtxo_tap_key) — the taptree is committed
inside the output key, and arkd mints and co-signs against the cooperative path
without inspecting the rest. Contract eVTXOs already ship built exactly this
way: `evtxo_tree` (crates/ark/src/lib.rs:181) takes the cooperative key and the
exit-leaf key as SEPARATE parameters, `ContractPolicy.owner_pk` is documented as
"user-supplied exit-leaf owner x-only key", and the path is e2e-verified
spending through arkd on regtest (make e2e-evtxo-service-arkd, 2026-06-25).
So the primary answer is now a third taptree leaf:
recovery leaf: <recov_delay> OP_CSV OP_DROP <recovery_pk> OP_CHECKSIG
Three things make it cheap, all already present:
- recovery_pk is the wallet's on-chain single key (the DKG dealer secret), so
there is no new secret and no new backup surface — it is already backed up
because it secures the on-chain balance.
- The cosigner already holds it: that key's public point IS the DKG walletVk,
persisted as `wallet_vk` (cosigner/state.rs:192). No new protocol message.
- The ASP needs to know nothing.
The gap is narrow: plain wallet VTXOs still go through `Vtxo::new_default`,
which reuses one `owner` for both leaves. eVTXOs got the better construction;
ordinary VTXOs did not.
Costs kept explicit rather than buried: a stolen phone gets the balance once
recov_delay elapses (so it must be months, and must exceed the ASP's exit delay
so forfeit assumptions are never undercut); the change is forward-only, since a
new taptree means a new address and existing VTXOs keep the old script until
re-settled; and it assumes ASPs continue not to constrain taptrees.
That last risk is why pre-signed exit transactions stay documented as the
fallback instead of being deleted — they need no script change at all, so they
survive an ASP that starts rejecting non-default taptrees, at the cost of
per-VTXO reissue on every settle/send/receive and no cover for VTXOs received
after the outage begins.
Also fixed a wrong line citation in that section: crates/ark/src/lib.rs:137 is
`default_vtxo_tree`'s exit helper, which reuses one key and is the contrast, not
the proof. The proof is `evtxo_tree` at :181.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wallet has been 2-of-2 {phone, cosigner} since the hardware signer was
removed, but several places still advertised the old 2-of-3 design.
- welcome_screen.dart: every new user was told "Secure 2-of-3 ... You hold 2
keys (Spending + Recovery). The server holds 1 share to co-sign." There is no
recovery key (client.dart:335 — "No hardware signer, no recovery share"), so
the app was promising users a key that does not exist. Now states the real
arrangement: one share on the device, one on the co-signing server, neither
sufficient alone.
- ARK_PROPOSAL.md, PRODUCTION_READINESS.md: 2-of-3 -> 2-of-2, and "any two of
the three shares" -> "both shares".
While in ARK_PROPOSAL.md, two adjacent claims in the same passages were also
wrong and are corrected:
- Its "Why This Isn't Custodial" table claimed "User online, cosigner offline |
User performs unilateral exit via CSV timelock". That is false and directly
contradicts README.md → Emergency exit: the stock VTXO taptree keys both the
cooperative and exit leaves to the same owner (the 2-of-2 group key), so the
timelock governs WHEN you may exit, not WHO. Replaced with separate temporary
and permanent outage rows plus the honest caveat, cross-linked to the README.
Leaving a pitch document asserting funds are recoverable when they are not is
the worst possible place for that error.
- The cosigner is described as WASM/Wasmtime-sandboxed per user; it has been a
native per-user actor for some time, with WASM retained only for contract
components. Fixed in the prose, the "Done" list, and the architecture diagram.
Left alone deliberately: the 2-of-3 setups in crates/threshold/tests/. That is a
generic t-of-n FROST library and those tests exercise real 3-party behaviour,
including run_reshare, which models the 2-of-3 -> 2-of-2 migration on purpose.
Rewriting them would delete coverage, not fix a claim.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ze host
First real deploy of this stack surfaced three bugs that could only appear on
an actual apply, plus paste corruption that had reached a commit.
Caddy could never bind 80/443. The bootstrap ran `setcap cap_net_bind_service`
on the binary, but the unit sets NoNewPrivileges=true, which makes the kernel
ignore file capabilities entirely. Caddy crash-looped 3178 times over 4.6
hours with "bind: permission denied" while the runtime sat healthy behind it.
Fixed with AmbientCapabilities=CAP_NET_BIND_SERVICE, which systemd applies
before execve and which survives NoNewPrivileges.
The 49 MB binary uploads to S3 multipart, so its ETag is the hash-of-part-
hashes form ("<hash>-10"), not the file's MD5. With `etag = filemd5(...)`
terraform planned the MD5, got the multipart form back, and aborted with
"Provider produced inconsistent final plan" — identically on every retry.
Switched to source_hash, which drives re-uploads without being compared
against S3's response.
The redeploy provisioner used `set -euo pipefail` under local-exec's default
["/bin/sh", "-c"]; /bin/sh is dash on Debian and Ubuntu, which has no
pipefail, so it died before running anything. Pinned the interpreter to bash.
user_data.sh.tftpl also carried two stray pastes inside the systemd unit —
one of them committed in eafc9d1 as `Restart=alwayswhat the hell is all
this...`, an invalid directive. Restored to Restart=always /
NoNewPrivileges=true.
Deployment configuration is now described in one committed, auto-loaded file
rather than reconstructed from defaults, mirroring mutiny/enclave.yaml.
Secrets stay in the gitignored secrets.auto.tfvars.json. The aws_profile is
pinned to mpc-deployer because the machine's default profile is a different
account, and allowed_account_ids fails the plan if it ever resolves wrong;
the profile is passed to the module too, since local-exec does not inherit
provider credentials.
Also guards the SecureString parameters with prevent_destroy: secret_env
defaults to {}, so an apply without the secrets file emptied the for_each and
planned a silent DELETE of WEBAUTH_TOKEN_SECRET — which would leave the
runtime rejecting every passkey-gated wallet.
Right-sized for signet: t3.small, 4 GiB data, 8 GiB root (the AL2023 AMI
snapshot floor). ~$33/mo -> ~$16/mo. Verified live at 159 MB of 1913 used.
Verified against the running deployment: TLS issued, both units active with
NRestarts=0, state.db on the data volume, ASP and VTXO stream connected,
FCM initialized, boarding watcher enabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two bugs that made receiving funds impossible against the mutinynet deployment, both client-side. MpcBitcoinWallet keeps its own chain view — boarding scans, broadcast and history all go through Electrum, and the cosigner has no Bitcoin-node dependency. The endpoint was hardcoded to regtest loopback (127.0.0.1:50001) and NO caller anywhere passed anything else, so on a phone talking to mutinynet scanBoarding() dialled a dead port, returned an empty list, and boarding reported no error and did nothing. networkName was already wired to the network the cosigner reports, but that only picks address encoding, which is what made the wallet look like it was on mutinynet when it wasn't. The endpoint now derives from that same network, so choosing a server chooses its chain source; explicit host/port still win. Unknown networks throw rather than inheriting a default, on the same reasoning as parseBitcoinNetwork: scanning the wrong chain silently is worse than refusing to start. mainnet is deliberately absent. This also repairs mutinynet_e2e_test.dart, which passed networkName 'signet' and silently took the regtest default. Second, scanBoarding returned mempool outputs. arkd validates every boarding input and rejects the whole intent if one is unconfirmed — "INVALID_PSBT_INPUT (5): failed to validate boarding input: tx <id> not confirmed" — so a single pending deposit blocks boarding of everything else. It now filters on Electrum's height > 0. Filtering alone would leave a fresh deposit reading as "No funds detected yet", the same confusing silence as the Electrum bug, so scanBoardingPending reports what is still in the mempool and the board screen shows "Waiting for confirmation" with the amount instead. Verified: live round-trip through the real ElectrumClient to electrum.mutinynet.com:50001; 5 new tests pinning the mapping, the fail-closed cases, derivation from networkName and explicit overrides; app-core 27 tests pass; dart analyze clean; flutter analyze 0 errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BatchStartedEvent carries intent_id_hashes — the intents actually in that batch — and we never read it. All three code paths recorded the batch id and called confirm_registration on any BatchStarted. On a single-tenant regtest ASP every batch is ours, so this never showed. A public ASP batches many clients together and broadcasts BatchStarted regardless of whose intents made it in, so the cosigner joined whichever batch it saw first, adopted a foreign batch_id, and left its own intent unconfirmed. arkd then aborted that batch because its real participants had not all confirmed: INTERNAL_ERROR (0): not enogh intent confirmations received batch_includes_intent compares sha256(intent_id) as lowercase hex against intent_id_hashes, matching arkd and the reference client in third_party/rust-sdk/ark-client/src/batch.rs. Non-matching batches are now skipped without touching batch_id or batch_expiry, so the session keeps waiting for its own. Applied at SettleSession::handle_batch_started, the delegate loop in batch.rs, and both BatchStarted arms in actor.rs. The hash is pinned against sha256sum in the tests, so a change in hashing surfaces here rather than as an aborted batch on a live ASP. Coverage: exact match, found among other participants, a batch we are not in, an empty batch, and that a raw unhashed id does not match. ark: 11 tests pass. cosigner-runtime: 45 pass, 0 fail. Deployed to mutinynet and confirmed live by binary hash; boarding now reaches tree signing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A max-effort review of the v3 branch (392 files) surfaced eighteen findings. This closes the eight non-contract ones; the contract/eVTXO findings are deliberately left, since that feature is parked. Batch-scoped Ark events were only half-gated. 405e0c7 filtered BatchStarted by intent hash but left every downstream event ungated, so on a public ASP an unrelated batch's BatchFinalized was recorded as OUR settlement — which then wiped the VTXO set and wrote one phantom entry at a non-existent outpoint — and its BatchFailed aborted a settle still waiting for its own batch. Adds foreign_batch_id(), one classifier covering Tree*/Finalization/Finalized/ Failed, applied to all four event loops. BatchStarted stays exempt: it is filtered by intent hash and is what establishes the id everything else is matched against. Before joining a batch nothing is ours, matching the reference client's Step machine (third_party/rust-sdk/ark-client). Passkey registration was an account takeover. register/begin took user_id straight from the body with no authentication, group keys are public (they are the URL path segment and are handed out for contacts), and register_begin APPENDS to a user that already has a credential — excludeCredentials is only a client-side hint. Anyone could attach their own authenticator to any wallet and mint a session token whose `sub` is that wallet, which verify_auth accepts as full authentication for every operation. Now requires a Schnorr signature over a new PASSKEY_REGISTER op, deliberately NOT satisfiable by a session token: a token is what a passkey mints, so accepting one would be circular and would let a leaked token attach a second authenticator. Signed while the share is still un-gated, before gateShare. The contacts and payment-request routes authenticated the identity in the BODY while the URL chose the actor, and the handlers ignored req.user_id entirely — so any keypair could write any wallet's allowlist, which is the only gate protecting payment-request/create. Adds require_owner(), built on the existing in-memory is_authorized_signer (no KV lookup), to contact add/remove/list and payreq list/decline. PAYREQ_CREATE stays cross-user by design. build_send collapsed exit_delay to vtxos.first() and reused one input's spend script, control block and scriptPubKey for every input. The delay is part of a VTXO's taproot tree, and a wallet legitimately holds a mix: boarding-settled VTXOs carry boarding_exit_delay, received ones unilateral_exit_delay. Board then receive then send produced a wrong prevout and an ASP rejection. SendVtxoInput now carries its own delay and spend info is derived per distinct delay, cached — the same treatment generate_delegate already had. Boarding settled only the first UTXO of a batch and reported the whole set as boarded, silently stranding the rest. The client now settles one per call and loops; the server rejects a batch of more than one instead of truncating. The CLI signed whatever sighash the cosigner echoed back, so a malicious cosigner could answer step1 with a sweep to itself and complete the 2-of-2 with no user action. It now signs the locally-built sighash, refuses a substituted echo, and verifies the aggregate against the group key — matching what the Dart client has always done. An auth failure was reported as an ASP outage: refreshVtxos returned false for any exception, and _probeArk is itself authenticated so it failed for the same reason and "confirmed" a phantom outage — evicting the user from Ark on a routine token expiry or a dismissed biometric prompt. Adds a typed WalletAuthException (401/403) that the poll loop distinguishes. The FCM background re-delegate wired neither a token nor a seed source, so it 401'd and the failure was swallowed as "foreground will catch up". It now carries the persisted session token, and returns early with a clear message when the share is passkey-gated — that path cannot work headlessly, since PRF needs a user gesture. Tests: 9 new in crates/ark (5 intent-hash, 4 batch-gate, hash pinned against sha256sum so a hashing change fails here rather than as an aborted batch on a live ASP); a cross-wallet authz test that fails without require_owner; and an e2e proving mixed exit delays in BOTH orderings — boarded-first (172032, 86016) and change-first (86016, 172032) — since the old code keyed off whichever VTXO sat first. Each round asserts the delay set has more than one member, so it cannot silently degrade into a same-delay test. Verified: 46 cosigner-runtime, 29 ark, 27 app-core, 14 e2e against a live arkd; dart analyze clean across app, app-core and e2e; cli and ffi build. Not covered: passkey registration is format-verified statically (prefix, op, canonical string, digest and wire fields all match across Rust and Dart) but needs a device run; the offline-mode and background-delegate paths have no automated coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Flutter integration test is no longer runnable, so the workflow and its three make targets go rather than sit there failing. flutter-integration.yml had exactly one job — the emulator run — so the whole file is removed, along with integration-test, integration-test-ci and integration-test-ci-ark and their stale .PHONY entry. Adds `make cli` for the regtest REPL, pointed by COSIGNER_URL and overridable with URL= so it can drive a deployment as easily as the local runtime, plus `make cli-build` for a compile-only check. Regtest only: the keystore holds plaintext signing secrets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The job allowed 25 minutes; the suite takes ~26 on a dev machine and the same budget also has to cover checkout, Dart setup, `docker compose up` plus a 30s settle, chain init and arkd init — on a runner slower than a workstation. So the job was being killed mid-run and reported as a failure whether or not anything was actually broken. Adding the mixed-exit-delay test made it worse. 50 leaves real headroom; the removed Flutter workflow used the same figure for a comparable stack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.