Skip to content

New cosinger - #55

Merged
aruokhai merged 45 commits into
mainfrom
new_cosinger
Sep 26, 2026
Merged

aruokhai merged 45 commits into
mainfrom
new_cosinger

Conversation

@aruokhai

Copy link
Copy Markdown
Contributor

No description provided.

aruokhai and others added 30 commits August 13, 2026 18:07
… 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>
aruokhai and others added 15 commits September 16, 2026 21:13
… 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
aruokhai merged commit 458c030 into main Sep 26, 2026
4 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant