Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,41 @@ The network path a mixnet-only surface resolves to, either the Standing
Client's tunnel or clearnet. zingolib derives it from the indicator, and the
app never sees it. A price fetch that resolves to clearnet is refused, never
sent.

**Leg**:
One of the three links a mixnet send crosses: the client's websocket to its
entry gateway (leg one), the sphinx path from that gateway to the Exit Node
(leg two), and the Exit Node's TCP connection to the destination (leg
three). The local SOCKS5 listener is leg zero.

**Exit Node**:
Our name for the node whose network requester opens leg three. Nym's
literature calls it an exit gateway, and its embedded service a network
requester (historically, a service provider).

**Destination**:
The clearnet host a mixnet send is addressed to, reached only by the Exit
Node on leg three.
_Avoid_: correspondent (the term this one renames), recipient (the payment
addressee inside a transaction, never a network peer)

**Sentinel**:
The fixed public resolver (`1.1.1.1:53`) that exit evidence is gathered
against, per zingolib ADR 0044: one DNS round trip through the tunnel,
where any reply proves the Exit Node carries traffic. Not a destination,
never eligible for a verdict.

**Capture**:
The debug-only tracing layer in the proxy FFI that matches nym's own log
events to observe legs one and two, streamed to the host as diagnostics.

**Probe**:
The debug-only FFI surface that gathers evidence through the running
proxy on a caller-supplied deadline: the Sentinel arm (one Sentinel round
trip) and the destination arm (one TLS handshake against a caller-supplied
destination, then hang up).

**Verdict**:
What one probe arm classified its attempt into, combining the round trip's
outcome with the events captured in its window; silence that no capture
explains is reported as indeterminate, never dressed as a diagnosis.
115 changes: 115 additions & 0 deletions docs/adr/0005-the-proxy-component-is-named-for-the-mixnet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# 5. The proxy component is named for the mixnet

Date: 2026-08-19

## Status

Accepted, ruled by the maintainer on 2026-08-19. The rename itself is
deferred to a follow-on pull request, because its scope reaches every
build system in the repository and does not belong inside PR #1276.

## Context

The crate is `zingo-nym-proxy-ffi`, and because it uses
`uniffi::setup_scaffolding!` rather than a UDL, that crate name becomes the
component namespace: Kotlin imports `uniffi.zingo_nym_proxy_ffi`, and the
generated scaffolding exports C-ABI symbols named
`ffi_zingo_nym_proxy_ffi_*`.

Three separate things are wrong with that name, and each is visible from
inside the crate itself.

The vendor name is the odd one out. Every type the crate exports is already
neutral about which mixnet it carries: `MixnetProxyHandle`,
`Socks5Endpoint`, `ProxyFfiError`, `ProxyDeathReason`, `ProxyDeathObserver`.
"Nym" survives only in the crate name and in an internal comment about
`NymProxy`. The wallet uses the same neutral vocabulary throughout — Mixnet
Mode, `MixnetProxy`, `MixnetSlot`, `MixnetTiming` — so the component name
is the single place the implementation choice leaks into the interface.

The `_ffi` suffix names the mechanism rather than the thing, and it states
that mechanism a third time. Every consumer already reaches the component
as `uniffi.<name>`, and the crate exists for no purpose other than being
bound. The wallet component settles the house convention by counterexample:
it is every bit as much an FFI component, with a UDL and generated Kotlin,
and it is named `zingo`, not `zingo_ffi`.

The word "shim" describes the crate's posture toward its own dependencies
rather than what a caller receives. A consumer across the boundary holds a
handle to a running mixnet proxy and calls `start`, `socks5Endpoint`,
`exitNode`, and `stop`. That it happens to wrap `NymProxy` from
`zingo-netutils` is invisible to them and irrelevant to the name.

## Decision

The crate is `mixnet-proxy`. The component namespace is `mixnet_proxy`,
so Kotlin imports `uniffi.mixnet_proxy` and the scaffolding exports
`ffi_mixnet_proxy_*`. The word "shim" is retired from the vocabulary of
this repository, in prose, in file names, and in identifiers; where a name
is needed, the thing is the mixnet proxy.

The wallet component keeps the name `zingo` for now.

