Skip to content

Add OpenCSV payments behind a dev build flag (prototype) - #2

Open
posix4e wants to merge 25 commits into
mainfrom
opencsv-wallet
Open

Add OpenCSV payments behind a dev build flag (prototype)#2
posix4e wants to merge 25 commits into
mainfrom
opencsv-wallet

Conversation

@posix4e

@posix4e posix4e commented Aug 1, 2026

Copy link
Copy Markdown

What

Native OpenCSV payments in Signal-iOS, behind a dev-only build flag. OpenCSV (org, paper) is a client-side-verified payment scheme anchored to Bitcoin: value travels as consignments (openings + a recursive STARK proof) delivered off-chain, and the recipient verifies the proof locally — no trusted server, no validator network. Signal is a natural transport: consignments are just attachments.

With BuildFlags.openCsvPayments (dev builds only, off everywhere else):

  • Receive: an incoming opencsv-consignment.bin attachment is verified in-app by the real recursive prover (Rust, via the OpenCsv binary pod) against an anchor-chain snapshot fetched from a user-configured URL, and renders as a payment bubble — +100 USD · verified / verification failed / verifying….
  • Send: an attachment-keyboard action opens a minimal sheet (amount + recipient key + anchor-server setting). Proving a transfer takes ~0.5–1 s on an A18/A19; the consignment goes out through the normal message pipeline.
  • Address exchange over chat: payments carry the sender's receiving key as an OpenCSV address: body line; a Share My Key in Chat button bootstraps first contact; the send sheet prefills the recipient key from the thread. No out-of-band key exchange.

Why the architecture looks the way it does

Modeled on the existing (MobileCoin-era) payments architecture and current SSK conventions:

  • SignalServiceKit/OpenCsv/ — service actor (OpenCsvPayments), typed FFI client, KeyValueStore-backed store, anchor-provider protocol. All protocol logic stays in Rust; Swift owns transport, persistence, and UI.
  • Secrets live in the Keychain via KeychainStorage; verdicts + consignment blobs live in the SQLCipher-encrypted GRDB via KeyValueStore (no schema migration needed). Coin state is not persisted: the wallet is rebuilt at startup by replaying stored blobs through the (milliseconds-fast) verifier, so the database can never disagree with the prover.
  • The receive hook is a two-line seam at attachment-download success; re-render goes through the standard db.touch(interaction:) path.
  • CVComponentOpenCsvPayment follows CVComponentPaymentAttachment's ManualStackView pattern; consignments are intercepted in buildNonMediaAttachment before the generic-attachment path.
  • The Rust core integrates like LibSignalClient: a binary CocoaPod (OpenCsv.xcframework, device + universal-simulator slices, module map). This PR uses the local-path pod (../opencsv-rs/apple); a released prebuilt with checksum pinning would replace it, exactly like LIBSIGNAL_FFI_PREBUILD_CHECKSUM.

Benchmarks

On-device prover numbers (opencsv-rs BENCHMARKS + issue #1): recursive transfer prove 548–960 ms (iPhone 16e / 17 Pro Max), verify 2–5 ms, proofs 46–56 KB — 3–5× faster than the reference server. Verification cost to the app is negligible; proving runs on the actor off the main thread.

Test plan

  • 12 unit tests in SignalServiceKitTests (OpenCsvTest.swift): consignment/address detection, verdict JSON decoding, store round-trips (Keychain secrets, verdict/replay/spend state), and live-FFI smoke tests against the linked Rust library. All green.
  • Debug (flag on) and Testable Release (flag off) both build under -warnings-as-errors.
  • End-to-end on hardware: a two-direction payment exchange between this app on an iPhone 17 Pro Max and the opencsv CLI linked as a secondary device, over production Signal, with matching supply audits — evidence in the tracking issue.

Prototype caveats (deliberate, documented)

  • Test-grade FRI parameters and an off-circuit issuer signature (see opencsv-rs README) — prototype soundness, not production.
  • The anchor chain is a demo HTTP server standing in for Bitcoin; the app fetches snapshots from a user-configured URL (nothing hardcoded, empty default).
  • The 2-in/2-out circuit shape means a wallet holding one coin can't transfer until it holds two.
  • Address announcements in chat are unauthenticated (like any pasted address); binding them to Signal identity keys is future work.
  • No push in personal-team demo builds, so receipt requires the app foregrounded (irrelevant to properly-signed builds).

🤖 Generated with Claude Code

https://claude.ai/code/session_01TpbPYGqsxNpwwBSfLxfJET

posix4e added 8 commits July 31, 2026 19:54
OpenCSV is a client-side-verified payment scheme on Bitcoin
(github.com/opencsvnet): payments travel as consignment attachments whose
recursive proofs the recipient verifies locally (~0.5s prove / ~5ms verify
on device). This adds the wallet engine behind a dev-only build flag:

- OpenCsvClient: typed Swift wrapper over the opencsv-ffi C ABI (Rust
  staticlib via the OpenCsv pod)
- OpenCsvPayments: actor owning the wallet; verifies downloaded
  consignments against a configurable anchor-server snapshot and proves/
  anchors outgoing transfers
- OpenCsvWalletStore: secrets in Keychain; verdicts, blobs, and spends in
  GRDB via KeyValueStore, replayed through the verifier at startup
- OpenCsvAnchorProvider: protocol + remote (user-configured URL, nothing
  hardcoded) + demo/test provider
- detection hook at the attachment-download-success seam
- BuildFlags.openCsvPayments (dev builds only)
- CVComponentOpenCsvPayment: amount + verification status bubble, modeled
  on CVComponentPaymentAttachment; consignment attachments intercepted in
  buildNonMediaAttachment before the generic-attachment path
- component plumbing: CVComponentKey/CVMessageCellType cases, state build,
  forwarding ban, marker body-text suppression
- attachment-keyboard action (1:1 threads, dev flag only) opening a
  minimal send sheet: amount + recipient owner key + anchor server URL
  setting; delivers the proved consignment through the normal message
  pipeline as an opencsv-consignment.bin attachment
Detection conventions, verdict JSON decoding, wallet store round-trips
(Keychain secrets, verdict/replay/spent state), and live-FFI smoke tests
against the linked Rust library.
The receive pipeline reads it before any send happens; saving only on
send meant a first incoming consignment verified against an empty chain.
Payments now carry the sender's receiving key as an "OpenCSV address:"
line in the message body, and the send sheet gains a Share My Key button;
the recipient field prefills from the newest key announced in the chat,
so no manual key exchange is needed. Matches the opencsv-signal CLI
conventions; covered by detector unit tests.
Sweep the thread's recent messages for consignment attachments without a
stored verdict: verify downloaded ones, enqueue downloads for pointers.
Covers consignments that arrived before the anchor server was configured
or whose download completed without a successful verification.
The personal-team entitlement trim and the local-networking ATS exception
are demo-environment changes and must not ship; they were swept into the
previous commit by accident.

@posix4e posix4e left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review (full findings below). Verdict: mergeable-behind-the-flag after 3 blockers + S1–S3. Security posture is genuinely solid — no verdict-spoofing path, keychain hygiene correct, concurrency sound.

B1 (unreviewable-by-upstream as-is): pod 'OpenCsv', path: '../opencsv-rs/apple' + path-checksum in Podfile.lock — non-reproducible, CI-breaking, no integrity pin on a prebuilt binary. LibSignalClient two lines above shows the convention (git tag + PREBUILD_CHECKSUM env). Also links the Rust staticlib into NSE/ShareExtension for a feature they never use.
B2 (funds can burn): sendPayment persists spent state before the message is enqueued, and the enqueue's unstructured Task swallows attachment-build failure — crash/failure between finalize and enqueue = coins marked spent, consignment never delivered, no pending-outgoing recovery path. Spent-marking must be transactional with enqueue, or a retry/resume path must exist.
B3 (wrong amounts displayed): no outgoing verdict at send time; the retry sweep's verify credits only the change output, so sending 5 of 100 renders '95 USD' on the sender's bubble; third-party consignments render verified '+0'. Verdicts need direction/amount semantics; write outgoing verdicts at send time.

Should-fix: S1 detection predicate inconsistent across download hook / render / retry sweep (pick one); S2 startup replay silently no-ops without a cached snapshot (lost spent-state); S3 persistence errors swallowed wholesale (try? on every store write — owsFailDebug minimum); S4 anchor-server trust boundary unexamined (no TLS/TOFU/checkpoint; comment it); S5 single-actor verify = cheap DoS (bound FFI input size); S6 filename-only detection + no file fallback on failed verdicts; S7 dead FFI surface (proveMint/initIssuer uncalled); S8 implicit asset choice; S9 tests cover leaves not the risky pipeline transitions.

Nits: Acknowledgements/Podfile.lock churn (COCOAPODS bump, moved boring-sys block, license string not body); blocking db.read in actor; raw Rust error strings in UI; unvalidated recipient hex; empty-blob null baseAddress to Rust; prefill should say WHO announced the key.

Positives worth keeping: KeyValueStore/replay design (persisted state can never disagree with the prover), hook placement, FFI memory discipline, localization/logging conventions, faithful CVComponentPaymentAttachment mirroring.

posix4e added 2 commits August 1, 2026 08:30
Real chains derive the anchor binding context from the anchor
transaction's funding outpoint, so the anchoring service owns it: the send
pipeline now reserves a context, rebinds the (already proved) record to it
via opencsv_pending_rebind — proving is ~1s on device and the context does
not enter the proof — and publishes both. Demo chains return no context
and keep the one the prover drew, so the flow is uniform.

AnchorProvider gains reserveContext(); publishAnchor carries the ctx.
Verification needs the anchor server, which is often unreachable exactly
when a consignment arrives (offline, server down, URL not yet configured).
Those attempts store nothing so they can be retried — but the retry only
ran when the payment sheet was opened, so a consignment could sit
unverified indefinitely while the user saw nothing.

The sweep now runs across all visible threads once the app is ready and
again on every foreground, so recovery never depends on the user finding a
particular screen.
posix4e added 15 commits August 1, 2026 11:12
…, B3)

Two blockers from the PR review, coupled by the same fix.

B2 — funds could burn. The spend was persisted before the message was
enqueued, and `ThreadUtil.enqueueMessage` is fire-and-forget: it reports
nothing, and its build-failure path simply returns. The pending record was
therefore cleared when the message had merely been *queued for building*,
so a failed attachment build destroyed the payment. (My first attempt at
this fix carried doc comments claiming 'cleared only on success' — they
were not true of the code beneath them.)

Anchoring is irreversible, so the spend is correct; delivery is what has
to become durable:

- the spend and the undelivered consignment are persisted in one write,
  now placed *before* the snapshot fetch — a network round trip no longer
  sits between an irreversible on-chain event and its record
- delivery builds the message itself and does prepare(tx:) -> attachment
  id -> verdict -> enqueueMessagePromise -> clear pending in ONE
  transaction, so the record is cleared only if the message exists
- retries are bounded and dead-lettered (kept, never discarded — the
  consignment is the only copy of a payment that already happened), passes
  are serialized, and in-flight deliveries are claimed so a foreground
  sweep cannot double-send
- the send sheet no longer re-arms Send after anchoring (tapping again
  would spend a second pair of coins) and no longer wipes the stored
  anchor URL when the field has not loaded yet
- concurrent sends are refused rather than selecting the same coins twice
- spend-set writes throw instead of silently leaving coins spendable after
  a restart; a decode failure no longer reads as an empty queue

B3 — wrong amounts. Verdicts gain a direction. Outgoing records carry the
amount sent to the recipient, since the self-ingest only credits change
(a 5-of-100 send rendered '95 USD'). A verified consignment crediting none
of our coins renders as third-party rather than a verified '+0'.

Bytes are stored once and referenced by replay entry instead of keeping a
second base64 copy (~110 KB -> ~47 KB per payment).

Known gap, documented rather than papered over: a crash between anchor
broadcast and finalize still loses the consignment, because the FFI's
pending transaction is in-memory and the openings carry fresh randomness.
Closing it needs an FFI pending export/import.
The pod was declared at Podfile root with a path: source, so every target
linked the Rust staticlib — including SignalNSE and SignalShareExtension,
which never touch the wallet — and nothing pinned what got built.

- pinned by revision (opencsv-rs 738e897) and built from source at install
  time, so there is no unpinned prebuilt binary; the pinned revision is the
  integrity guarantee. A commented-out path: line stays for local
  iteration, matching the LibSignalClient convention above it.
- declared only under target 'SignalServiceKit', the sole target whose
  Swift imports OpenCsvFFI. Verified: Pods-SignalNSE and
  Pods-SignalShareExtension now contain zero OpenCsv references (both were
  non-zero before).

Two limitations to state plainly rather than paper over:

- the app target still carries -lopencsv_ffi, so CocoaPods links the
  staticlib into the final binary as well as into SignalServiceKit. The
  reviewer's case (extensions that never use it) is fixed; removing this
  last copy needs more than a Podfile change.
- Signal's Pods/ is a committed submodule populated by `make
  dependencies`, not `pod install`, and our pod is not in that repo. A
  contributor must run `pod install` after `make dependencies`, and real
  upstream adoption would mean Signal regenerating Signal-Pods — likely
  with their prebuilt+checksum convention instead of building from source.
S1: the three call sites disagreed. The download hook passed bodyText nil
while the retry sweep and the render path passed the message body, so a
consignment whose filename was stripped — recognisable only by its body
marker — was a payment on the render path and an ordinary file on the
download path, and went unverified until the user happened to open the
wallet. Detection now resolves the owning message body itself, and all
three ask the same question through it.

S6: a consignment that fails verification rendered as a payment bubble
with no affordance to reach the file. It now falls back to the ordinary
attachment cell, so the bytes stay available for diagnosis.
…(S2, S3)

S2: startup replay was gated on a cached snapshot, and re-marking spends
sat inside that gate. With no cached snapshot the wallet replayed nothing
and marked nothing, then presented an empty balance as though the wallet
were genuinely empty. It now fetches a snapshot when none is cached, says
so loudly when none can be had, and re-applies spend state
unconditionally — one coin at a time, because the FFI call is
all-or-nothing and a single unknown id would otherwise leave every spent
coin looking spendable.

S3: every store write used try?. A failed spend-set write is a
double-spend waiting to happen, a failed replay-order write orphans a
stored consignment, and a decode failure that reads as an empty
collection silently discards state. Reads and writes now report instead
of swallowing.
S5: verification is serialized on one actor, so an attacker who can send
attachments could stall every payment in the app with an oversized
'consignment'. Blobs are now bounded (1 MB; real ones are 47-57 KB) and
empty ones rejected before crossing the FFI.

S7: deleted the uncalled proveMint/initIssuer wrappers. Issuance is a CLI
operation; carrying the surface in the app implied otherwise.

S8: spending whichever asset sorted first is an accident, not a decision.
With one asset it stays unambiguous; with several the caller must choose,
and gets a clear error instead of a silent pick.

Nits: the recipient key is validated before it reaches Rust (a malformed
key surfaced as a raw Rust string in the UI); an empty blob can no longer
hand a null pointer to the C ABI; the prefill now names who shared the
key, since a pasted address is only as trustworthy as its source; the
remaining raw-error paths map to localized copy.

S9: added transition tests — detection agreeing with and without a
filename, and a sent verdict surviving to the shape the bubble renders.
The gap I documented rather than fixed in the blocker round: proving
produces coin openings whose randomness cannot be re-derived, and they
lived only in the FFI wallet's memory. A crash between broadcasting the
anchor and finalizing therefore spent the coins on-chain and destroyed the
consignment the recipient needs — unrecoverable.

opencsv_pending_export/import (opencsv-rs be2c7b4) makes the pending
transaction durable, so the send path now:

- exports and persists the pending transaction *before* publishing, and
  refuses to anchor at all if that record cannot be written — better to
  fail early than to spend coins we cannot learn to recover
- persists the txid the moment the anchor is broadcast, via a new
  onBroadcast callback, because from that instant the coins are spent
  whether or not this process survives the confirmation wait; without it a
  crash mid-wait would look like 'nothing was published'
- clears the recovery record in the same write that records the spend and
  queues delivery

On launch and foreground, recoverInterruptedSends finishes the job: a
record with no txid was never broadcast and is dropped; one with a txid is
re-imported, finalized against its confirmed anchor, and queued for
delivery. The export is sensitive (it reveals coin values and owners) and
is written only to the encrypted database.
Swaps the trust model from 'one anchor server' to the design in #3.

- OpenCsvChainView binds the three new FFI entry points: SPV point
  verification (headers -> block -> merkle branch, trusting no server),
  N-of-M cross-checked exclusion, and tip sync.
- Exclusion now fans out to every configured indexer; any single earlier
  sighting rejects, so hiding a double-spend requires compromising all of
  them. Tip disagreement is a hard error carrying the disagreeing tips —
  never a silent majority pick, because which indexer is lying is not
  knowable from here.
- Deciding and crediting stay separate: cross-check establishes whether a
  consignment should be believed, and crediting keeps its single path
  through verify_consignment.
- Settings gain an indexer list, SPV peers, and network, with the v1
  single-anchor-server value migrating in as one entry. Configuring
  exactly one indexer is allowed but logs plainly that it is not a
  cross-check.
- filterDiagnostic is carried but documented at every level as never
  evidence: BIP158 basic filters exclude OP_RETURN, so it says nothing
  about whether an anchor is real.

Still to do for full v2: surface the indexer list in the send sheet
(currently one URL field), wire SPV point-verify into the receive path
where peers are configured, and the opt-in self-scan badge.
…erify, badge

Built against the scan FFI posted to #3 (opencsv-rs @ 5a21db1, pod pin
bumped). The exclusion decision now prefers the phone's own chain view,
and the bubble says so.

- Decision ladder (chainViewPlan, pure and tested): self-scan whenever
  SPV peers are configured, N-of-M cross-check at two or more indexers,
  single-snapshot demo mode below that. An unsynced scan index throws —
  a weaker view is never substituted silently.
- Self-scan: opencsv_scan_sync walks BIP158 filters for the protocol
  marker output and SPV-fetches matching blocks into an on-disk
  occurrence index (Caches/OpenCsvCbf — re-derivable, never wallet
  state); opencsv_scan_verify then decides consignments locally with no
  server believed. Sync runs on app activation before the retry sweep,
  off the actor; repeat syncs resume from the index tip.
- SPV point-verify wired into the receive path: after crediting, the
  verdict's anchor location is joined with the snapshot entry it was
  verified against (the only place the claimed txid and record bytes
  exist on this side of the FFI) and held against PoW headers plus a
  merkle branch. Exactly two outcomes reject — txid_mismatch and
  record_not_in_tx, the chain proving a lie. Everything else — tip lag,
  accruing confirmations, unreachable peers, undecodable replies —
  stays retryable, because pod drift must never masquerade as a payment
  rejection. SPV runs post-credit by necessity; the window is bounded
  since rejects store no replay blob, so a phantom in-memory credit
  dies at next launch.
- Verdicts record which chain view decided them; a self-scan-verified
  bubble reads "fully verified by this phone".
- Send sheet gains an SPV peers field — one setting powers both scan
  and point-verify. Indexer-list UI deliberately not built: indexers
  are optional accelerators in the final design. scan_check
  deliberately not bound: the app never sees raw nullifiers.
- Backend decoder: an unknown backend type now throws instead of
  silently decoding as an empty snapshot.

Two FFI edges found live and handled before shipping: from_height 0 is
rejected as the mempool sentinel (store default is 1 and clamps), and
scan_verify checks consignment encoding before scan registration, so
garbage bytes surface as an error, never a persistable rejection.

Verified: 39 tests in 8 suites (was 20), including offline FFI smoke
tests against the real binary and a live regtest end-to-end — the
app's Swift bindings synced a bitcoind (-blockfilterindex
-peerblockfilters), discovered a marker-bearing anchor hand-crafted
with bitcoin-cli (64-byte OP_RETURN record + 546 sats to
OP_0 sha256(OP_TRUE)) purely via the filter walk, and resumed a second
sync from the index tip: tip 113, 1 anchor, 4302 filter bytes + 570
block bytes. Debug (flag on) and Testable Release (flag off) both
build under -warnings-as-errors.
…ch v1, persistent CBF client

The retarget fix (e137096) is what the 16e measurement campaign was
blocked on; the from_height=0 clamp upstreams our store-side guard. The
batch-v1 and persistent-client surfaces are present but not yet bound —
the app still uses the unchanged one-shot API. All 38 OpenCsv tests
pass against the new pod; the regtest-gated live test ran separately
during the campaign.
Pin to opencsv-rs 290c8e081 (opencsv_scan_export_snapshot). Crediting
now prefers the phone's own scan-index snapshot when SPV peers are
configured and a sync has succeeded — no server in the receive path —
falling back to server/cache otherwise, cached for offline replay.

Found live during the capture runs, both now fixed:

- A payment message routinely beats the chain view by seconds, and the
  scan decision treated AnchorNotFound / InsufficientConfirmations as
  final. Chain-lag reasons now get one cheap resync and an inline
  retry, then a *withheld* (retryable) verdict — never a stored
  rejection for a lagging tip. Definitive reasons (NullifierConflict,
  proof failures) stay final.
- Verdict logs carried no reason; they now log reason and deciding
  chain view, which is what made the sentinel bug findable at all.

Also: send sheet gains a network field (regtest/signet/mainnet, store
default unchanged), chain caches are namespaced per network so a
network switch can never replay another chain's cache, and the test
suite grows the export smoke test, chain-lag classification, and an
env-gated live-scan diagnostic.

Known upstream blocker (reported on #3): snapshot verify does not
resolve the bitcoind backend's mempool-sentinel location, so phone
crediting rejects CLI-minted consignments until that lookup lands.
Verified payment bubbles rendered as empty: .openCsvPayment was
missing from bottomNestedShareCVComponentKeys, so the component built,
the body deferred to it, and the layout never seated it. Rejected
consignments never hit this — they fall back to the generic attachment
cell — so the bug only ever ate successful payments.

Pin to opencsv-rs 161639795: SnapshotChain learns the bitcoind
backend's mempool-sentinel location (resolved by txid across
anchor_at/ctx_at/locate), which is what let the phone credit
CLI-minted consignments — confirmed live on device
("consignment 13/14: verified via self-scan", ~350 ms verify).
There is no explorer website because the phone is the explorer. Tapping
a payment bubble now opens a sheet showing what this device actually
established, not what a server said.

- Provenance is the headline, because it is the differentiator: "fully
  verified by this phone — no server was asked, none was believed" for
  self-scan, versus cross-check or single-snapshot. Rejected verdicts
  show their stored reason; pending ones show the last withheld reason
  and that the sweep retries on foreground.
- Chain evidence is derived at tap time rather than persisted on the
  verdict: the stored blob is re-verified against the scan-index
  snapshot, so confirmations are always current and verdicts written
  before this existed still render. Anchor height/position, txid,
  record and ctx are copyable; a public-explorer link appears only on
  networks that have one (never regtest — there, the phone's own
  evidence is all there is, which is rather the point).
- The phone's chain view is shown honestly: synced tip, anchors
  indexed, and the filter/block bytes the last sync cost.
- Re-verify runs the decision live and reports what the chain says now;
  it never rewrites the stored verdict, keeping deciding and crediting
  separate as elsewhere. The raw consignment is reachable from the
  sheet, which closes S6's gap for verified payments too.

Withheld reasons live in memory only — a withheld verdict deliberately
persists nothing, so the sheet says "last attempt: …" rather than
implying a stored state.

CVComponentDelegate gains didTapOpenCsvPayment; all seven conformers
get no-op stubs (only the conversation view presents the sheet).
Configuration is not sending. The sheet now shows a balance line, the
amount, "to {name}" resolved from the key announced in this chat (hex
never appears in the send flow — payments happen in conversations), and
a Send button whose states are the status: Proving… → Anchoring… →
Sent. The no-re-arm-after-anchor discipline is unchanged and now
visible instead of silent. With no announced key, the sheet's single
call-to-action is sharing yours.

Everything else moved to a wallet screen: balance detail, receive as a
QR with the key demoted to a tap-to-copy line, share-in-chat, and the
chain configuration (network, SPV peers, anchor server) behind a
collapsed Advanced disclosure — same persisted settings, better
address. Deleting rows from the send flow is the feature.
All from live runs on an iPhone 16e against a regtest chain over LAN,
zero servers in the receive path:

- s1-chat-badges: the conversation with verified payment bubbles.
- s2-verified-bubble: "+100 USD - fully verified by this phone".
- s3-send-sheet: the (pre-simplification) send sheet mid-send, 400 USD
  balance, serverless configuration visible, recipient prefilled from
  the chat's announced key.
- receive-verify-send.mp4: one unstaged 55-second take — the payment
  arrives as a Signal message, verifies against the phone's own chain
  view in about a second, and the take ends on the send sheet. Real
  message, real timing.
Every foreground tick paid a full P2P re-dial (~0.7-2.9 s measured on
device). The scan now opens a persistent client on first use and syncs
on its connections; measured all-in on the 16e — open + handshake +
sync on a fresh process — 221 ms, with steady-state syncs on the open
client strictly faster.

The fast path can never be a new failure mode: any persistent-path
error drops the client and falls back to the one-shot call for that
tick, reopening next time. A network or peer change closes the client
(those connections point at the wrong chain), alongside the existing
per-network cache semantics. Sync log lines now carry elapsed ms and
which path ran.
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