New cosinger - #55
Merged
Merged
New cosinger#55
Conversation
… 1 forever VERSION and BUILD_NUMBER were both empty by default, so `make release` fell back to pubspec.yaml's `1.0.0+1` — versionCode 1 on every single upload. versionCode must strictly increase: Android refuses to install a lower-or-equal one over an existing install, and App Distribution treats a repeat as the same release. So every build after the first would silently fail to install for anyone who already had the app, with no error pointing at the cause. The name now comes from pubspec.yaml, so there is one place to bump a release. The build number is the git commit count — monotonic, unique per commit, and needing no bookkeeping — rather than a hand-maintained integer someone has to remember. Both are still overridable: make release # 1.0.0 (264) make release VERSION=1.2.0 # pin the name, auto build number make release VERSION=1.2.0 BUILD_NUMBER=57 Release notes default to version, build, short SHA and the commit subject, so a build in the tester list is always traceable to a revision. Adds `make version`, printing exactly what the next release would ship — including a warning when the working tree is dirty, since that silently makes the shipped artifact untraceable to the stated commit. `release` now depends on it, so the summary appears before the upload rather than after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gRPC The cosigner was an always-on multi-tenant service: a DashMap of actors, a command channel per actor, a `route_*` function per operation and a global VTXO stream fanning receives out to whoever owned the script. None of that survives a per-request runtime, where the host hands an instance one tenant's filesystem and one passkey assertion. Tenancy is the host's. `enclave-runtime`'s tenant.rs says where the separation is — "not in the guest" — so `Tenant` became `Cosigner`: the one wallet this process serves, loaded from its seal at open. Its lock is scaffolding until the host owns the lifecycle; the host pool already serialises a client against itself, naming this caller as the reason. Ceremonies become bidirectional sessions. Sign, Dkg, Send and Settle each hold their state on the handler's stack instead of parking it on an actor between requests — a FROST nonce, DKG round secrets, half-built transactions. An abandoned stream now drops them rather than leaving them addressable by whoever sends the next request. Removed with the always-on model: the global VTXO stream and the indexer subscription (the client watches for its own receives), the 60s auto-settle tick, the esplora boarding watcher, the 24h actor eviction sweep, the OnboardingManager's session map and TTL, and the REST API entirely. The contract/eVTXO layer and the WebAuthn RP go as no longer planned — the runtime verifies assertions. One thing is now unguarded: the contract gate was the only check between an authorized caller and a signature over arbitrary bytes, and sign_finish has nothing in its place. The policy IR has to land before this is exposed for real signing. 7,150 deletions against 1,775 insertions. Tests: 20 pass, 0 fail (the four contract_gate failures went with the layer they needed a WASM fixture for). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… streams SharedServices was carrying three fields nothing could reach any more: `session_authority` (zero uses — it minted the Bearer tokens WebAuthn issued), `events` (one publisher, no subscribers, since the SSE stream was its only reader) and `actor_idle_threshold_secs` (the eviction sweep's). The EventBus and the session-token half of verify_auth go with them. SettleDelegate becomes a session, so every ceremony is now a stream: Sign, Dkg, Send, Settle, SettleDelegate. SubmitArkSend stays a call, which is correct — the client builds and signs the transaction and the cosigner only submits it. `store_only` has no replacement yet. It sealed a ReadyToSettle delegate for the 60s auto-settle tick to drive later, and that tick assumed an always-on process. The durable background task meant to take over is not built, so there is no unattended settle path at the moment. `Ceremony::full_transaction` is now read by nothing and kept deliberately: it is the bytes a signature will authorize, which is exactly what a policy has to see and what the sighash alone cannot give it. Tests: 14 pass, 0 fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nto it `cosigner-runtime` containing `src/cosigner/` was one nesting too many, and "runtime" read as a thing that hosts cosigners. It hosts one. The crate is `cosigner`, `src/cosigner/*` moves up to `src/*`, and the binary follows. `CosignerActor` becomes `Cosigner`. "Actor" named a concurrency pattern that no longer exists — no loop, no mailbox, no messages — and the `Cosigner` wrapper around it had shrunk to a mutex, two accessors and a `persist` that duplicated `seal`. One struct now, with the lock held by the services that share it, where it is visibly transitional rather than hidden behind a method. `restore_actor_ snapshot` and friends lose the same dead word. Telemetry goes with it. The OTLP exporter shipped traces, metrics and logs to a collector: a second network dependency, a flush to get right on shutdown, and somewhere for the cosigner's telemetry to travel to. Stdout via `tracing_subscriber::fmt`, as enclave-runtime does, is simpler and cannot silently stop working. Dependencies that outlived their code: wasmtime and wasm-compose (the contract sandbox), axum/tower/tower-http/tonic-web (the REST server), webauthn-rs and uuid (the RP), dashmap, ed25519-dalek, tokio-util, futures, anyhow, tempfile. Re-vendored: 589M/471 crates to 317M/310. Nine config fields nothing read any more, and the handlers for the deleted VTXO stream, auto-settle tick and the unary load tester. `data/*.db` was committed runtime state; untracked and ignored. Tests: 14 pass, 0 fail, no warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o Upstreams The log could not be reached and should not have existed. `Cosigner::history` was written, sealed and restored, and read by nothing: `ListArkTransactions` served a second copy, `CosignerState::ark_tx_history`, out of a plaintext KV tree. Neither survives the model change. The cosigner is called rather than running, so it can only log what it performed — a send or a board — and "receive" entries came from the VTXO stream it no longer runs. A client rendering that log would show a wallet that never received anything, which is worse than showing none. The client watches its own scripts and owns the history; the RPC is gone from the proto with the reason recorded there. `SharedServices` becomes `Upstreams`. Shared meant shared between tenants — one ASP connection and one store serving every actor in the process. There is one cosigner now, so the name described an arrangement that no longer exists rather than a thing: its store, the ASP, and the push channel it nudges a device through. Also fixes a regression from deleting the registry. `rehydrate_cosigner_state` loaded the host projection — VTXOs, device tokens, the stored delegate threshold — when an actor spawned, and went as unreferenced once `get_or_spawn` did. `Cosigner::open` restored the seal but never the projection, so a restart served an empty VTXO set and pushed to nobody. It reads both now; the seal carries VTXOs without expiry, and expiry is what decides when a delegate must settle, so until that moves into the seal both are needed. Tests: 14 pass, 0 fail, no warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ature split The client constructs; the cosigner co-signs and verifies. GetArkInfo, GetArkAddress, GetBoardingAddress and ListVtxos were none of that. Each was the cosigner fetching public ASP parameters and deriving an address from a key the client already holds, or listing VTXOs it can no longer observe now the stream is gone. The client reads the ASP and derives its own addresses. That empties `handlers/ark.rs`, `drop_spent_vtxos` included — cache reconciliation belongs with whoever owns the cache. `ark`'s feature split already described the target — `signing` is tx-building and FROST/MuSig2 math with no tonic/tokio, "what the cosigner guest enables"; `client` adds the ASP transport, host-only — but it had never been built, so it did not compile. `foreign_batch_id` used a cfg-gated `Event` without a gate of its own, and five `async fn`s taking `&mut AspClient` sat under a gate that applied only to the item above them. Gated; `--features signing` builds now. That is what makes the rest measurable. Switching the cosigner to `signing` leaves exactly three things: `get_info` (client supplies it), `submit_tx` (client submits), and the two ASP event loops in `settle_delegate` and `boarding_settle_step2`. The last is an inversion rather than a move — the client drives the round and the cosigner answers each event with what to submit, which every step already supports: `on_batch_started`, `on_tree_signing_started`, `on_tree_nonces`, `on_batch_finalization` and `on_batch_finalized` are all sync and transport-free. It needs new proto for the event relay, both loops rewritten as state machines, and an ASP client on the Dart side, so it is its own change; the cosigner stays on `client` until then. Tests: 14 pass, 0 fail, no warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cosigner held an `AspClient`. It registered intents, opened event streams, reacted to each event itself, and submitted transactions — which made a thing meant to be *called* keep a socket of its own, and made it the caller's Ark client as well as its signer. It has none now: `ark` is pulled with `signing` alone, so the tonic/tokio transport is not even linked. Settle inverts rather than moves. What the cosigner contributes is signatures — the intent proof, the MuSig2 tree nonces and signatures, the forfeit transactions — and every step producing those was already synchronous and transport-free. So the caller relays each ASP event and the cosigner answers with what to send next: `Register`, `Submit(confirm|nonces|signatures|forfeits)`, `Sighashes`, `Idle`, `Complete`. Both shapes run through one loop in `settle.rs` — a boarding output when `boarding_utxo` is set, a self-refresh when it is not — replacing `settle_delegate`, `boarding_settle_step2` and `boarding_settle_step3`. Send follows: the cosigner hands back what to `SubmitTx`, turns the ASP's signed checkpoints into the ones to `FinalizeTx`, and seals only once the caller says the ASP accepted. Nothing is recorded for a send that never landed. `ArkInfo` comes from the caller in `SettleOpen`, `SendOpen` and `PaymentRequestCreateRequest`, because the caller is the one talking to the ASP. It cannot redirect funds with it: every output is derived from the cosigner's own key, and a payee address from the ALLOWLISTED key, so a wrong `signer_pubkey` costs a rejected round rather than a misdirected payment. SubmitArkSend goes — it relayed a client-built transaction and counter-signed the checkpoints, which is the caller's own job now; the Send session records what it paid. `foreign_batch_id` is ungated: classifying an event needs no connection, and a guest driving a settle from relayed events needs exactly it. Tests: 14 pass, 0 fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`DKGStep1/2/3` were three unary calls, so the server held the round-1 and round-2 secrets between them — and those secrets are how the key is born. The ceremony is a stream now, so the session is a local: created at open, dropped when the stream ends. No `sessions` map, no TTL, no eviction sweep, and nothing for a sweep to find after an abandoned ceremony. Two exchanges, not three. Step 2 did nothing a caller needed: it recomputed the cosigner's round 2 and returned the round-1 packages step 1 had already returned. It existed to give the unary API somewhere to trigger that from. The cosigner does it itself now, leaving what the ceremony actually is — the wallet's round 1 in and everybody's out, then its round 2 in and ours out with the key. `onboarding_step1/2/3` become `dkg_open` / `dkg_finish` and a private `compute_local_round2`. The rendezvous goes with it. Each step parked a `oneshot` in a `pending_*` pool and waited for whichever participant closed the round to fulfil it — a rendezvous for a ceremony several parties joined by separate requests. It is 2-of-2 and one of the two is this cosigner, so there is one remote participant and it always closes the round on arrival: the pools could never fill. `Reply`, `drain_pairs_with_err` and `last_touch` go too. First in-process coverage of a full ceremony, in `dkg_test.rs`. What existed before tested `OnboardingManager`'s session map, TTL and eviction sweep — all of which the redesign deletes. These test what matters: that the wallet and the cosigner derive the same group key, and that an abandoned ceremony does not leak its round-1 secret into the next one. Tests: 16 pass, 0 fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e for good
`onboarding/` was three files and a `mod.rs` for one ceremony: the session, the
round state, and the handlers driving them. One file, `handlers/onboarding.rs`,
next to the other handlers. `settle.rs` moves there too — it is a handler like
the rest.
The contract layer's remains go with them. `ContractPairing`, `ContractPolicy`,
`ContractRefreshed`, `contracts_json` and the `arr32_hex` serde helper were
still threaded through the policy, the snapshot and the host projection with
every caller passing `None` or `""`. `install_policy` loses two parameters.
`sign_open`'s pairing branch goes too, and it is worth saying what that was: a
`{service, cosigner}` pairing actor rebuilt a contract eVTXO's cooperative-leaf
sighash from its own sealed params and would sign only that or the ark-tx leg.
Real conditioning — but on pairing actors, which no longer exist, and a normal
wallet always took the other branch and signed the requested message as-is. So
nothing changes for a wallet; there is simply no pairing actor left to condition.
`contracts/` is deleted: 525M of WASM SDK and example contracts that only the
removed engine consumed, plus the Makefile target that built them.
rust-analyzer pointed `linkedProjects` at `cosigner-runtime/Cargo.toml`, which
has not existed since the rename, and at the two contract crates. Fixed and
trimmed; all six remaining projects `cargo check` clean.
Tests: 16 pass, 0 fail, no warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hat is gone `CosignerState` was the host's plaintext mirror of what the guest held sealed — its own doc said "Formerly on `CosignerInstance`". There is one `Cosigner` now and it owns everything, so the mirror was a second source of truth for the same data, populated from the thing it mirrored. Most of it was already dead. `owned_scripts` and `ark_tx_history` had no readers; `guest_delegate_threshold` and `delegate_session` served the deleted auto-settle tick; `utxo_state` was never populated, so the one function reading it always got an empty slice. `policy_state` was a JSON projection of the typed policy the cosigner already holds — `get_user_xonly_pubkey` parsed a verifying key out of JSON to get what `owner_pk_hex()` returns directly. What was live moves onto `Cosigner`: `group_key` (configuration, read per call through a mutex before), `owned_vtxos` carrying the expiry a delegate's renewal deadline needs, and `device_tokens`. Six helpers went with it — `auth_check`, `auth_check_group`, `timestamp_check`, `ensure_policy_loaded`, `get_user_xonly_pubkey`, `calculate_spent_amount` — none with a caller outside `helpers.rs`, all threading a `&mut CosignerState` that reached `timestamp_check` and was ignored. `verify_auth` is what the service actually calls. `save_user_delegate`/`load_user_delegate` and the whole `bitcoin/` module (a spent-amount parser whose only caller was one of the six) follow. `run_blocking` goes too: it existed to mutate the projection off the async threads through a `parking_lot::Mutex`, and its last caller was `register_device_token`, which now just writes its own field. helpers.rs 257 lines lighter, cosigner.rs 107, and one fewer thing to keep in step with the seal. Tests: 16 pass, 0 fail, no warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cosigner pushed to a payer's device when a payment request arrived. It cannot: it is called rather than running, and the notification went out on a detached `tokio::spawn` that in a per-request runtime would fire after the instance was gone. enclave-runtime has the durable task queue an outbound call like this belongs on, so waking a device is the host's — and with the push goes the registry that feeds it. `RegisterDeviceToken` is removed from the proto with the reason recorded there. `fcm_client.rs`, `device_token.rs`, `push_payment_request`, the `DeviceToken` type and its persistence go with them, as do the `FCM_SERVICE_ACCOUNT_JSON` and `FCM_BASE_URL` config. The sealed intent was always the durable record — a failed notification never failed the request — and the app polls on resume. `Upstreams` is down to one field. It held an `AspClient` until the caller took over driving the Ark protocol and an `FcmClient` until now; both gave a thing meant to be called a socket of its own. There are none left: the cosigner talks to its store and to whoever called it. Dependencies that went with the push: reqwest, jsonwebtoken, base64, http. Re-vendored, 317M/310 crates to 277M/232. Tests: 16 pass, 0 fail, no warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Upstreams` was down to a `KvStore` and one timing knob, and `KvStore` was a trait with exactly one implementation behind an `Arc<dyn …>`. Two layers of indirection over a SQLite file. `store::Store` is now that file: `open`, `get`, `put`, `delete`, `get_all`, `clear`, the sealed-snapshot helpers, and the renewal-deadline margin the one delegate call reads. The names each described an arrangement that is gone. `SharedServices` meant shared between tenants; `Upstreams` meant the ASP connection and the push channel alongside the store. There is one cosigner and it has no sockets, so what is left is storage — and one backend needs no trait to choose between. Startup shrinks with it: no ASP connect, no ASP_URL (required until the caller took over driving the protocol, unread since), no FCM client. The process opens its database and serves. Six top-level modules now — auth, config, cosigner, handlers, session, store, types — and the crate is 4,224 lines. Tests: 16 pass, 0 fail, no warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`session.rs` held two: `SigningSession` with the four ceremony streams, and `MpcWallet` — the old unary API, where nine of sixteen methods were refusals. Three pointed at the streams that replaced them, three at the deleted contract layer, one at something never implemented. The RPCs stayed in the proto when their handlers went, so the trait kept demanding stubs. There is one `Cosigner` service now: the four ceremonies as bidirectional streams, and the seven calls that are genuinely single-round beside them — contacts, the request-to-pay inbox, server info. Nothing is held between messages for those, so a stream would buy nothing; they are not refusals or leftovers, just calls. `cosign_session.proto` imports `mpc_wallet.proto` for the shapes those carry, so `build.rs` points `extern_path` at the already-generated module rather than emitting them twice. The session package moves from `mpc_wallet.session.v1` to `cosigner.v1` — nesting under `mpc_wallet` made the extern path swallow the session's own types. `mpc_wallet.proto` stops declaring a service and keeps only message shapes: 593 lines to 185, with 36 messages removed for RPCs that no longer exist. `contracts/` is gone from disk too. `git rm` untracked it a few commits back, but 525M of gitignored build artifacts kept the directory alive. Tests: 16 pass, 0 fail, no warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects, both introduced by the rewrite, both found while planning the client work that would have been built on top of them. **The four streams were unauthenticated.** `check()` ran on all seven unary RPCs and on none of `Sign`, `Dkg`, `Send` or `Settle`. Every handler still carried the comment "auth ran at the REST boundary" — a boundary deleted three commits ago — and `SignOpen`/`SendOpen`/`SettleOpen` all carry `signature` and `timestamp_ms` that nothing read. The only remaining gate was `sign_open`'s `is_authorized_signer`, against a PUBLIC verifying share, so anyone who could reach the port could open a `Send` and have the cosigner co-sign a spend. Sign, Send and Settle now authenticate at open, once, for the session. Dkg cannot and says so: the owner key it would verify against is what the ceremony mints. Its integrity comes from FROST and from living on one stream. **Boarded funds could not be spent.** There were two VTXO sets: `vtxos` in the seal, which `send_open` and `generate_delegate_for` selected from, and `owned_vtxos`, loaded from a plaintext tree and written by boarding and sending. Nothing bridged them — `set_vtxos`, the only thing that could have, had zero callers — so a freshly boarded VTXO was invisible to a send. This came in when `CosignerState` folded into `Cosigner`: the projection became `owned_vtxos` and the sealed field was left beside it. One set now. `VtxoEntry` survives because it carries the expiry a delegate's renewal deadline needs; `vtxos()` converts at the ark boundary. The seal is its only home — the plaintext `save_user_vtxos`/`load_user_vtxos` pair is gone, and with it the load-after-restore that silently overwrote what the seal had just restored. Tests: 20 pass, 0 fail. Four are new. `stream_auth_test` drives tonic against a real server on an ephemeral port rather than calling handlers directly, because the defect was never in `verify_auth` — it was that nothing called it, and only the wire shows that. It also asserts an authentic open still reaches the commitments round, so the gate is not just refusing everything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es it Received VTXOs had no way in. The cosigner learned its set from an ASP subscription it no longer runs, and `SendOpen` deliberately omitted the inputs — "a caller cannot nominate VTXOs it does not own" — so a VTXO received from another wallet could never be spent. Boarding could add to the set and sending could spend it, but nothing else. `SendOpen` and `SettleOpen` carry `repeated VtxoInput vtxos` now, and `accept_vtxos` decides what of it this wallet could own. The split is: - NOT trusted, ownership. Every spendable VTXO sits under a scriptPubKey derived from the cosigner's own owner key and one of the two exit delays the ASP published. A delay outside that pair names a script this wallet does not control and is refused, so a caller cannot widen what it owns by asserting it. - Trusted, existence. Whether an outpoint is really unspent is the ASP's to know, and inventing one costs a rejected transaction and nothing else. Existence is not a secret. Two other proto fixes that had to land before any client is generated: `ForfeitTxs` packed the signed commitment transaction into the same list as the forfeits, but the ASP's `SubmitSignedForfeitTxs` takes them as separate fields — so a one-element list was ambiguous between a lone forfeit and a lone commitment, and the client would have had to infer which from settle-phase state. Two fields now. `cosigner.v1.ArkInfo` duplicated `mpc_wallet.ArkInfo` field for field, which would have given Dart two classes of the same name from two packages. `cosign_session.proto` already imports `mpc_wallet.proto`; it uses that one now. Tests: 21 pass, 0 fail. The new one asserts a mixed received/boarded set is accepted, a third delay is not, and a refused set leaves the previous one in place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stage 0 of the client restructure. Still on REST, still compiling — this only removes what the server stopped serving, so the codegen in the next stage has less to break. Gone from `MpcClient`: `subscribeEvents` (the SSE endpoint is deleted), the five contract/eVTXO methods, `listArkTransactions` and `registerDeviceToken`. Their `WalletApi`/`RestWalletApi`/`GrpcWalletApi`/`AttestedWalletApi` members go with them, as do `services_registry.dart` (a raw-HTTP client for a contract-template directory that no longer exists) and `ark/ark_evtxo_spend.dart`. The app loses its Services tab and its device-token registration. Push display still works; what is gone is this side telling the cosigner where to reach us, because the cosigner has no outbound socket to reach us with. Two behaviour changes worth naming rather than burying: `_delegateIfNeeded` used the Ark history to subtract our own sends from the new outpoints it saw, so only real receives triggered a refresh. Without history every new outpoint counts as external, which refreshes a delegate after our own change lands too — conservative rather than wrong. The Ark tab's transaction list is now permanently empty, and says "History unavailable" instead of "No transactions yet", which would have implied transactions were coming. In e2e, the receive-history assertions are removed with a note: they guarded `vtxo_stream::apply_stream_update` against dropping "receive" rows, and that stream is gone. The two FCM tests are deleted outright — they drove a push the cosigner cannot send. `evtxo_arkd_e2e_test.dart` goes with the contract layer it tested. `dart analyze` clean in app-core, app and e2e; 27 app-core tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…otify Unattended settling had no driver. The 60-second tick that refreshed VTXOs went with the always-on process, and `ARK_PROPOSAL.md` still promises the cosigner does it while you are offline. The obvious shape does not work, and the reason is worth writing down: a guest has no egress. `wasmtime_wasi`'s `SocketAddrCheck` defaults to refusing every address and enclave-runtime never overrides it — it builds its `WasiCtx` without `inherit_network` or `socket_addr_check` — so the sockets interfaces link and every connect is refused. A background task therefore cannot register an intent, relay a batch round, or ask the ASP what arrived. It cannot settle, and it cannot "check for new events" either. What it can do with no socket is read its own sealed delegate and compare a deadline to the clock. So the cosigner does not settle in the background — it wakes its owner when a settle comes due, with the delegate already signed and waiting, and the app drives the round over the attested channel. `notify.wit` names that the primary use: "a finished task telling its owner to come and look". It is also more durable than the tick it replaces, which only ran while the process happened to be up; a queued task survives a restart by construction. `host.rs` names the two capabilities — `enclave:tasks/queue` and `enclave:notify/notify` — method for method, so the guest port is an adapter rather than a translation. `Detached` is the impl for a plain process, and every call fails rather than succeeding quietly: a silent no-op would let a deadline pass with everything looking healthy, which is the failure this exists to prevent. `handlers/watch.rs` is the exported `run-task` body. Arming rides `apply_delegate_sigs`, the last interactive call a settle has, because `enqueue` is interactive-only — background work cannot grant itself standing work. An unknown expiry is refused rather than guessed. A vanished delegate cancels the watch instead of waking someone every half hour about work that is done. `RegisterDevice`/`ForgetDevice`/`DeviceCount` forward to the runtime. The cosigner stores no token and sends no message; `DeviceCount` returns a number because it is not meant to be able to enumerate a tenant's devices. Tests: 28 pass, 0 fail. Seven are new, including two that build a real delegate offline and assert the wake fires past the deadline and not before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cosigner/enclave/` built an AWS Nitro EIF around the always-on HTTP server: `enclave.yaml` named port 7074, `PERSISTENCE_BACKEND: enclave`, `AUTO_SETTLE_SAFETY_MARGIN_SECS` and an `ENCLAVE_NITRIDING_UPSTREAM`, and `flake.nix` fetched ArkLabs' introspector-enclave runtime to supervise it. None of that describes what the cosigner is now: a Wasm component with no listener, no auto-settle and no supervisor, hosted by enclave-runtime. `release-eif.yml` and `verify.yml` go with it. Both existed only to build and verify that image — they install the pinned `enclave` CLI, read `cosigner/enclave/enclave.yaml` and publish `cosigner/.enclave/artifacts/` — so with the config gone they have no input. `crates/enclave-client` stays. That is the client-side attestation *verifier*, used by `ffi/src/enclave/`, and it is the half worth keeping: what it needs is a host that serves an attestation document, not an image built from here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cosigner is meant to run as a guest inside enclave-runtime, and it did not
build for the target at all. Two dependencies stood in the way, and neither was
earning its place.
**tonic does not build for wasm32-wasip2.** Not with features off — at all,
which is why enclave-runtime's own `guest-grpc` example hand-frames gRPC. What
tonic was still providing turns out to be three things, none large: the framing
(five bytes — a compression flag, a length, the message), the status (a code and
a message in the trailers), and a generated service trait. `src/grpc/` writes
the first two out and drops the third: the paths are
`/cosigner.v1.Cosigner/<Method>` and there are fourteen, so `session.rs` matches
`:path` against a table. The messages are still generated — `build.rs` runs the
same protoc pass with `build_server(false).build_client(false)`, so what lands
in the crate is prost and no transport. The build script runs on the host, so
needing protoc there costs the wasm build nothing.
`src/grpc/duplex.rs` is where a bidirectional stream lives. `wasi:http` looks
request/response and half of it is, but `response-outparam.set` is documented to
"allow execution to continue after the response has been sent" and
`incoming-request.consume` borrows rather than consumes — so the request body
and the response body are two independent resource trees a guest may hold at
once. `SessionBody` holds both and drives the handler between reads.
The ceremonies did not change, and that was the test of whether the transport
had been kept at arm's length: `yield x` became `duplex.send(x)`,
`inbound.next().await` became `duplex.recv().await`, and the four
`async_stream::try_stream!` blocks are otherwise the same code as plain `async
fn`s.
Everything below `session.rs` is now synchronous. Every `.await` in the crate
outside the streams traced back to `seal_snapshot`/`restore_snapshot`, which
call a blocking store and were `async` for no reason; `store.rs` also stops
hopping through `tokio::task::block_in_place`, which existed to keep a commit's
fsync off a multi-thread runtime's other tasks. There is no runtime now and no
other tasks: one instance serves one request.
**SQLite was a quarter-megabyte of vendored C for a four-call key-value store.**
It does build for the target — enclave-runtime has a conformance suite proving
it — but it needed wasi-sdk and `-DSQLITE_THREADSAFE=0` to get past a
`pthread_create` static assertion, against a schema of `(tree, key) -> value`
with no query, no join and no index. It is a directory now: one per tree, one
file per key, both names hex.
The hex is the part doing real work. Trees and keys are arbitrary caller strings
— a group key, a contact label, `a:b`, `a*`, `../../etc/passwd` — and hex makes
every one a legal, unambiguous, case-stable filename with no separator to
smuggle a path through and no metacharacter left live. The RESP backend this
descends from flattened `(tree, key)` into `"{tree}:{key}"` and recovered the
tree with a `SCAN MATCH` glob, so a key containing `:` aliased into a
neighbour's namespace; hex makes that unrepresentable rather than merely tested
for. Writes land through a temporary and a rename, which `wasi:filesystem`
documents as atomic — one directory-entry move inside one commit — so a reader
sees the old value or the new one and never a torn seal.
`SQLITE_PATH` becomes `STORE_DIR` throughout, and `db-reset` removes a directory
rather than a file and SQLite's two sidecars.
`main.rs` is `#[wstd::http_server]`. There is no listener, no runtime to start
and no shutdown signal: the component exports `wasi:http/incoming-handler` and
the host owns the socket, the TLS and the HTTP/2 negotiation. That has a
consequence worth stating plainly — **there is no native server any more.** The
macro leaves `fn main` as an `unreachable!()` stub, so `runtime-build` builds the
wasm and `runtime-run` refuses with an explanation instead of starting something
that panics. `up`, `signet-hardware` and `e2e-mutinynet` still carry their own
`cargo run --bin cosigner` lines and need the same fix.
`stream_auth_test.rs` drove tonic over a TCP socket; it drives
`CosignerService::route` with a real framed body now, which covers the routing,
the frames and the `grpc-status` trailer as well as the `check()` regression it
was written for. Three cases are new, including a stream cut off mid-ceremony —
the trailers are the only place that difference can be said, and reporting OK
would tell a client its round completed.
A dead `tonic::Streaming` field goes from `BoardingSettleInFlight`: one writer,
no readers, and a doc comment describing a design that was already gone.
The vendored `wit/` and `scripts/wit-drift.sh` land here too, from the settle
watch. `wasi-sdk` is still needed for one vendored C library — secp256k1-sys —
and `scripts/build-cosigner-wasm.sh` mirrors enclave-runtime's own flags.
`make cosigner-wasm` produces a 4.2M component. 40 tests pass; host build clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stages 1–6 of the client restructure. The cosigner serves one gRPC service with
four bidirectional streams and ten unary calls, and has no outbound sockets —
so the caller drives the Ark protocol and relays each ASP event in. Nothing on
the Dart side knew that: `app-core` spoke 25 REST paths that no longer exist.
**The ASP is ours now.** `asp/asp_client.dart` is the eleven calls the cosigner
used to make on our behalf, transliterated from `crates/ark`'s own client.
`getOwnedVtxos` queries BOTH of the wallet's scripts, always: a wallet holds a
mixed set — a boarded VTXO keeps the boarding delay while received and refreshed
ones use the unilateral delay — so asking for one makes the other bucket
invisible, and invisible is indistinguishable from empty. It also tags each VTXO
with the delay of the script it came under, because the cosigner refuses a delay
outside the ASP's pair and only the caller knows which script produced which.
`sessions/{sign,dkg,send,settle}_session.dart` drive the four ceremonies. Two
invariants are written into the settle driver as comments: open the ASP event
stream *after* `RegisterIntent` returns but *before* replying `IntentRegistered`,
and answer every server message — the cosigner reads after every yield, so a
driver that skips a reply deadlocks the round rather than failing it.
**Addresses are derived over FFI, not in Dart.** `GetArkAddress` and
`GetBoardingAddress` are gone and `crates/ark`'s derivation is behind the
`signing` feature, which pulls `tonic-build` into its build script — every
cross-build would then need protoc to generate prost types the FFI never calls.
`ffi/src/ark/address.rs` lifts the three functions, which need only `ark_core`
and `bitcoin`. Reimplementing in Dart was never an option: two divergent VTXO
taptrees already exist in this repository, with different opcode order,
different sequence encoding and a different output key, and a third guess means
funds at an address nobody can spend. A parity test against `ark::client`
across four networks and three delays is what keeps this one honest.
`server_host.dart` gains a pinned ASP per cosigner host and refuses an
unconfigured one rather than defaulting. The wrong ASP does not redirect funds —
every output is derived from the cosigner's own key — it produces VTXOs the
cosigner will not recognise, which reads as an empty wallet. That is worth
failing loudly for.
**The delegate moved.** `has_active_delegate` came from a cosigner watching the
ASP for us; it cannot. The fact now lives where the knowledge is, as the
outpoint set the last settle covered, persisted — without persisting it a cold
start reads as "no delegate" and settles again: a real batch round, minutes
long, for a delegate already sealed. Note that renewing is now genuinely
expensive where `settleDelegate(storeOnly: true)` was cheap, and
`_delegateIfNeeded` still fires it automatically on receive. Whether an
unattended multi-minute round should start unasked is a product call;
`needsDelegateAction` is the mechanism if the answer is no.
**Two things the app was saying that were not true.**
The Ark sheet read "delegated to the server, which automatically refreshes all
your funds before they expire — no action needed". A guest has no egress, so the
cosigner cannot settle for us at all; it holds the signed renewal, watches the
clock and wakes us. "No action needed" is the kind of untrue that costs someone
their VTXOs.
The attestation badge rendered nothing when the status was null, and the status
is now always null — so a user who saw a green "Enclave Verified" badge sees an
empty gap instead, silently downgraded. The widget is deleted: verification
belongs per request, where a polled badge could only ever describe some earlier
request and never the one carrying the money. The connect-time PCR0 guard stays,
failing closed, with a comment saying plainly that it asserts something narrower
than it reads.
**The push path was entirely dead, not just the background half.** Every handler
read `msg.data['type']`; enclave-runtime sends `{"v", "category", "ref"}` and
never sets `type`, so all three early-returned on every message. They key off
`category` now, matching `settle-due`. The background delegate is deleted — it
called `settleDelegate(storeOnly: true)` under an 8-second timeout, and renewing
is now an ASP round that waits on the ASP's schedule; a background isolate
cannot drive one, and the cosigner could not have used a sealed delegate by
itself anyway. The handler itself stays, because the wake is data-only by design
and nothing else runs for it.
`registerCurrentToken` enrolled nothing. `MpcClient` simply never exposed
`registerDevice`, though the RPC, the connection method and the auth op all
existed — so no token could be enrolled, `wake` would find zero devices, and the
cosigner's whole settle watch fired into nothing. Added and wired.
Still open: `requestPayment` throws. The request is addressed to the PAYER's
cosigner, and enclave-runtime resolves the tenant from the caller's own
interaction token and strips any tenant header a client sends, so every
connection we can open lands in our own instance. Carrying the signed request
out of band and having the payer's app submit it would close this with no change
to the cosigner, but that is a product decision. The inbox half works.
`dart analyze` clean in protocol and app-core; `flutter analyze` 0 errors, 12
tests pass, `flutter build apk --debug` succeeds. e2e is not ported and cannot
run yet — it spawns a native cosigner binary that no longer exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stops asking twice enclave-runtime gates every request on a WebAuthn assertion bound to its method and path, and stamps the tenant it resolved to. Checking a Schnorr signature by the wallet's share key in every body as well proved nothing more — the same device holds both — so it is gone: from the cosigner, from every message (the fields are `reserved`), and from the client. The cosigner requires `x-enclave-tenant` on every call and fails closed without it. **Signing moves into the streams.** The runtime allows one active request per tenant for a stream's whole life, so `Send` and `Settle` opening a nested `Sign` stream while parked would deadlock until `max_interaction` killed them. The FROST commitment/share exchange is now in-band on both streams. **A request to pay is authored, not addressed.** A requester cannot reach the payer's cosigner — the tenant comes from the caller's own token — so the requester writes a request signed by their group key (BIP-340 over a domain-separated digest of payer, requester, amount, memo, expiry, not-after and a nonce) and the payer's app delivers it. The payer's cosigner verifies the signature, that it names this wallet, the validity window and the contact allowlist, refuses a replayed nonce, and derives the payee address from the key that signed — never from anything the requester supplied. **A wallet with a key refuses a second DKG.** `install_policy` overwrote unconditionally, and 2-of-2 has no way back from a replaced key. A send also now invalidates the sealed delegate, which it spent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The verifier expected nitriding's protocol: a 79-byte `user_data` of `sha256:` prefixes whose TLS hash it never compared, PCR0 only, and the AWS root prepended to the bundle rather than compared — so no dev document could pass and a production one was checked less than it read. It now follows enclave-runtime's own `nitro-attestation::verify`, in pure Rust (that crate verifies with aws-lc-rs, which needs CMake for every mobile target): - the chain against a pinned root, byte-equal, with validity windows, CA constraints, issuer names and signatures checked at every depth, before the leaf's key is trusted for the COSE signature; - ES384 only — QEMU's unsigned `alg: -1` documents are refused; - PCR0 and PCR16 together: the image, and the component that image measured before it served; - the 68-byte multihash `user_data`, bound to the certificate *this connection was served*, and its guest hash measuring to the PCR16 the document carries; - the nonce, and the document's age. The tests run against a real document captured from a dev enclave serving the cosigner, with a refusal for each check. The FFI is one call, `enclave_verify_connection`; the per-response Schnorr verifier went with the protocol it served. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… against a real enclave **Every approval attests.** `EnclaveGate` verifies the `x-enclave-attestation` document on every `/auth/*` response against `EnclavePins` and the certificate its socket was served, before reading the body. Guest responses carry no document, so the cosigner's channel dials through `PinnedTransportConnector`, which refuses a socket serving any certificate but the attested one. **Approvals are minted before a call exists.** grpc-dart runs an async metadata provider after the connection is ready and then dereferences a transport that may have gone meanwhile — a mint is two round trips and a signature — so calls failed as `UNAVAILABLE: Null check operator` or never left. `CosignerConnection` now awaits an `Approver` first; streams open once their token is in hand. `MpcClient.enclave(gate:)` replaces the TLS-and-interceptor parameters. The gate takes a `PasskeyRegistrar` for enrolment, and `Authenticator` is handed the whole request options so a platform authenticator can add to them. `writePaymentRequest` / `receivePaymentRequest` carry the signed request out of band. `DevEnclave` reads a dev enclave's run directory — pins, Pebble root, relying party — so the harness and the CLI wire a wallet identically. The e2e suite runs against `dev-enclave.sh` under QEMU, 14 tests: DKG and its refusal over an existing key, board, settle and sends, mixed exit delays, restore from storage, contacts, request to pay, share gating, and three for attestation — the served component's hash, a wrong PCR16 refused before anything is sent, and a channel pinned elsewhere refused. The REST-era suites are archived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Rust CLI spoke REST routes, per-request Schnorr auth and dev JWTs, none of which exist; porting it would have meant reimplementing the gRPC sessions, in-band FROST, the ASP client and request authorship that app-core already has. So it is Dart now, over `MpcClient` and `DevEnclave`: every call approved by a software passkey, every approval attested. new, use, wallets, whoami, info, receive, boarding-address, fund, board, balance, send, contacts, contact-add/rm, request (hands the signed request straight to a local payer), accept-request, requests, approve, decline. Regtest only: passkeys and shares are plaintext, kept per enclave boot under ~/.merlin-cli/enclaves/<root fingerprint>/, since a boot starts the store from nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`PlatformPasskey` is the gate's authenticator and registrar over Credential Manager, and the share's seed: every assertion evaluates PRF, so a spend is approved and unlocked by one gesture, and the output is stripped before anything is sent. Onboarding is server, passkey, DKG — a passkey first, because nothing reaches the cosigner without one, and DKG blinds the share under its PRF from the start. After registering it waits until the passkey can sign, asking with `preferImmediatelyAvailableCredentials` so a not-yet-indexed passkey fails quietly rather than opening "Sign in another way"; one that never becomes usable is replaced. Registration asks for a discoverable platform passkey. The runtime offers `residentKey: discouraged`, which Play services before Android 14 takes literally: it made a security-key credential outside Password Manager that One Tap never finds. webauthn-rs does not enforce the choice, so the client makes it. Every host is an enclave with pins: remote from the manifest (PCR0 and PCR16 under the AWS root), local from build-time defines. Production passkeys are bound to vtxos.com, whose assetlinks.json names the app, not to each deployment's host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng enclave `make up` started a native cosigner that no longer exists. `make up-enclave` builds the component, brings up regtest and arkd, mines every 10s, reverses the phone's ports, and boots the cosigner in a QEMU enclave — with rp id vtxos.com and the app's signing-key origins, so a phone can register against it. `make down-enclave` stops it and whatever an interrupted boot left holding its ports, without matching the shell that runs it. `make e2e-enclave` runs the suite, booting an enclave or attaching to one (ENCLAVE_RUN). `make flutter` passes the running enclave's trust root, Pebble root, PCRs and rp id as defines, and refuses to build without them — it used to resolve the script from the wrong directory and build an app with no pins at all. `make cli` runs the REPL. `bitcoin.sh init` unloads every wallet but `default`, because NBXplorer's root-path RPCs fail with two loaded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…led delegate A VTXO has to be refreshed in a batch round before the ASP's expiry. The cosigner held the machinery for a delegate — the wallet pre-signs an intent and `ALL|ANYONECANPAY` forfeits, the cosigner signs the tree with its own key — but ran it immediately in an app-relayed round, and its watch never armed because VTXO expiry never reached it. Now it does what a delegate is for. **Sealing.** `VtxoInput` carries the indexer's `expires_at`. At the end of every `Send` and `Settle`, on the same stream and under the same approval, the wallet sends `SealDelegate` with its whole current set; the cosigner builds a delegate valid from the earliest expiry less the safety margin, the wallet FROST-signs it in-band, and the cosigner seals it and answers `DelegateSealed`. `SettleOpen.seal_only` does the same alone, for funds that arrived by a receive. The watch is armed for the delegate's deadline itself, and re-armed (cancelled, forgotten, enqueued) when a new delegate moves it — the runtime refuses a task id reused with different input. **Running.** `asp/` is a client for arkd's REST gateway over the guest's `wasi:http`: register intent, the event stream as server-sent events, confirm, tree nonces and signatures, forfeits. When the watch comes due, the background task runs the sealed delegate against it and drives the same `settle_on_event` steps an app relay would — no wallet signature, no phone, no passkey. The registered intent's id is sealed as soon as the ASP assigns it, so a retried run follows it rather than registering twice. With no `ASP_URL`, or a round that fails, it wakes the owner and the next interval tries again: a conclusion, not an error, because five errors kill the watch for good. Also: `run-task` is given the runtime's run id, `<id>:<generation>:<occurrence>`, which the watch refused on every run; the vendored `tasks.wit` follows the runtime's documentation of it; an interactive refresh builds its delegate valid now, which the ASP requires; and the watch tests' ASP parameters were placeholders every delegate test had been silently skipping on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…settle `sessions/delegate.dart`: once an operation's result is indexed, the session sends the wallet's set and answers the cosigner's sighashes, so a delegate is sealed under the approval — and the passkey seed — the operation already had. A seal that cannot happen does not fail the operation; it leaves funds the app shows as unprotected. `MpcClient.delegateStatus` is what is sealed, `unprotectedVtxos` what it does not cover (answered from the indexer, no cosigner call), and `protectFunds` seals over a receive with one approval of its own. `DevEnclave.storeId` names a kept dev store. The e2e harness boots with ASP egress, `ASP_URL` and a margin that makes a delegate due five minutes after its VTXOs were made. The full flow asserts a delegate over the boarded VTXO and over each send's change, and that a receive is covered only once protected. A new test boards, waits for the delegate to come due, and watches the cosigner refresh the funds from the enclave with no call from the client — 15 of 15. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…w themselves Every cosigner call is a passkey approval, so every call the app made on its own was a fingerprint the user did not ask for. - A cold start asks the cosigner nothing: the network comes from the ASP and is cached. - Contacts and payment requests are kept locally and changed as the cosigner confirms each change; opening a screen reads nothing, and pull-to-refresh is the only read. - The push token is enrolled when it changes, not on every start. - Onboarding is one fingerprint: the DKG's own approval waits for the new passkey to be findable, rather than a sign-in of its own before it. - Nothing refreshes unasked. The cosigner renews funds from its sealed delegate; the Ark tab offers "Renew automatically" for funds no delegate covers, and "Refresh funds" only for funds past due. `delegate-settled` and `settle-due` wakes refresh what the tab shows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ave restart `balance` says whether a sealed delegate covers what is held and when the cosigner runs it; `protect` seals one. Wallets are kept per store id when the dev enclave keeps its store, so they survive a restart; per boot otherwise, as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… arkd `make up-enclave` keeps the enclave's store across restarts and cosigner rebuilds — a phone keeps its wallet, and needs only `make flutter` for the new pins — with `FRESH=1` to start over. It gives the cosigner the one origin it needs, arkd on the host, with `ASP_URL`, a ten-minute background task limit for a round, and a delegate margin of 15060s so a delegate runs about five minutes after its VTXOs were made (`ENCLAVE_DELEGATE_MARGIN` for another). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Ark tab's transaction list is rebuilt from the ASP indexer: every VTXO the wallet's scripts ever held, grouped by the transaction that made or spent it, into received, sent, boarded and renewed. Receives included, and no cosigner call, so no passkey prompt. Fewer prompts: - Ark send, board and renew approve the cosigner call before unlocking the share, so the one gesture yields both the token and the PRF seed. It was two fingerprints: the share asked first, then the call. - The device's push token rides DkgOpen, and after a rotation the next SealDelegate, instead of a RegisterDevice of its own. Onboarding no longer prompts again after the DKG. Also: rust-analyzer no longer links the removed cli/Cargo.toml, and a dead-code allow for DelegatePhase::Settling without the client feature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mutiny.vtxos.network was a plain EC2 host running the native cosigner binary, which no longer serves anything. It is torn down (a snapshot of its data volume kept) along with the dead introspector stack, and replaced by infrastructure/mutinynet-qemu: - tofu: a c8i.large with nested virtualisation running QEMU's nitro-enclave machine, 443 only, SSM administration, a separate store volume, the DNS record, and a bucket with private artifacts/ and public pins/. State in s3://vtxos-tofu-state. - deploy.sh builds and packs on the build machine (enclave-runtime dev-enclave.sh --pack), ships through S3 and installs over SSM; the host never compiles. Let's Encrypt, arkade's ASP as guest egress, real FCM. - On every boot the host publishes pins/deployment.json: PCR0, PCR16 and the emulator's per-boot trust root. App: the MutinyNet host's pins come from that manifest, trust root included, refused if it names another host or relying party. The gate refetches pins once when a document fails, so a redeploy or restart does not strand a running app; an unreachable manifest still surfaces as the attestation failure. make mutinynet-deploy / mutinynet-smoke replace the stale signet targets. Test coins only: on an emulator the host can read tenant data and sign attestation documents. Runbook in infrastructure/mutinynet-qemu/README.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The wallet carried a second, parallel Bitcoin wallet — its own single key, balance, send and receive screens, Electrum sync, and an "offline mode" that hid Ark and fell back to it. None of that was Ark. It is gone, along with the Services tab, which never had a screen behind it. What is left holds money in Ark, gets it out alone, and is configured; boarding stays as the on-ramp, reduced to the deposit scan it always was (app-core/lib/boarding.dart). In its place, the thing README called "designed, not implemented": every seal now also signs one unilateral exit per VTXO — a spend through the VTXO's own exit leaf, after its timelock, paying an address in a wallet this app does not control. The owner keeps them and can broadcast them with nobody's help, which is the one thing that becomes unobtainable if the cosigner stops answering. - crates/ark/src/exit.rs builds and finalizes it; both sides use it, so the cosigner knows what it signs and the wallet can check it. The old hand-rolled exit leaf was unspendable — wrong opcode order, unencoded delay, ending on OP_DROP — and is bypassed rather than trusted. - The exits ride the seal's existing FROST round, so they cost no extra approval, and dust is skipped rather than failing the seal. - The wallet rebuilds every exit itself and refuses the round unless each sighash matches; on the way back it checks each transaction is its own, signed by its own key, and derives its txid from those bytes. - Zero fee, with a P2A anchor: a fee fixed today is a guess about a fee market years away, and a wallet whose cosigner is gone cannot re-sign. - The Exit tab shows what is covered, what is not, and — from the indexer — the whole path on-chain, commitment first and the exit last, with anything missing named rather than implied. Proven against bitcoind on regtest: an exit is refused before its timelock (non-BIP68-final), accepted into a block after it, and pays the whole VTXO to the owner's address. Also: "Refresh funds" asked the owner to act at the moment the cosigner was about to act itself; it now appears only when a renewal is genuinely late or expiry is near. A refresh already sealed on its way out — it now waits long enough for the indexer to make that reliable, and says so when it could not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A wallet used to die with its phone. Its FROST share existed in one Hive box on one device, and the passkey's PRF was only a blinding factor over it — so losing the phone lost the key, even though the passkey itself was safely synced in Google Password Manager or iCloud Keychain. Every file that mentioned it said the same thing: "no cloud backup", "no recovery share". The passkey is now the whole wallet. Nothing is escrowed and nothing is exported; what changes is that both halves of the share become reproducible. s_wallet = f_wallet(id) + f_cosigner(id) - app-core/lib/passkey/key_derivation.dart derives the wallet's dealer polynomial (a0, a1) from the PRF output through one labelled HKDF. a0 also fixes the identifier, since it is derive(a0*G). Same passkey, same polynomial, same identifier — on any device it syncs to. A VRF would buy nothing here: nobody has to be convinced the derivation was done right, only that it reproduces, and that is checked against what the ceremony recorded. - The cosigner keeps the other half. It used to destroy its polynomial at the end of round two; it now seals the one scalar it dealt the wallet (SnapshotState.wallet_dealt_share_hex, 32 bytes). Worth nothing alone — the passkey holds the term it is summed with. - A new Recover RPC hands that back, and refuses four ways: before onboarding, to an identifier the ceremony never saw, for a wallet sealed before this existed, and it never installs a policy. It is the mirror of refuse_if_onboarded, which bars the second DKG that would otherwise be the only way to do this. - MpcClient.recover() rebuilds the share, fixes the even-Y sign against the sealed verifying share, and saves nothing unless s*G is that share. PlatformPasskey.discover() finds the credential with an assertion carrying no allowCredentials — the PRF rides the same gesture — behind a new "I already have a wallet" route. Share blinding moves onto the same KDF, one label apart. It used to abuse a zero-constant refresh polynomial as a seed expander, defined differently in Dart and Rust; SECURITY_FINDINGS TH-6 flagged exactly that, and it is now off every wallet path. A wallet blinded under the old derivation cannot be unblinded under this one, which is fine — nothing is in production — and the sign path now checks s*G against the verifying share so any seed mismatch says so plainly instead of failing as FROST aggregation. Deriving keys from the PRF means a wallet with no seed source can no longer run a ceremony, which was every e2e and CLI wallet. SoftwareAuthenticator has a real PRF of its own now (HMAC-secret over the credential's key material), so a test wallet is recoverable from its state file exactly as a real one is from its passkey — and the e2e proves recovery rather than simulating it. Verified: 76 cosigner tests, including the arithmetic over a real ceremony; 69 app-core tests; 21/21 enclave e2e, where a second client with a fresh storage id recovers a boarded wallet and then *sends* — a signature the cosigner accepts is the only thing that proves the share is right — and a changed PRF is refused rather than half-served. The cost is stated rather than implied, in SECURITY_FINDINGS RC-1/RC-2: the passkey is now a single factor, and cross-device PRF stability is platform behaviour rather than a guarantee. It fails loudly when it does not hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…for every operation The phone kept part of the key. A blinded FROST share sat in its Hive box, and beside it, in the clear, the DKG dealer secret a0 as `onchainSecret` — for an on-chain wallet that no longer exists, read by nothing. The box is unencrypted and append-only, so every value ever written was still in the file. And the passkey's PRF output was kept in memory for two minutes after ANY assertion, so that the several FROST rounds of one send would not each prompt: a clock was the only limit on its life, and approving a contact list left one lying around. Recovery already knew a better way to get a share, and now everything uses it: s = f_wallet(id) + f_cosigner(id) The first term is derived from the PRF, the second is the one scalar the cosigner sealed at DKG. The device stores only what a rebuilt share is checked AGAINST — identifier, verifying share, group key — and a recovered wallet and a freshly made one are the same thing. - One fingerprint per operation, still. An approval is bound to one method path, so asking `Recover` before each send would have cost a second gesture. The contribution rides the stream the operation already approved instead: the first server message of Sign, Send and Settle carries `wallet_dealt_share`, once per stream, and never the cosigner's own share. One rule for all four (`dealt_share_for`), answering only the identifier the ceremony recorded. That identifier is public — it tells a wrong wallet so, it authenticates nobody; tenants are kept apart by the runtime, one instance and one store each, and the tests say so rather than pretend to a tenant table. - Sign had to change shape. The wallet committed first, and its nonce is hedged with a share it no longer has until the cosigner answers. So the cosigner commits first, as it always did in-band; the binding factor covers every commitment whoever sent theirs last. It is the in-band round over one message now, and script-path only BY NAME — it always was in effect, since the cosigner signs untweaked and checks every share. `sign_open`/`sign_finish` and the `applyTweak` nothing called are gone. - `MpcClient._withOperation` is the whole lifetime of a secret: take a turn (operations that sign are serialized), do the slow secret-free reads BEFORE the approval (it is good for under a minute), take the seed from the approval's own gesture (`SeedSource.seedDuring` — the PRF is no longer even evaluated for any other assertion), overwrite it once the polynomial exists, refuse a passkey that is not this wallet's before anything is opened, rebuild when the stream brings the contribution, check s*G against what THIS DEVICE stored, sign every round of that stream with it, dispose in a finally. - `WalletStore` refuses a share by any name it has had, at any depth, and state is versioned. Old state is refused by name rather than read as absent — which used to send the app back to onboarding over a wallet that exists. No migration: the app offers a reset on launch, the CLI has `reset <name>`, and the wallet comes back from its passkey. Blinding, its label (retired, never to be reused), PinSeedSource, gateShare and the PIN pad are deleted. Cancellation, which three reviews were right about. An operation that cannot be stopped is a share that cannot be released and, behind one lock, a wallet that can do nothing else — and `close()` is graceful, so it never was a cancel. `cancelOperation()` ends the turn at once wherever it is parked, with the cleanup in the OUTER frame, before the next operation can start: - every wait on a party other than the cosigner goes through `CancelSignal.guard`, so a settle parked on a silent ASP unwinds and takes the share in its frame with it; - a cancelled Recover stays cancelled — the call itself is cancelled, and `_stillRunning` refuses every state change for a turn that is over, so a late reply adopts and saves nothing; - a prompt cannot be withdrawn from Dart, so a cancel while one is showing starts nothing when it is answered: the late seed is overwritten, the late approval dropped (also when the passkey fails after approving), and the next operation waits for that cleanup rather than tripping over a passkey that is still mid-gesture. The app gets `cancelOperation`, cancels before it hangs up in reconnect and reset, and the boarding screen gets "Stop waiting". Deliberately NOT wired to backgrounding or a timer: what an abandoned round costs is the ASP's to say. What this is not. It does not zeroize: the seed buffer is overwritten, but coefficients and the share are Dart BigInts, the FFI takes a key package as JSON, and the PRF output arrives inside an immutable string. The lifetime is bounded by reference. Signing always needed the cosigner, so there is no new availability dependency; pre-signed exits hold no secret and the cosigner's unattended renewals use no wallet share — both untouched. A development architecture, not production-ready: README "No key at rest", and SECURITY_FINDINGS RC-3 / RC-4 with the open follow-ups as NK-2..NK-6. Wire break and state break: old app and new cosigner cannot sign with each other, dev wallets need the reset, and a seal from before the dealt share was kept needs a fresh store. Tests: cosigner 84 (stream_contribution_test drives `route` over real frames), app-core 119 (an in-process cosigner over real gRPC; reads the box file back as raw bytes; every cancellation test mutation-checked against its fix), flutter 12, and the enclave e2e 22/22 against this tree — new: nothing secret at rest, and an authenticated tenant who knows the victim's seed still cannot have their half. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A second 2-of-2 over a key of its own, minted by a reshare so the wallet's own
key is untouched and escrowed money is visibly separate. A service is paired
into it by a key-preserving refresh, so `{service, cosigner}` signs the same key
`{wallet, cosigner}` does — two pairings, one key, the cosigner in both. Nothing
moves without it, which is what a 2-of-3 would have quietly given away.
Say the limitation plainly: nothing in Bitcoin enforces this. Both pairings sign
the same key, so what stops an owner emptying a live escrow, or a service taking
after the deadline, is this cosigner declining to co-sign. Enclave-enforced, not
script-enforced — defensible because the refusal lives in attested, measured
code, but not the same guarantee as an output that cannot be spent.
The share reaches the service as two halves by two routes, and neither party may
hold both: the cosigner deals one from inside the enclave, the wallet deals the
other from the device. A pairing counts as finished only when both parties say
so, because neither can answer for the other — only the service ever holds both
halves, only the wallet knows its own delivery landed.
The cosigner's half travels on a connection the runtime holds, not a request of
its own. Not for pairing's sake but for what comes after: a service asking to be
paid has to speak first, and it has no passkey for its user's tenant.
A release is judged on six things, none of them taken on the service's word: the
service that spoke is the one paired in; the escrow permits a release now, asked
again immediately before signing because two calls out sit in between; the
transaction satisfies the sealed policy; it fits what is left of the allowance;
the payment evidence satisfies the policy, fetched by the cosigner itself from a
provider the policy names with a credential bound to that provider; and that
payment has not already justified a release. Only then a signature.
The service sends a proposal, not a transaction. An Ark send is an ark tx plus a
checkpoint per input, so a supplied blob would have to be re-derived before it
could be signed — instead the cosigner builds it, judges what it built, and
signs what it judged. Nothing to bind, because it is one object.
The service commits first, so the cosigner makes its nonce and its share inside
one invocation and never writes a single-use nonce down. FROST's binding factor
is what makes commit order safe.
Which payments have been spent is the wallet's ledger, not a deal's: a session
can be replaced and a wallet can hold several escrows with one service, so a
ledger scoped to either would let one payment pay twice.
Nothing is scheduled for a deadline. A deadline is a fact about the clock that
every decision reads out of the seal, so an instance that did not exist when it
passed reaches the same answer as one that did. The cost, stated: nobody pushes
the owner when a deal ends; they see it in the escrow listing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Alice commits bitcoin to an escrow for card spending and buys a $20 coffee. The card
programme settles with the merchant in fiat, as card programmes do, and then asks to be
reimbursed out of her escrow. The cosigner verifies, for itself and with its own read-only
credential bound to the provider's origin, that the purchase actually cleared — and only
then signs.
The release does not fund the card payment. It reimburses a settlement that already
happened. The card network and the processor behind it are simulated end to end and every
simulated record says so; the escrow, the pairing, the policy, the evidence fetch, the
threshold signature and the Ark transaction are the production paths.
`examples/card-escrow/` holds the settlement service, a deterministic provider in which an
authorization and its clearing are two linked records rather than one object with a flag,
and the failure demonstrations. `e2e/bin/card_walkthrough.dart` is the walkthrough: it
funds an escrow, has it reimbursed, breaks the connection on purpose to show it recover,
waits out the deal and reclaims what is left. It has been run — 20,000 sats reimbursed on
verified evidence, 80,000 reclaimed.
Building it changed two things in the cosigner.
**A deal now ends one way: its deadline passes.** `EscrowState` is gone, and with it the
owner's ability to close early. A commitment she can revoke is not one — a service that had
already paid a merchant against it would be left holding the loss, which is the thing an
escrow exists to prevent. Her control is the deadline she chooses, and short deals struck
again as needed cost nothing. What that buys is that a lapse leaves no record because there
is nothing to record: the seal is byte-identical either side of the deadline, and every
decision reaches the same answer the same way. A second way to end a deal would have been a
second thing to get wrong, and the one that was there could be got wrong silently — it
wrote a flag that a lapse never writes.
**Reclaim exists.** `{wallet, cosigner}` spending the escrow key back to its owner once the
deal is over — the pairing the service is not in. The destination is derived from the key
this cosigner already holds and is not on the wire, so a reclaim cannot be pointed anywhere
but home. The inputs' exit delay is derived too: an indexer does not report it, and a zero
produces `OP_0 OP_CSV`, which no ASP accepts.
A policy term converts a fiat amount at a rate the owner sealed, with integer arithmetic
throughout and a refusal rather than a rounding when it does not come out whole. Without
it, "a $20 purchase cleared" says nothing about how many sats are owed, and whoever
supplies the rate decides how much leaves the escrow.
And one bug only a real transaction could show: every Ark transaction carries a zero-value
pay-to-anchor output so it can be fee-bumped, and the policy counted it as a destination —
so `outputs_only_to` refused every release ever made. Excluded now, and only at zero value,
because anyone can spend a P2A output and one carrying value is money leaving to whoever
claims it first.
Four rounds of review landed on the service, and every finding has a test that fails
without its fix: a retry rebuilt its proposal from current inputs and so proposed a
different release under an answered request id; a pairing reported itself ready when its
share could not be stored; concurrent saves raced on one temporary file; a signed release
kept nothing it needed to be submitted after a restart; two purchases on one escrow could
select the same inputs; and a failed submission released the escrow while its transaction
might still land.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Money enters this wallet on-chain, at the boarding address, and then settles into Ark. That was already the only route, but the UI offered "Receive" and "Board" as separate places and put an Ark address on the first of them — which is not a way in at all unless somebody else is already sending you off-chain. So Receive IS the boarding screen: the address, a QR for it, what has arrived and what is still in the mempool, and the button that settles it. `/ark/board` and the Board button are gone, and the Ark-address tab with them. It now says what it is worth, which is the thing an amount on a screen is for: the Ark balance beside what has just arrived, and — once boarding finishes — how much settled and what the wallet holds now. The boarded figure is read before the settle, because afterwards the boarding balance is zero and there is nothing left to name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gate has checked PCR0 and PCR16 on every call since attestation landed, and nothing ever showed them. An app that cannot say what it is trusting is asking to be taken on faith, which is the opposite of the point of attesting at all. A collapsed "Verified enclave" section under the balance card, with both and a tap to copy each. Read off `gate.pins`, so it is what was actually enforced rather than a label — and hidden until the gate exists, because before the first attested call nothing has been measured. Both, because neither is an identity alone: PCR0 says which runtime image, PCR16 which guest that runtime loaded. A known runtime can serve anything, and a guest measurement is written by the runtime that loaded it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…re they sign The review of the escrow commits (b8b9805..50f9bd0) found three highs, six mediums and a low. Each was reproduced or read end to end before it was fixed, and each fix has a test that fails without it. The ledger, with what was left open on purpose, is SECURITY_FINDINGS.md "PR #55 review, 2026-09-26". - H1 escrow operations could not be cancelled, and a cancelled reclaim could still move money: the three escrow streams were built outside CosignerConnection._track, their secrets lived in closures, and the waits on the ASP, the delivery and the confirmation had no CancelSignal.guard. Now tracked, rebuilt inside the WalletOperation, guarded. Duplex.close() also never returned for a stream nobody had listened to yet. - H2 a release was signed before it was recorded, and a failed seal was only logged — one failed write was one payment paid twice. Now record, seal, then sign, with the record rolled back when the seal fails. - H3 reclaim signatures taken while no deal was live could be spent under a deal struck afterwards. An escrow a reclaim was ever opened on is retired from deals: reclaim_opened_at is sealed before the first nonce, and open_escrow_session checks it first. - M1 x_only sliced a 66-byte key after lowercasing and a multibyte character panicked the guest; M2 an unreadable seal opened as a wallet with no key; M3 the plain-HTTP exception was a hostname prefix match; M4 escrows were not recoverable and a reset kept the old wallet's; M5 restoreWallet kept a credential id after a failed recover; M6 setState after an await in the signing and onboarding screens. - L a release answered under a previous deal is refused, not re-signed. cosign_session.proto: Recover returns the escrows with their derivation context, and an escrow says whether a reclaim was opened on it. Run: cosigner 224, app-core 140, Flutter 12, analyzers clean, and the enclave e2e 27/27 on this tree. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The e2e is the only thing that proves a ceremony end to end, and it was not in CI: booting the enclave needed Nix, cargo and an enclave-runtime checkout, for a boot that itself takes four seconds. enclave-runtime now publishes a self-contained bundle (image, host binaries, harness, WIT, the QEMU and MinIO container images) as a release asset, and this repository boots from it. - enclave-bundle.lock names the release and its sha256; `make enclave-bundle` fetches it into .enclave/, refuses a tarball whose hash differs, and .enclave/ then is the default ENCLAVE_RUNTIME for the e2e and the WIT check. The hash is the trust boundary: the bundle's image.env is sourced and its images are docker-loaded, so a lock bump is a review of what the runtime published. - The harness boots a bundle with --prebuilt and first checks its image.env against the options it would have built with. Those live in one place, e2e/lib/e2e_profile.dart; `make enclave-bundle-args` prints them for the runtime's "Publish a dev enclave" workflow. They are measured into PCR0, so a bundle packed with others is refused by name, with the repack command — a wrong renewal margin would otherwise skip the delegate test quietly. - ci.yml gains enclave-e2e: the bundle on a hosted runner's KVM, as the runtime's own CI already boots one; logs uploaded on failure. It replaces e2e.yml, which built a binary that no longer exists. cancel-in-progress. - The interactive stack keeps the checkout (ENCLAVE_CHECKOUT): its image is built with the app's rp id and origins, options a bundle cannot take. up-enclave.sh says so if pointed at a bundle. wit-drift no longer counts the unpacked bundle as a vendored copy, and compares against ENCLAVE_RUNTIME. - electrs pinned by digest, the one floating image in the regtest stack. Verified: 27/27 from the pinned release with the checkout moved aside; the drift guard refuses an edited image.env before boot; the runtime's own eight-leg e2e passed from the extracted tarball on the publish runner. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`dart pub global activate protoc_plugin` installs the latest plugin, 25.1.0, which emits stubs for the protobuf 6 runtime. `protocol` pins protobuf ^3.1.0, so the stubs `make proto` generated on the runner did not analyze, and the e2e job failed as soon as `dart test` compiled them — after the bundle had been fetched and verified, wasi-sdk installed, the WIT checked and the cosigner built. 21.1.2 is the generator for protobuf 3 and what generates them locally. Pinned in all three jobs, and named in the Makefile's hint and AGENTS.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
aruokhai
added a commit
that referenced
this pull request
Sep 26, 2026
… Grid Groundwork for MerlinPlatform, a separate repo that pays a Nigerian bank account through Lightspark Grid and is repaid out of the customer's escrow, but only once the cosigner has read the payout and the payee's account from Grid itself. What it needs from here: a reference Grid can name, the escrow service card-escrow already had, and the wallet side of the flow. - cosigner: `safe_reference` accepts ':'. Grid names a payment `Transaction:<uuid>`, and the URL is always origin + path, so a colon stays inside a path segment where it cannot start a scheme or an authority. Tested with a real Grid id; a scheme-looking reference is still refused. - crates/escrow-service: card-escrow's settlement service (its share of the escrow key, FROST signing, retries and reconciliation, ASP submission), moved out so every service paid from an escrow shares one copy. Card names made generic (Started/Settled, started_ref/settled_ref), and the never-read Service.terms and provider_origin dropped. - escrow-service: `Service::give_up`. A first ask writes its proposal down before asking, and a written-down proposal holds the escrow, so a payment that would never settle blocked every later one on the same escrow for good and was re-asked every 15s for ever. give_up frees it, and refuses once anything was signed — that is reconciliation, not giving up. - examples/card-escrow: re-exports the crate as `service`. Its tests had rotted since PR #55 added `reclaim_opened_at`; they are not in CI. - e2e/bin/grid_walkthrough.dart: the wallet side of the Grid flow. It pairs MerlinPlatform, checks every value the policy pins before sealing it (a platform could otherwise hand back `{"op":"always"}`), funds the escrow and waits for the platform to be repaid exactly the agreed price. The escrow-service lockfile is seeded from card-escrow's: ark-core pins secp256k1 0.32.0-beta.2, which is yanked, so a fresh resolve fails. Verified: cosigner tests; escrow-service 10/10 and card-escrow 57/57 (65 before the move, plus the two give_up tests); card_walkthrough.dart passes end to end against a dev enclave, and grid_walkthrough.dart against a dev enclave, MerlinPlatform and the Grid sandbox (₦30,000 paid out, 23,010 sats released). Co-Authored-By: Claude Opus 5.5 (1M context) <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.