Add OpenCSV payments behind a dev build flag (prototype) - #2
Conversation
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
left a comment
There was a problem hiding this comment.
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.
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.
…, 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.
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):opencsv-consignment.binattachment is verified in-app by the real recursive prover (Rust, via theOpenCsvbinary pod) against an anchor-chain snapshot fetched from a user-configured URL, and renders as a payment bubble —+100 USD · verified/verification failed/verifying….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.KeychainStorage; verdicts + consignment blobs live in the SQLCipher-encrypted GRDB viaKeyValueStore(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.db.touch(interaction:)path.CVComponentOpenCsvPaymentfollowsCVComponentPaymentAttachment's ManualStackView pattern; consignments are intercepted inbuildNonMediaAttachmentbefore the generic-attachment path.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 likeLIBSIGNAL_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
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.-warnings-as-errors.opencsvCLI linked as a secondary device, over production Signal, with matching supply audits — evidence in the tracking issue.Prototype caveats (deliberate, documented)
🤖 Generated with Claude Code
https://claude.ai/code/session_01TpbPYGqsxNpwwBSfLxfJET