## Considered options

**Keep `zingo-nym-proxy-ffi`.** Rejected. It names a vendor the interface
does not expose, and a mechanism the import path already states.

**`zingo-mixnet-proxy`.** Rejected, though it carries a real argument. Both
components are bound into one application process, so their C-ABI symbols
share a global namespace with every other native library the application
links, and a `zingo_` prefix both reduces the chance of a collision with a
third-party UniFFI component and makes a symbol legible as ours in a crash
dump or a linker error. It also pairs visibly with `uniffi.zingo`. The
maintainer ruled against the prefix: within this repository the component
is unambiguous, and the shorter name says what the thing is without
restating where it lives.

**`mixnet-proxy`.** Chosen.

## Consequences

The symbol prefix becomes generic. `ffi_mixnet_proxy_*` is a plausible name
for another party's component, so a future collision is possible where
`ffi_zingo_mixnet_proxy_*` would have been safe, and a symbol in a crash
dump no longer identifies itself as ours. This decision accepts that cost.

The name will overstate the crate's reach until its couplings are cut. It
depends on `zingo-netutils` for `NymProxy` and bootstraps TLS through
`org.ZingoLabs.Zingo.NymTlsInit`, so a consumer outside Zingo cannot use it
today. A name that invites reuse should either acquire that reuse or lose
those couplings; whichever happens, it should happen deliberately.

The rename is large and touches systems that cannot all be verified from
one place. Roughly 316 identifier sites across about 35 files carry the old
names, spanning Cargo manifests, `uniffi-bindgen`, two workbench binaries,
`android/app/build.gradle.kts`, `android/app/lint.xml`,
`ios/Zingo.xcodeproj/project.pbxproj`, `ios/NymTransportModule.swift`,
three `build_*.mjs` scripts, `rust/android/docker/Dockerfile`, the contract
tests, and ADR 0004. Seven paths change too, including the crate directory,
the checked-in generated bindings under `android/app/src/*/java/uniffi/`,
the workflow file `nym-proxy-ffi-check.yaml`, both `*-android-shim.rs`
workbench binaries, and this repository's ADR 0004, whose file name
contains the retired word. Cargo verifies only the Rust portion; Gradle,
Xcode, and CI each need their own build, which is the reason this is a
pull request of its own rather than a change carried inside another.

ADR 0004 is renamed and its prose updated by the same change. Its decision
is untouched: the platform verifier must hold the application Context
before the proxy opens any connection.

## Open

Whether the wallet component becomes `zingo_wallet`. Beside
`uniffi.mixnet_proxy`, the bare `uniffi.zingo` claims the product name for
one component among several, and two peers would read better as
`uniffi.zingo_wallet` and `uniffi.mixnet_proxy`. That rename moves the
crate name `zingo`, hence the built library, the UDL namespace, the
checked-in bindings, every Kotlin and Swift import, and the loader
configuration, so it is a larger change than this one and wants its own
decision.

Whether the React Native module classes `NymTransportModule` and
`NymTlsInit` are renamed with the component. They name the application's
bridge module rather than the component, so they are application
vocabulary and are left to a separate ruling.
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# 7. The mixnet's three legs are observed by capture and probe

Date: 2026-08-24

## Status

Accepted.

## Context

Mixnet Mode routes a send through three links: the client's websocket to
its entry gateway, the sphinx path from that gateway to the exit node,
and the exit node's TCP connection to the destination. When a send
fails, remediation differs by leg. A dead gateway link wants a
reconnect, a refusing exit wants a redraw, and an unreachable
destination is not a mixnet fault at all. Today the app observes none
of them. The proxy FFI reports start failures as prose and the death of
its local SOCKS5 listener, which is leg zero.

The SOCKS5 protocol cannot carry the discrimination. nym's local SOCKS5
server answers every CONNECT with `Success` before anything enters the
mixnet (`nym-socks5-client-core` 1.21.5, `socks/client.rs`,
`acknowledge_socks5`), so `check_connectivity` in `zingo-netutils`
proves only the local listener. zingo-netutils' Sentinel (zingolib ADR
0044) already proves an Exit Node carries traffic, with a round trip
deliberately shaped like traffic the exit sees constantly, but that
evidence never crosses the FFI. When the exit node fails to reach the
destination, its network requester does send a typed
`ConnectionError { connection_id, network_requester_error }` back
through the mixnet, but the client core logs it at `error!` and drops
it (`socks/mixnet_responses.rs`). The gateway link is no better served.
`Socks5MixnetClient` in nym-sdk 1.21.4 exposes no connection state and
no event stream, and websocket failures surface only as `error!` events
inside `nym-gateway-client`. Every signal this decision needs exists in
the process, and none of it crosses an API.

## Decision

The proxy FFI defines the missing observations with a tracing capture
layer and a caller-driven probe, and `zingo-netutils` makes the
bootstrap race typed.

A `tracing` layer inside `zingo-nym-proxy-ffi` matches events from two
target prefixes: `nym_socks5_client_core`, where the exit's
`ConnectionError` reason surfaces, and `nym_gateway_client`, where
websocket send and stream failures surface. Captured events and the
bootstrap narrative queue inside the crate, capped and drop-oldest,
and the host polls one `drain_diagnostics` export that surrenders the
queue in order with the count of any events lost to overflow. The host
implements no callback, so no host code runs on the mixnet client's
threads and no new trait object crosses the FFI. The existing
`ProxyDeathObserver` keeps its at-most-once production contract
unchanged.

The probe has two arms. `probe_sentinel(deadline_millis)` opens one
tunnel through the running proxy and performs the Sentinel round trip
`zingo-netutils::sentinel` defines: a DNS lookup of a constant name,
sent to a public resolver whose silence indicts the tunnel rather than
itself. A Sentinel answer proves legs one and two: the Exit Node
carries traffic. A captured exit `ConnectionError` in the window
proves those legs and names the exit's refusal, and this is the
definition of "exit refused". Silence with captured gateway-client
errors reads as a dead gateway link. Clean silence at the deadline
reads as indeterminate beyond the gateway, and is reported as exactly
that. Because Exit Nodes filter destinations by policy, a refusal
verdict names the Sentinel, never the exit's health.

`probe_destination(host, port, deadline_millis)` completes one TLS
handshake against the caller-supplied destination through the same
tunnel, verifying against the compiled-in Mozilla bundle (ADR 0006),
and hangs up without sending one byte of application protocol. A
completed handshake proves all three legs and the certificate chain. A
TLS-layer fault is its own verdict carrying rustls's reason, which is
the class of failure `nym_http_api_client` flattened into `client
error (Connect)` for weeks. A captured exit `ConnectionError` names
the exit's refusal of this destination. An attempt that ends without a
TLS fault classifies by the window, or stays indeterminate with the
local ending named. The crate holds the deadline and the caller's
host and port, and no wallet configuration.

For the client-to-gateway leg, `zingo-netutils` gains a public
`BootstrapEvent` enum with five lifecycle variants: `DiscoveryStarted`,
`DiscoveryFinished` with the candidate count, `PullLaunched` and
`PullFailed` with the Exit Node address (carrying the pull's whole
typed `NetOpFailure`, ruled over prose so a diagnostics surface renders
structure instead of parsing it), and `Connected` with the winner. The
driver resolves arm indices to Exit Node addresses. The planner's
hedging mechanics stay private. Every race reports, the first
bootstrap and each reconnect alike, so the screen never goes blind at
the moment after a death.

The whole surface compiles behind a cargo feature that only debug
wallet builds enable. The consumer is a hidden diagnostics screen in
the Zingo app whose bridge module registers from the debug source sets
(`androidDebug`, `#if DEBUG`). Release binaries never carry the log
coupling.

## Consequences

The test surface discriminates the three legs as sharply as an unforked
nym allows, and the exit's own refusal reason reaches the screen. The
price is coupling to nym's log targets and message shapes. The gate
confines that coupling to debug builds, so a nym upgrade that breaks
capture breaks a debug screen and its tests, never a release. The
diagnostics screen cannot help diagnose a release install in the field.
A new `zingo-netutils` surface means a zingolib change and a new tag
before the FFI work starts, and the backend and UI land as separate
changes.

## Considered options

**Read the exit's refusal from SOCKS5 reply codes.** Impossible. The
local server acknowledges success unconditionally before the mixnet is
involved.

**Define the refusal behaviorally, as a stream closed without data.**
Rejected. It loses the refusal reason and cannot distinguish a
mid-mixnet drop from an exit-side refusal.

**Fork nym's client core to forward `ConnectionError`.** Rejected. It
is the honest typed channel, but it adds a nym fork to maintain on top
of the existing zingolib fork, for a debug-build need.

**A Sentinel-only probe, with leg three left to the send.** Rejected
after a first ruling for it. It cannot discern the TLS failure modes
of a live destination, and a certificate fault would keep presenting
as an unexplained stall exactly as it did through
`nym_http_api_client`.

**A destination probe that sends an application request.** Rejected.
It puts protocol knowledge in the crate or pushes it across the FFI,
and the handshake alone already proves the three legs and the chain.

**A streaming callback interface for the diagnostics events.**
Rejected after a first implementation. UniFFI hands the crate a boxed
trait object for every callback interface, host code runs on whatever
thread fires the event, and the only consumer is a debug screen that
polls its platform bridge anyway. The drain keeps every type static
and deletes the forwarder thread the callback needed.

**Read gateway state from a nym-sdk API.** Impossible today. The
research found no connection state, no `is_connected`, and no event
stream on `Socks5MixnetClient`.

**A separate harness APK, or instrumented tests alone.** Rejected as
the host. The diagnostics screen lives in the wallet app, debug builds
only, so a person watches a live device with the wallet's own
configuration.

**Ship the capture layer in release builds.** Rejected. The log
coupling is too brittle to ship to users, and the field-diagnosis case
has not yet earned it.
7 changes: 5 additions & 2 deletions rust/nym-proxy-ffi/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 26 additions & 2 deletions rust/nym-proxy-ffi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ crate-type = ["lib", "cdylib", "staticlib"]

[dependencies]
# NymProxy lives here, behind the nym feature; this shim always needs it.
zingo-netutils = { git = "https://github.com/zingolabs/zingolib", tag = "zingolib_nym_rc0", features = [
# Pinned to the nym_bootstrap_events head (PR 2742) for BootstrapEvent;
# retarget to the next zingolib tag once that PR merges.
zingo-netutils = { git = "https://github.com/zingolabs/zingolib", rev = "8fc056a945e24fa5d36d274aed5d1cf5b725fafa", features = [
"nym",
] }
# Proc-macro scaffolding (setup_scaffolding! + #[uniffi::export]); no build.rs
Expand All @@ -23,15 +25,37 @@ uniffi = "0.28"
tokio = { version = "1", features = ["io-util", "net", "rt-multi-thread", "time"] }
thiserror = "2"
tracing = "0.1.44"
# The diagnostics surface (ADR 0007): SOCKS5 tunnelling for the probes, TLS
# for the destination arm (verifying against the patched Mozilla-bundle
# verifier, ADR 0006), and the tracing capture layer.
tokio-socks = { version = "0.5", optional = true }
tokio-rustls = { version = "0.26", optional = true, default-features = false, features = [
"ring",
] }
rustls-platform-verifier = { version = "0.7", optional = true }
tracing-subscriber = { version = "0.3.23", optional = true, default-features = false, features = [
"registry",
"std",
] }

[features]
# Builds tests/live_mixnet.rs, which reaches the real Nym network.
live-mixnet = []
# The three-leg observation surface (ADR 0007): the tracing capture layer,
# the Sentinel and destination probes, and the observed bootstrap. Only
# debug wallet builds enable it; release binaries never carry it.
diagnostics = [
"dep:rustls-platform-verifier",
"dep:tokio-rustls",
"dep:tokio-socks",
"dep:tracing-subscriber",
"zingo-netutils/socks5-transmit",
]

[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] }
# The tests read their temporal parameters from `time::test`.
zingo-netutils = { git = "https://github.com/zingolabs/zingolib", tag = "zingolib_nym_rc0", features = [
zingo-netutils = { git = "https://github.com/zingolabs/zingolib", rev = "8fc056a945e24fa5d36d274aed5d1cf5b725fafa", features = [
"testutils",
] }

Expand Down
Loading
Loading