MONAD pins the Spilman library revision with explicit, fallible receiver-state
lookups. Custom RelayPayments implementations must return
Result<Option<ChannelState>, String> from channel_state: None for unknown
channels and Err for lookup failures. This API update requires no database reset.
The control wire now carries structured ChannelLink { channel_id, zero_balance_signature, params, funding_token } and ChannelPayment { channel_id, balance_raw, signature } messages instead of nested
payment_json. This pre-alpha wire break requires coordinated client and relay
updates. The pinned Spilman parameter schema also renames the persisted/public
maximum_amount field to maximum_amount_for_one_output; recover channels
before upgrading or explicitly reset disposable alpha wallet and relay databases.
Use the relay management action request_channel_unlink with
{"channel_id": "..."} before close_channel for a linked channel. Retirement
persists across relay restarts and prevents relinking. The client finishes any
outstanding payment acknowledgement and sends an ID-only unlink request; its
remaining session credit stays usable. Success is one authoritative session
status with no linked channel. Close the channel separately after it is
unlinked. Automatic replacement channel provisioning occurs when the session
pauses.
See relay management actions.
The cooperative unlink messages require coordinated client and relay updates; older clients do not understand the release request.
Wallet summaries show Live or Stale / unavailable with the last successful
process sample time. Stale snapshots retain last-known figures; missing amounts
and counts display as unavailable rather than zero. On the relay page, an unlinked
channel in Closing offers Resume close to resume its durable close journal.
Wallet actions are disabled while the source is stale or a conflicting command
is pending. Resume close does not initiate client-side fund recovery.
Relay toggles use set_control with {"field":"accept_new_tunnels","enabled":false},
changing only the selected setting. This replaces the alpha set_controls command:
update API callers and serve the updated UI alongside the relay; no database reset
is needed. Client/relay Disable stays available during slow provisioning or close
operations. Each command retains its own progress record; Disable has no queue priority.
cargo build -p monad-management -p monad-client -p monad-relay -p monad-test-mint
cargo build -p monad-test-mint --example demo-fund
node monad-management/ui-tests/network-demo.mjsThe launcher prints Clients, Relays, and Mints URLs on one localhost
server. Open each in its own tab. The demo has two clients, a two-hop QUIC route,
and one SAT/MSAT mint. Its shared client wallet starts with 1,000,000 test sats
issued by that mint; the second client starts disabled. Nothing is funded by a
browser refresh. Type traffic, topup 100000, restart-relays, or quit in the
launcher terminal. traffic-on / traffic-off start/stop paced traffic for the
demo. Entry uses SAT channels and exit uses MSAT channels, with
100-sat credit targets, 50-sat minimum topups, and 1,000-sat channel funding budgets.
At 200 bytes/msat per hop, these correspond to 20 MB of target credit,
10 MB per minimum topup, and 200 MB per channel. Provisioning starts
manual; enable automatic channel provisioning in the UI whenever you like.
The initial purse is split across SAT and MSAT proofs. Ctrl-C also stops the demo;
temporary databases are retained.
For hop-by-hop provisioning or an empty-wallet experiment, set
MONAD_DEMO_MANUAL=1 and optionally MONAD_DEMO_SATS=0 before launching.
See the combined demo guide for detailed experiments and
browser tests. Node 20+ and curl are used by the demo; production pages need only
the Rust management binary and a browser.
Configured clients also expose a managed speed/latency test. Run
cargo run -p monad-test-traffic -- run --config monad.yaml for the ratio server,
then use the Speed test panel on any client card (or the management commands
described in Traffic tests). The Traffic servers page
shows server endpoints, connections, and byte counters.
The relay page keeps linked channels beside their sessions and places unlinked channels in a shared newest-first list below the relays. Closing remains visible; closed channels show compact final paid/capacity amounts without low-capacity red.
cargo build -p monad-management -p monad-test-mint
node monad-management/ui-tests/demo.mjsOpen the printed /mints URL in one or more browser tabs. The disposable demo
starts two SAT/MSAT test mints and the management aggregator; Ctrl-C stops them.
Keysets and rotation outcomes update across live tabs. Reload restores state
without resubmitting commands. Each launch uses fresh temporary databases and
prints their location; they are retained for inspection.
For existing configurations, open /mints on your monad-management listener.
See mint UI usage and browser tests.
Relay channel rows report authoritative ownership, including the owning session ID. Close is available only for channels reported as unlinked; unavailable ownership is shown explicitly and disables Close. Command controls stay disabled through queued/running execution. The main notice shows terminal results and channel IDs. Lost responses are reconciled by request ID without resubmission; unresolved requests remain locked until confirmed or the process generation changes. Unlink-request success still requires waiting for the channel ownership to become unlinked before closing.
Configured client and relay processes now optionally expose Unix-socket management endpoints. See the headless API reference for configuration, commands, operation tracking, and monitoring semantics.
Run cargo run -p monad-management -- --config monad.yaml to aggregate those
processes on management.listen. GET /v1/snapshot provides JSON monitoring and
GET /v1/events streams SSE at five samples per second per process. Commands use
/v1/processes/{name}/commands. The TCP listener is loopback-only; this release
provides a headless API, ready for a later dashboard.
Client snapshots expose a tagged, revisioned lifecycle with run/route generations,
one tagged funding state per hop, the latest sanitized route/exit failures, and a
bounded transition history. A recovered client can therefore be active while still
showing what most recently failed; management observes this state but does not own
route recovery or payment decisions.
Optional SAT/MSAT CDK test mints can be configured under test_mints and started
with cargo run -p monad-test-mint -- run --config monad.yaml. One process hosts all
configured test mints; each has its own persistent database and Cashu HTTP address.
Set management.test_mint_socket to expose keyset monitoring and per-unit rotation
through the same management TCP/SSE service. YAML fees initialize the first keysets;
rotation commands select subsequent fees, and restart preserves the rotated state.
See managed test mints for configuration, fee units/range, and
operation examples.
Relay embedders can pass a per-relay Arc<SessionRegistry> to
run_with_wallet_manager_registry_and_shutdown and update RelayControls while
the relay runs. All controls default to enabled. These are process-local overrides,
not YAML edits; the HTTP management service uses these same controls.
accept_new_channels = falserejects first-time channel links, while stored channels can relink and receive payments.accept_new_tunnels = falserejects new CONNECT requests with HTTP 503, including onward QUIC tunnels. Established tunnels continue.accept_new_sessions = falserejects new sessions, including streams on an existing QUIC connection. Existing sessions continue.enabled = falsecancels pending handshakes and active sessions. Awaitwait_disabled()for application cleanup before re-enabling. The three admission settings are preserved. Payment channels are not automatically closed by disable; any separately configured expiry auto-close policy remains in effect.
The listener stays bound while disabled so the same instance can be re-enabled.
Normal defaults retain existing behavior. New-channel refusal uses the new
CHANNEL_ADMISSION_DISABLED control error. Bootstrap rejection now uses a minimal
structured error, and CONNECT refusal carries a machine-readable reason header.
Update clients and relays together for these alpha wire changes; no wallet/schema
migration or database reset is required.
Client embedders can supply one Arc<ClientManagement> per configured client to
run_configured_client_managed, or attach a handle to a ConnectorRuntime with
with_management. set_automatic_provisioning(false) permits existing-channel
reuse and payments but makes an unfunded hop wait. hops() exposes partial-route
sessions; provision_once(session_id) authorizes one normal provisioning attempt
for a waiting hop using the configured funding budget. Duplicate pending requests
and stale session IDs are rejected. This does not toggle automatic provisioning.
Configured-client set_enabled(false) stops route/SOCKS work; is_running() stays
true during cleanup. Re-enable is rejected until cleanup finishes. The SOCKS socket
stays bound, and connections received while disabled are immediately dropped.
The connector-only handle exposes funding controls; lifecycle enable/disable is
implemented by the configured-client runtime owner.
Deliberate manual-funding waits do not consume the route setup timeout; network setup, mint request, and heartbeat deadlines still apply. A relay's new-channel refusal retains the already-funded channel and retries its link every five seconds instead of opening replacements. Transport failures still use normal route rebuilds.
Explicit administrative session/CONNECT refusals during route construction retry every five seconds without discarding healthy prefix sessions or spending the route setup budget. Each connection attempt remains bounded. Disable the client through management to cancel these waits and clean up the partial route. An individual exit refusal fails its SOCKS request promptly while other tunnels continue.
DESTINATION_POLICY_DENIED is distinct from temporary admission refusal. A denied
next relay blocks the configured route without automatic retries; management stays
available to inspect the reason and disable/re-enable the client. Destination policy
rules themselves are not implemented yet. See admission refusals.
MONAD is a multi-hop, VPN-like TCP tunneling system built in Rust.
It provides:
- a local SOCKS5 proxy for normal applications
- hop-by-hop encrypted transport using Noise NK
- multiplexed control and data channels using HTTP/2
- arbitrary TCP tunneling via HTTP/2
CONNECT - recursive multi-hop nesting for onion-style routing
- IPv4, IPv6, and hostname support
Channel payment updates are accepted only while the sending session owns the
channel and the relay's exact prior payment snapshot remains current. A rare
ownership-handoff or close race returns PAYMENT_CONFLICT with no credit and
rebuilds the affected session; MONAD never blindly retries the stale payment.
Relay wallet close results are typed: Closed carries the normal receiver/sender
payout split; SenderRefundedAfterExpiry means the sender spent the full funding
minus mint fees via the refund branch and no receiver payout exists. Both are
successful administrative terminal outcomes. UnknownSpent retains a recoverable
journal because close versus refund remains unresolved; retry recovery later.
--json exposes outcome and details. Batch close reports resolved, unresolved,
and failures separately. Terminal refunds are excluded from autosweep and drain.
Close journal v3 binds each durable relay-close attempt to its exact SIG_ALL
message digest and blinded receiver key. Complete all-spent NUT-07 evidence is a
sender refund only when no witness signature matches any authenticated attempt;
extra unrelated signatures do not make that refund ambiguous. Incompatible
journals are rejected without deleting wallet data.
Wallet databases explicitly verify SQLite synchronous=EXTRA on each writable
connection. Back up all client and relay wallet databases using SQLite's backup
API or consistent SQLite snapshots, not raw copies of live .db files (committed
data may still be in WAL files). This is not atomic across separate databases and
depends on the filesystem and hardware honoring sync requests. Read-only wallet
inspection does not change database settings.
New channel funding requires an active keyset with no final expiry, or with final
expiry at least the channel's actual expiry plus a recovery window. Both
client_wallet.funding_keyset_recovery_window and each relay's
channel_policy.funding_keyset_recovery_window default to 24h; the client uses
the larger of its own setting and the relay's advertised requirement. With the
normal 24-hour channel lifetime, this reserves at least 48 hours from creation.
Durations accept values such as 12h, 2d, or 0s (no additional buffer).
Equality passes; a keyset explicitly reporting expiry zero is never eligible.
The relay enforces its window on new links and stored relinks before changing
ownership, so increasing the setting can reject previously accepted channels.
This does not impose a new cutoff on payments in an already-linked session.
Close, drain, and refund output selection still requires active, unexpired keys and refreshes the mint cache when none are usable. This does not extend a mint's expiry or guarantee recovery if the wallet stays offline past it. There are no new proactive keyset swaps or background workers; existing channel auto-close behavior is unchanged. V1 expiry is mint-provided metadata, not bound to the keyset ID; positive V2 expiry is ID-bound. Both formats remain supported.
Implemented today:
monad-relay: accepts client connections, performs Noise handshake, runs an H2 session, proxiesCONNECTtunnels, enforces per-session billing with pause/resume, keeps a shared in-memory cache of configured mint keysets, and persists relay-side Spilman channel state in SQLitemonad-client: provides reusable route selection, the session payment driver, a SQLite-backed channel wallet, a loose-proof wallet, and multi-hop connection setupmonad-common: shared Noise transport (with session ID from handshake hash), H2 stream helpers, control protocol types (ClientMessage/ServerMessage), exact session billing arithmetic, session types (RelayConnection,SessionPricing,SessionSpilmanInfo), shared bidirectional proxymonad-quic: shared QUIC transport code plus standalone echo tooling —QuicStream, secp attestation helpers, echo server/client, and shared config/keygen helpers used by relay and clientmonad-test-client: localhost SOCKS5/manual test harness for mocked relay funding, circuit rebuild testing, and daily-driver browser/SSH experiments- QUIC hop support: relay dual TCP+UDP listener, QUIC connection pool, configured client routes, and
quic-secp256k1-pubkeyH2 header for CONNECT forwarding - Noise-payload bootstrap: MONAD uses the Noise
NKpattern over secp256k1 with ChaCha20-Poly1305 and BLAKE2s; the client maps each supported Cashu Spilman channel protocol version to its supported keyset-format versions in the first handshake payload, and the relay selects one protocol plus the full mutual keyset-format set before H2 starts. Today2026-09-14supportsv1andv2and requires a nonempty intersection, alongsideh2andsession_constantpricing - deterministic developer tooling: pinned Rust toolchain, repo-local rustfmt config,
Makefile, and GitHub Actions checks for formatting and tests - session payment system: paused-by-default sessions, initial
SessionStatusafter control stream establishment, exact totals-based billing with directional pricing, bounded chunk overshoot with pause/resume enforcement, open/total/failed CONNECT counters,ChannelLink,ChannelPayment,ChannelEvicted, and correlated Ping/Pong liveness - relay-reported linked-channel sync:
SessionStatusincludes the currently linked channel's id, latest accepted cumulative balance, capacity, and unit - ordered control exchanges: one initial
SessionStatus, then one status/error per ordinary request in FIFO order, with up to five requests outstanding. Pause and eviction no longer push statuses; useGetSessionStatusfor an explicit snapshot. Ping/Pong and advisories may interleave without consuming request responses. - relaxed extension notifications: either side may send
ExtensionNotificationwith one nonempty stringname;dataand any other members are optional and unconstrained. Current runtimes ignore them without a response, FIFO slot, or state change. - relay-side session FSM for steady-state control handling and full teardown on control-stream detach
- in-process relay wallet manager: multiple hosted relays can share one SQLite-backed relay wallet database while keeping distinct Cashu receiver keys / wallet names
- client-side direct control-loop funding logic for per-session channel acquisition, linking, and payments, with periodic local cleartext-counter checks sizing payments from client-side byte and payment records
- client wallet library path:
SqliteClientWalletmanages Spilman channels,LooseProofWalletstores spendable Cashu proofs, andsession_driverhandles per-session linking and payments;MockWalletremains for tests and connector harnesses - blinded-hop routing over QUIC:
CONNECT blinded.monad.invalid:443, tweak-prefixed QUIC forwarded sessions,RouteHop/Routeconnector support, public-key-only blinded-path construction, and parity-aware reverse-tweak key recovery for MONAD's x-only secp256k1 identity model - integration tests for direct, nested, IPv6, hostname-resolution, TCP secp transport, QUIC single-hop, QUIC nested tunnels, mixed TCP/QUIC hop chains, and the session payment / pause / resume lifecycle
Production clients offer both v1 (keyset IDs starting with 00) and v2 (01). Clients select funding keysets from their own mint cache, independently of relay-advertised keyset IDs. Selection requires an active same-mint/unit keyset with a negotiated version and sufficient recovery lifetime. Linking a channel funded by an unnegotiated version returns LinkKeysetVersionNotNegotiated and ends that MONAD session, not the shared QUIC connection. The client keeps the channel usable for a compatible session; loose input proofs are not restricted by this negotiation.
Not implemented yet:
- user-facing client wallet commands for mint quotes, proof minting, and richer balances
- unattended infinite retry before the first successful configured-client route connect; startup still fails fast on persistent misconfiguration
monad-relay uses named relay-wallet identities inside one shared SQLite relay-wallet database. Relay configuration lives in a single YAML file. Omitting --relay starts every configured relay in YAML order in one process; --relay <name> starts only that relay while the process still exclusively owns the whole configured relay wallet.
Example monad.yaml:
relay_wallet:
db_path: /var/lib/monad/relay.db
client_wallet:
loose_db_path: /var/lib/monad/client-loose.db
channel_db_path: /var/lib/monad/client-channels.db
sender_secret_hex: "${MONAD_CLIENT_SENDER_KEY}"
channel_funding_token_target_msats: 1000000
target_topup_buffer_msats: 10000000
minimum_topup_msats: 0
funding_keyset_recovery_window: 24h
management:
listen: 127.0.0.1:9090
relays:
- name: relay-a
receiver_secret_hex: "${MONAD_RELAY_A_RECEIVER_KEY}"
quic_cert_seed: "${MONAD_RELAY_A_QUIC_SEED}"
transport_key: "${MONAD_RELAY_A_TRANSPORT_KEY}"
listen: 127.10.0.11:9050
trusted_mints:
- url: https://dev.mint.camelus.app
units: [sat]
pricing:
in_bytes_per_millisat: 1
out_bytes_per_millisat: 1
connect_limits:
max_open_per_session: 64
max_setups_per_session: 16
max_open_per_relay: 1024
max_setups_per_relay: 512
channel_policy:
funding_keyset_recovery_window: 24h
min_expiry: 1h
min_capacity: 1msat
max_amount_per_output: null
expiring_channels:
close_before_expiry: 24h
auto_close:
enabled: false
interval: 1h
clients:
- name: local
socks: 127.10.0.1:1080
route:
- "${MONAD_RELAY_A_TRANSPORT_PUBKEY}::127.10.0.11:9050"Environment variables are substituted from the process environment or from a .env file in the same directory as the config. Defaults are supported: ${VAR:-default}.
Run the relay:
# Run every configured relay through one wallet manager.
monad-relay run --config monad.yaml
# Or run one selected relay while owning the same complete wallet.
monad-relay run --config monad.yaml --relay relay-aInspect the shared relay-wallet DB:
monad-relay wallet --config monad.yaml --relay relay-a list
monad-relay wallet --config monad.yaml --relay relay-a show
monad-relay wallet --config monad.yaml --relay relay-a channels
monad-relay wallet --config monad.yaml --relay relay-a expiring-channels
monad-relay wallet --config monad.yaml expiring-channels
monad-relay wallet --config monad.yaml --relay relay-a close-expiring-channels --dry-run
monad-relay wallet --config monad.yaml --relay relay-a close-expiring-channels
monad-relay wallet --config monad.yaml close-expiring-channels --dry-run
monad-relay wallet --wallet-db-path /var/lib/monad/relay.db close --channel-id <channel-id>
monad-relay wallet --config monad.yaml --relay relay-a drains
monad-relay wallet --config monad.yaml --relay relay-a drain --mint-url https://dev.mint.camelus.app --unit sat
monad-relay wallet --config monad.yaml --relay relay-a recover-drain --drain-id <drain-id>recover-drain resumes prepared, submitted, or locally finalizing attempts.
It verifies exact restored outputs and permits bounded immutable replay only
after checked all-input Unspent evidence. Invalid or ambiguous evidence retains
reservations. Finalizing/completed recovery is offline; terminal proofs stay in
the relay DB and cannot be replaced by conflicting completion. Incompatible old
nonempty drain journals are rejected, never migrated or deleted. See
WALLET.md for the recovery policy.
The reusable relay wallet backend also supports exact drains by channel ID. A request may contain at most 1,024 unique Closed channels, all owned by one relay and using one mint/unit, with valid nonempty receiver proofs; any invalid or already reserved channel rejects the whole request before mint submission. An in-process caller holding the running relay's manager may execute or recover these drains through runtime-owner authority. The loopback management API and relay page expose this as exact channel selection plus durable-attempt recovery while relay traffic remains active. The standalone wallet CLI keeps its selector-based syntax, remains an offline maintenance command, and still fails while that runtime owns the wallet.
Add --json to any wallet command for machine-readable output.
Repeating wallet close --channel-id <id> resumes the exact persisted close.
Verified local finalization works with the mint offline. Ambiguous or invalid
evidence leaves the channel Closing; it never releases funds or reports a
zero-value success. Legacy Closing records without exact journals are rejected,
not migrated or deleted. See relay close recovery.
One runtime process exclusively owns relay_wallet.db_path. It holds exclusive
maintenance access while opening/migrating the database, registering identities,
and populating the startup keyset cache, then shared maintenance access while
listeners run. list, show, channels, expiring-channels, drains, and
close-expiring-channels --dry-run remain available during runtime and use
read-only SQLite connections without migrations. Closing, draining, recovery,
and other mutating commands require exclusive maintenance access and fail before
opening the wallet or contacting a mint when a runtime is active. OS-backed
sidecar locks are released automatically if the process exits or dies. Inspection
does not write SQLite, but it still opens or creates the adjacent maintenance
sidecar for cross-process coordination, so the wallet directory must permit that
sidecar access. Existing database files with multiple hard links are rejected.
On first start, receiver_secret_hex is required so the relay can register its identity in the wallet database. On later restarts of the same relay wallet identity, omit receiver_secret_hex; the relay will load the existing receiver key for relay-a from the shared wallet DB.
All configured relays in a process share one RelayWalletManager, persistent store, and in-memory mint cache while retaining distinct receiver keys and wallet-name metadata. The config loader rejects any two relays that share the same receiver_secret_hex.
The reusable client wallet pieces exist in monad-client. The binary exposes
wallet admin/funding/recovery commands and can run configured QUIC SOCKS
clients. monad-client run --config monad.yaml runs every clients entry in
YAML order; add --client <name> to run only one entry.
LooseProofWalletstores loose Cashu proofs, mint quote state, premint batches, reservations, and spend/release state in SQLite.SqliteClientWalletuses those loose proofs to provision Spilman channels via upstreamcdk-spilman, stores MONAD channel metadata including expiry timestamps in SQLite, and implementsMonadWalletfor the session driver.- channel opening atomically reserves its loose proofs and journals the exact prepared swap before submission. Live ambiguous submissions restore their exact funding/change outputs through NUT-09; a valid empty funding restore plus every exact input
UNSPENTmay authorize at most one byte-identical replay submission. Authorization also requires execution authority and a wall-clock second later than the preceding authorized submission. - configured-client startup and manual
recover-openingsnever submit opening swaps. They finish restored or finalizing channels and cancel prepared attempts and rejected attempts without successors.wallet export-stale-opening-inputsrequires exclusive wallet access and, after one hour plus exact empty NUT-09 restore and all-UNSPENTNUT-07 evidence, groups eligible inputs by mint/unit and atomically marks every attempt represented by a bearer tokenExportedbefore printing it. The proofs remain reserved. The command reports successful tokens and unresolved attempts independently, so one failed group does not suppress another. Repeated exports are overlapping snapshots and may emit the same still-reserved proofs again. Export never finalizes a restorable opening; it reports thatrecover-openingsis required. Recovery later finalizes a delayed opening, or marks exported proofsExternallySpentonly after exact all-SPENTevidence and a final empty restore. Recent, stale, clock-rollback, partial, invalid, or unavailable evidence remains reserved. - output keyset handling is cache-first: channel opening selects a compatible active same-mint/unit keyset from client-owned metadata, ordered deterministically by keyset ID. The client refreshes before concluding that no compatible active keyset exists. Relay advertisements do not trigger refresh or rank keysets. A direct, unambiguous initial inactive-output-keyset rejection (
12002) may create one persisted successor using a changed active keyset. Ambiguous outcomes may authorize the one identical replay described above, but never a changed-request successor. - opening and refund HTTP rejections report the HTTP status and numeric NUT-00 code without retaining the mint's response body. Only a structured HTTP 4xx
12002, subject to the operation's journal authority, can permit changed output keys; error-message text is not rejection evidence.
client_wallet.channel_funding_token_target_msats controls the desired funding-token value for each newly provisioned channel. Cashu input fees are selected in addition to this target; output fees and deterministic channel outputs can make usable channel capacity lower. The default is 1000000 msats.
client_wallet.target_topup_buffer_msats controls the positive session balance the client tries to restore when funding is needed; the default is 10000000 msats. client_wallet.minimum_topup_msats sets a lower bound for normal topups; the default is 0 msats. Payment sizing uses the client's own byte and payment records; an unexpectedly higher relay-reported accepted-payment total can only increase the client's credit estimate.
If a relay rejects a signed/sent payment, the client preserves its pessimistic payment record and signed wallet history, records the error, and ends that session rather than sending replacement funds there. The relay may still be able to claim the signed amount despite reporting rejection.
Each solicited status must report at least the paid total expected at its FIFO
position. A status for an earlier query is not charged with a later queued
payment; a short-paid payment response ends the session. Empty-FIFO ordinary
responses are discarded, while recognized fatal errors still end the session.
The protocol-fatal registry entries are CONTROL_INVALID_MESSAGE,
LINK_KEYSET_VERSION_NOT_NEGOTIATED, and INTERNAL_ERROR; other registered
errors answer one ordered request. ChannelEvicted advisories carry an explicit
session or relay scope, with ownership transfer currently emitting session.
Numeric-limit failures are reported explicitly: the client checks its proposed session total before signing, and the relay checks both paid-total and signed remaining-credit limits before recording a payment. Transport accounting checks representability and records each successful write in the same poll, including partial writes followed by task cancellation. These are numeric safeguards, not a monetary cap or a prohibition on negative-balance overshoot.
Admin/recovery commands can use the configured singleton client_wallet:
monad-client wallet --config monad.yaml channels
monad-client wallet --config monad.yaml proofs
monad-client wallet --config monad.yaml import-token --token <cashu-token>
monad-client wallet --config monad.yaml import-token --token-file ./token.txt
monad-client wallet --config monad.yaml recover-channel --channel-id <channel-id>
monad-client wallet --config monad.yaml recover-openings
monad-client wallet --config monad.yaml export-stale-opening-inputsimport-token stores the token's existing bearer proofs without swapping them
into fresh proofs. It does not invalidate other copies or establish exclusive
ownership. Import only tokens you control, and do not continue using another copy
from another wallet.
Exports group inputs by mint/unit. Unchanged eligible proofs produce the same token encoding even when they span multiple input keysets; repeated output is not additional value and may contain proofs already present in an earlier export.
One in-process ClientWalletManager owns the configured loose-proof and channel
databases. Startup opens/initializes supported schemas and performs opening
recovery once before any client starts, then all client leaves share that wallet.
A second runtime using
either database fails immediately. channels and proofs use read-only SQLite
access, require no sender secret in explicit-path mode, and may run while
the runtime is active. Import and recovery commands require exclusive maintenance
access and fail immediately while a runtime owns the wallet. Lock sidecars are
created next to each normalized, deduplicated database path, including for
inspection when absent; the containing directories must allow sidecar access.
Existing database files with multiple hard links are rejected.
recover-channel explicitly recovers an established channel, unlike the startup
opening-recovery pass. Stop the client runtime first and keep the same loose DB,
wallet_name, channel DB, and sender key. Pending refunds remain Closing and
cannot be reused. Rerun the command after a retry-later outcome: it restores the
persisted request before bounded exact replay, rather than choosing fresh outputs
after uncertainty. Verified proofs awaiting local import/closure can finish
offline; completed recovery returns locally. Each CLI mint request has a 15-second
timeout. An empty sender-close scan is not reported as successful recovery.
Refund journal v2 is a testnet-breaking change with no data migration. Nonempty incompatible journals are rejected, not deleted; an empty legacy table can be replaced. Preserve funded databases and backups. Only explicitly reset disposable test databases you own, accepting loss of their local state; MONAD does not automatically reset wallets. Refund records contain secret output material, so do not publish database dumps or prepared requests. See Channel Fund Recovery for recovery boundaries.
Wallet commands can also use explicit DB paths and sender key material for manual or emergency access:
monad-client wallet \
--loose-db ~/.monad/client-loose.sqlite \
--channel-db ~/.monad/client-channels.sqlite \
--wallet-name default \
channels
monad-client wallet \
--loose-db ~/.monad/client-loose.sqlite \
--channel-db ~/.monad/client-channels.sqlite \
proofs
monad-client wallet \
--loose-db ~/.monad/client-loose.sqlite \
--channel-db ~/.monad/client-channels.sqlite \
--sender-secret-hex <hex> \
import-token --token <cashu-token>
monad-client wallet \
--loose-db ~/.monad/client-loose.sqlite \
--channel-db ~/.monad/client-channels.sqlite \
--sender-secret-hex <hex> \
import-token --token-file ./token.txt
monad-client wallet \
--loose-db ~/.monad/client-loose.sqlite \
--channel-db ~/.monad/client-channels.sqlite \
--sender-secret-hex <hex> \
recover-channel --channel-id <channel-id>
monad-client wallet \
--loose-db ~/.monad/client-loose.sqlite \
--channel-db ~/.monad/client-channels.sqlite \
--sender-secret-hex <hex> \
recover-openingsAdd --json to wallet commands for machine-readable output.
Remaining client-wallet work is operator/user experience rather than the core channel-payment library path: mint quote/mint commands, richer balance inspection, and close/sweep flows.
monad-common/ Shared transport and protocol helpers
monad-client/ Client library, wallet/driver logic, and binary entrypoint
monad-relay/ Relay binary and library
monad-quic/ Shared QUIC transport code plus standalone echo tooling
monad-test-client/ Local SOCKS5/manual test harness with mocked funding
cargo buildYou can also just use cargo run, which builds automatically if needed.
cargo testFocused real-control-stream payment-conflict recovery coverage:
cargo test -p monad-relay --test integration payment_conflictThese non-ignored tests cover first-hop full reconnects and middle-hop suffix rebuilds with persisted wallets. See payment-conflict validation for injection scope, assertions, and recorded payment/chaos stress results.
The repo pins its Rust toolchain and formatting config so local runs and CI stay deterministic.
Useful commands:
make fmt
make fmt-check
make lint
make test
make checkThe Makefile also includes named manual stress recipes for:
- transport-focused stress (
make stress-transport-extreme) - repeated
ChannelPaymentstress on one linked channel (make stress-payment-buffered) - repeated relink stress with one active channel per session at a time (
make stress-payment-relink) - configured-client chaos route rebuilds (
make stress-chaos-rebuild, the longer 5-hopmake stress-chaos-rebuild-intense, or abrupt-kill coverage withmake stress-chaos-rebuild-abrupt)
The throughput/payment stress recipes expect a high ulimit -n and are intended for developer load testing rather than routine CI. The configurable transport harness accepts explicit MONAD_STRESS_MAX_OPEN_CONNECTS_PER_SESSION, MONAD_STRESS_MAX_CONNECT_SETUPS_PER_SESSION, MONAD_STRESS_MAX_OPEN_CONNECTS_PER_RELAY, and MONAD_STRESS_MAX_CONNECT_SETUPS_PER_RELAY overrides; the extreme recipe sets these above production defaults. The chaos rebuild recipes run at a smaller scale and need no special ulimit. MONAD_CHAOS_KILL_MODE=graceful|abrupt|mixed selects whether chaos restarts use clean shutdown, task-tree cancellation, or alternating modes. The main client now uses timer-driven local counter checks for payment sizing; the stress recipes still use frequent GetSessionStatus polling intentionally to exercise relay control-plane behavior under load.
Ignored but important regression tests can be run explicitly when changing route rebuild or keyset-refresh behavior:
cargo test -p monad-relay test_configured_client_three_hop_middle_rotation_refreshes_relay_on_channel_link --test integration -- --ignoredCurrent coverage includes:
- Noise handshake and large-payload transport tests
- Noise session ID (handshake hash) matches on both sides
- session starts paused by default
- second control stream rejected
- CONNECT rejected before the first payment without destination setup
- CONNECT accepted during later pauses but forwarding blocked until payment
- per-session and relay-wide CONNECT open/setup limits, fail-fast refusal, and permit release
- SessionStatus CONNECT acceptance, closure, and failure counters
- control traffic remains usable while paused CONNECT DATA fills the H2 receive window
- funded data channel (payment unpauses, then data flows)
- session repauses and resumes after second payment
- session overshoot with negative balance and resume
- concurrent bounded tunnel operations overshooting shared credit
- exact billing boundaries, zero-rate rejection, and pre-mutation numeric-limit errors
- bounded control framing, parser complexity, error delivery, and fatal cleanup
- exact 32 KiB H2 header-list boundaries and internal trailer rejection
- underpayment stays paused until balance is positive
- multiple simultaneous tunnels
- 2-hop and 3-hop nested routing
- IPv6 final targets
- IPv6 relay listeners
- mixed IPv4/IPv6 hop chains
- hostname resolution at the final hop
- SOCKS5 IPv6 parsing
- QUIC echo with pinned-key authentication
- QUIC pinned-key rejection (wrong key)
- 1,000 concurrent QUIC streams over one connection
- large (4MB) single QUIC stream payload
- multiple independent QUIC connections
- QUIC single-hop (Noise+H2 over QUIC stream)
- QUIC control + data channels
- nested QUIC tunnel (TCP relay forwarding to QUIC relay)
- client connector with QUIC hop
- configured-client route rebuilds across first, middle, and final hop restarts
- configured-client abrupt relay loss through the chaos stress harness
- connector blinded hop route (
RouteHop::Blinded) - connector two-consecutive-blinded-hop route
- connector hard-fails when a relay lacks required blinded/nested capability bits
- concurrent QUIC pool access
- client QUIC first hop (direct QUIC connection from client)
- QUIC first hop then TCP second hop
- TCP secp single-hop and nested plain-CONNECT secp tunnels
- session funding and incremental payments via
ChannelLink/ChannelPayment - relay advertises multiple mint/unit funding options under session-wide rates
- control detach releases linked channels and tears down active / future streams
- changing a relay's current trusted mint policy stops new advertisement/acceptance for that mint without invalidating previously stored channels
Each relay wallet manager owns one shared in-memory SpilmanMintCache populated from configured mint URLs. The cache stores all keysets returned by those mints, active and inactive, for all units the mint reports. Advertisements map mint URL to unit to an object containing minimum_channel_lifetime_secs and funding_keyset_recovery_window_secs, generated from trusted policy even when keyset metadata is absent. They contain no keyset IDs or per-offer prices. Session prices are bytes_in_per_msat and bytes_out_per_msat. Stored channels may relink after a trust-policy change, but every relink must satisfy the current admission deadlines.
Clients and relays negotiate h2-2026-10-07. During pre-alpha development,
upgrade both ends of every hop together as breaking changes bring the
implementation into conformance with the control contract. Control framing
enforces the contract's 1 MiB line limit, duplicate-key and parser-complexity
rejection, and fatal malformed-input cleanup; the relay attempts one bounded
generic CONTROL_INVALID_MESSAGE before teardown. Remaining work is tracked in
issue #125. No database
migration or reset is needed for this advertisement update.
Every MONAD H2 request and response header list is limited to 32 KiB of uncompressed HTTP/2 header-list size. MONAD does not use trailing H2 HEADERS: control trailers are a fatal framing error, while CONNECT trailers close that tunnel without terminating the enclosing session. This restriction applies only to the internal MONAD H2 layer. HTTP headers and trailers sent to an internet destination travel as opaque CONNECT DATA and are unaffected.
Relays also bound logical CONNECT work independently of H2 stream mechanics.
connect_limits defaults to 64 open CONNECTs and 16 concurrent destination
setups per session, and 1,024 open CONNECTs and 512 setups across the relay.
Open capacity is held from request admission through tunnel close; setup capacity
is held only around TCP, authenticated QUIC, or blinded QUIC establishment through
successful 200 OK submission. Exhaustion fails immediately without destination
contact: session limits return typed HTTP 429 and relay limits return typed HTTP
503. Control traffic is unaffected.
When a first-time ChannelLink uses an unknown keyset for a configured trusted mint/unit, the relay performs metadata-independent structural checks, transparently invokes its bounded refresh coordinator, and retries the immutable link once. A successful refresh that still does not know the keyset produces a permanent LinkMintOrKeysetUnacceptable rejection. If cooldown, that relay coordinator's cross-mint capacity, timeout, or mint failure prevents a fresh decision, the relay returns a specific transient link error and the configured client preserves the channel and retries with backoff.
There is no client-requested relay refresh operation. Automatic first-link refresh is limited to configured trusted mint/unit pairs: within one hosted relay, each mint gets at most one actual attempt per cooldown regardless of outcome, concurrent same-mint links share one cancellation-safe attempt, cross-mint saturation fails fast, and mint I/O has a timeout. Hosted relays share the wallet cache but have separate refresh coordinators and budgets. Existing stored channels relink from authoritative persisted funding without requiring the current cache or triggering refresh. Startup discovery still populates the cache. Close and drain operations have separate recovery state machines; both use cache-only selection, can warm an empty mint/unit cache, and allow at most one changed-output-keyset retry after a recognized keyset rejection.
The canonical client funding implementation lives in monad-client/src/session_driver.rs and its private modules:
runtimeruns the serialized control loopstateholds local driver state, publishing helpers, and session-scoped channel accounting authorityreducerowns ordinary-request sequencing and channel replacement transitionspolicymakes in-memory funding decisions on the existing 250 ms opportunitiesfundingexecutes channel acquisition, link, payment, retirement, and recovery effectspaymentholds payment math and relay/client safety checks
Shared protocol helpers used by client, relay, and harness code live in:
monad-common/src/control_codec.rsfor newline-delimited control messagesmonad-common/src/payment_units.rsformsat/satraw-unit conversion
For maintainers, the most focused reference is docs/payments.md; ARCHITECTURE.md
stays the higher-level protocol overview.
MONAD transport now uses secp256k1 identities throughout:
- TCP MONAD transport uses secp Noise with 32-byte x-only relay identities
- QUIC MONAD transport uses secp attestation plus secp Noise with the same 32-byte x-only relay identities
Configured clients use secp256k1 hop identities from YAML route entries. The relay still keeps an Ed25519 seed internally for QUIC certificate generation, but that is no longer a client-facing MONAD transport identity.
Identity model:
- long-lived relay identities are 32-byte x-only secp256k1 pubkeys with implied even Y
- blinded-hop tweaked pubkeys are also 32-byte x-only secp256k1 pubkeys, kept even via deterministic tweak adjustment
- ephemeral ECDH pubkeys remain 33-byte compressed curve points
- Noise DH operates on full curve points internally even though the configured relay identities are x-only
For QUIC, the relay first presents a self-signed Ed25519 certificate so the standard TLS 1.3 handshake can establish an encrypted channel. MONAD then binds that live QUIC channel to the configured secp256k1 transport identity by having the relay sign a challenge plus QUIC exporter-derived keying material. Clients verify that signature against the expected secp256k1 public key.
Quick reference:
| Transport | Route config | Identity | Auth mechanism |
|---|---|---|---|
| TCP | addr + pubkey |
32-byte x-only secp256k1 | secp Noise NK |
| QUIC (secp) | addr + pubkey with QUIC route transport |
32-byte x-only secp256k1 | QUIC attestation + secp Noise NK |
cargo run -p monad-relay -- keygenThis prints:
- an Ed25519 seed/public key used for QUIC certificate generation
- a secp256k1 transport private key plus its 32-byte x-only public identity for MONAD TCP and QUIC transport auth
- a QUIC certificate derived from the Ed25519 seed
Create a monad.yaml file. A single file can hold the shared wallets, many relays, and one or more client route definitions. A relay process runs all configured relays by default or one relay selected with --relay <name>.
relay_wallet:
db_path: /var/lib/monad/relay.db
client_wallet:
loose_db_path: /var/lib/monad/client-loose.db
channel_db_path: /var/lib/monad/client-channels.db
sender_secret_hex: "${MONAD_CLIENT_SENDER_KEY}"
channel_funding_token_target_msats: 1000000
target_topup_buffer_msats: 10000000
minimum_topup_msats: 0
relays:
- name: hop1
receiver_secret_hex: "${HOP1_RECEIVER_KEY}"
quic_cert_seed: "${HOP1_ED25519_SEED}"
transport_key: "${HOP1_SECP_KEY}"
listen: 127.10.0.11:9051
pricing:
in_bytes_per_millisat: 1
out_bytes_per_millisat: 1
trusted_mints:
- url: https://dev.mint.camelus.app
units: [sat]
- name: hop2
receiver_secret_hex: "${HOP2_RECEIVER_KEY}"
quic_cert_seed: "${HOP2_ED25519_SEED}"
transport_key: "${HOP2_SECP_KEY}"
listen: 127.10.0.12:9052
pricing:
in_bytes_per_millisat: 1
out_bytes_per_millisat: 1
trusted_mints:
- url: https://dev.mint.camelus.app
units: [sat]
clients:
- name: local
socks: 127.10.0.1:1080
route:
- "${HOP1_SECP_PUBKEY}::127.10.0.11:9051"
- "${HOP2_SECP_PUBKEY}::127.10.0.12:9052"The pricing fields are required for every relay entry and must be greater than zero.
channel_policy is optional. min_expiry (default 1h) is advertised as
minimum_channel_lifetime_secs and enforced on both first links and stored
relinks. The channel must expire at least that far into the relay's future.
If the funding keyset has a final expiry, it must be at least
funding_keyset_recovery_window (default 24h) after channel expiry. Equality
passes; timestamp overflow fails. Clients skip locally too-short stored channels
and provision for at least the greater of their usual 24-hour lifetime or the
advertised minimum plus a margin (10% of the minimum, bounded to 1–60 seconds).
Long delays or clock skew can still cause admission rejection; prepared expiries
are immutable. These checks do not erase accepted payments or block recovery.
The new lifetime advertisement field is required: update clients and relays
together under h2-2026-10-07; no database reset is needed.
min_capacity and
max_amount_per_output use explicit sat or msat suffixes and are converted
to the linked channel's raw unit before validation.
expiring_channels.close_before_expiry controls when existing Open /
Closing relay channels are considered close to expiry for maintenance.
expiring_channels.auto_close is disabled by default; when enabled, the running
relay closes its own currently expiring channels once at startup and then every
configured interval, continuing after individual failures and logging summary
stats. close-expiring-channels runs the same kind of close sweep manually,
continues after individual failures, and exits non-zero if any close fails. Omit
--relay / --wallet-name on
expiring-channels or close-expiring-channels to scan all relay identities;
human output is a flat list with a RELAY column. Duration fields accept seconds
as numbers or strings such as 3600s, 60m, 2h, and 1d.
Single selected relay (this process still exclusively owns the configured wallet):
RUST_LOG=info cargo run -p monad-relay -- run --config monad.yaml --relay hop1All relays in this configuration, including the two-hop example above:
RUST_LOG=info cargo run -p monad-relay -- run --config monad.yamlSeparate relay processes require separate wallet databases/configurations. Do not
run hop1 and hop2 as separate processes against the shared relay_wallet.db_path
shown above; the second process will correctly fail wallet ownership acquisition.
Alternative two-process shape:
# terminal 1: config-hop1.yaml has its own relay_wallet.db_path
RUST_LOG=info cargo run -p monad-relay -- run --config config-hop1.yaml --relay hop1
# terminal 2: config-hop2.yaml has a different relay_wallet.db_path
RUST_LOG=info cargo run -p monad-relay -- run --config config-hop2.yaml --relay hop2monad-client wallet --config monad.yaml ... exposes configured wallet
inspection and recovery commands; explicit DB/key flags remain available for
manual access. monad-client run --config monad.yaml --client local starts the
configured route and binds the configured SOCKS5 listener.
All configured clients:
RUST_LOG=info cargo run -p monad-client -- run --config monad.yamlOne configured client:
RUST_LOG=info cargo run -p monad-client -- run --config monad.yaml --client localDirect connection route:
clients:
- name: local
socks: 127.0.0.1:1080
route:
- "<SERVER_SECP256K1_PUBKEY>::127.0.0.1:9050"Three-hop route:
clients:
- name: local
socks: 127.0.0.1:1080
route:
- "<HOP1_SECP_PUB>::127.0.0.1:9051"
- "<HOP2_SECP_PUB>::127.0.0.1:9052"
- "<HOP3_SECP_PUB>::127.0.0.1:9053"Each configured client listens locally as a SOCKS5 proxy at its
clients[].socks address. If any client leaf or SOCKS listener fails, the
process coordinates shutdown and awaits every managed client before releasing
the shared wallet.
Configured routes currently use QUIC for every hop:
clients:
- name: local
socks: 127.0.0.1:1080
route:
- "<HOP1_SECP_PUB>::127.0.0.1:9051"
- "<HOP2_SECP_PUB>::127.0.0.1:9052"Route entries are compact strings, not addr / pubkey maps:
- Clear:
<key>::<address>. - Blinded:
<tweaked-key>:B:<versioned-data>. - Keys accept the existing
npubencoding or 64 hex characters representing an x-only secp256k1 public key.mpubis not an alias. Serialization emits hex. - Clear address strings are opaque route data and round-trip without parsing, normalization, bracket insertion, or a default port. They must be nonempty, NUL-free UTF-8 of at most 975 bytes.
- Current TCP and QUIC dispatch requires an explicit numeric nonzero port. Use
relay.example:9050,127.0.0.1:9050, or[2001:db8::1]:9050; portless and bare IPv6 strings remain representable but fail before network dispatch. - The first hop must be clear. Each later blinded entry is decrypted by its
immediately preceding relay; the key before
:B:identifies the hidden target's tweaked identity, not the introduction relay.
For example, this syntactically valid clear hop uses the secp256k1 generator key (replace it with your relay's actual key):
route:
- "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798::localhost:9050"Construct a blinded suffix offline using only the real public keys and addresses:
monad-client blind-route \
"${INTRO_PUBKEY}::intro.example:9050" \
"${HIDDEN_B_PUBKEY}::[2001:db8::2]:9050" \
"${HIDDEN_C_PUBKEY}::hidden.example:9050"This prints a directly usable YAML route: block: one clear introduction hop
followed by blinded strings. All inputs must be clear hop strings; every hop
after the first is hidden. No wallet, private key, or network access is needed.
Run construction on a trusted machine: input arguments reveal the real route
to local process inspection and shell history. The generated blobs are opaque
unpadded base64url, with an explicit version 0 envelope. They are not
compressed or re-encrypted. Unknown versions, noncanonical base64url, invalid
points, truncation, trailing bytes, and ciphertext outside 50..=1024 bytes are
rejected. See Blinded Routing for the layout.
This is a breaking early-alpha YAML change: convert old maps to strings. It does not migrate or modify persisted wallets, nor change the transport protocol.
For non-first hops, the previous relay connects via QUIC instead of TCP. The
configured client uses secp256k1 transport identities for QUIC hop
authentication. See ARCHITECTURE.md for the full layering model.
When a funded hop fails, the configured client temporarily withdraws the active
route from the SOCKS listener. Existing application streams are not migrated to
the replacement route; they fail if their old route breaks. New SOCKS requests
fail while no route is published, then use the replacement route once it is
connected. A first-hop failure triggers a full reconnect. Later-hop failures
detach only channels tied to the old suffix sessions and try to rebuild from the
failed hop, preserving the unaffected prefix when possible; suffix rebuild
failure falls back to a full route reconnect. During suffix setup, transient
CONNECT 502 responses are retried every 250 ms for up to three seconds per hop
(within the overall setup budget), allowing a restarting relay time to return
without discarding the prefix. Failed or cancelled setup aborts and
awaits its owned payment and H2 tasks before detaching channels or retrying, so a
completed compatible channel can be reused. The default setup budget is five
seconds; cleanup can take longer if an in-flight mint call must finish. Library
embedders can override the budget per instance with
ConfiguredClientRuntimeOptions::route_setup_timeout and
run_configured_client_until_shutdown_with_options.
The client does not run concurrent rebuilds; failures observed during an in-flight rebuild are treated as stale and
ignored until the rebuilt route is active. Route (re)connects fail fast after a
few attempts before the first successful connect, so startup misconfiguration is
loud; once a route has connected, reconnects retry indefinitely with capped
backoff, and the client recovers when the route (or a refilled wallet) allows.
Loss of a hop's payment-driver failure sender is also treated as a hop failure,
including when the driver exits without sending its final notification.
Closing a funded connection releases only its own wallet attachments, including
cancelled link sends. Runtime wallet locks remain held by outstanding wallet
handles until their work actually finishes; aborting a blocking mint call does
not prematurely make the wallet available for maintenance. Control writes use
the existing 15-second heartbeat timeout instead of waiting forever for H2 capacity.
The configured client connects directly to the first hop via QUIC, then runs the same Noise+H2 session on top using the secp QUIC path.
Blinded routes are currently exposed through the Rust library route model
(RouteHop::Blinded) rather than the YAML client route format.
curl -x socks5h://127.0.0.1:1080 http://example.com/Use socks5h:// if you want hostname resolution to happen at the final hop instead of locally.
With ncat:
ssh -o ProxyCommand='ncat --proxy 127.0.0.1:1080 --proxy-type socks5 %h %p' user@example.comscp -o ProxyCommand='ncat --proxy 127.0.0.1:1080 --proxy-type socks5 %h %p' \
user@example.com:/path/to/file ./local-copyWhen a tunnel closes, the client and final relay log plaintext proxied bytes:
tunnel closed: example.com:22 | outbound=63630 inbound=5200 total=68830
Each SecpNoiseStream logs encrypted wire usage when the hop connection shuts down:
SecpNoiseStream closed label=client hop 2/3 to 127.0.0.1:9052 wire_read=... wire_written=... wire_total=...
To see these, use debug logging for the Noise module:
RUST_LOG=monad_common::noise_secp256k1=debug,monad_client=info,monad_relay=infoEach relay logs every CONNECT request it receives:
CONNECT 127.0.0.1:9052
CONNECT 127.0.0.1:9053
CONNECT satsandsports.cash:22
In a multi-hop chain, only the final hop sees the actual target. Intermediate hops only see the next hop.
Both client and relay handle Ctrl+C gracefully:
- stop accepting new work
- wait briefly for active tunnels/sessions to finish
- shut down H2 connections cleanly
- emit
SecpNoiseStreamwire-byte totals
Relay session teardown also ends paused tunnels and tunnels blocked on target writes or H2 flow control. Relay CONNECT setup runs concurrently with the control stream and has a 10-second deadline (including the blinded-hop tweak preamble). Setup failure or timeout returns HTTP 502. A CONNECT may be established and published while the session is paused; its proxy forwards no cleartext bytes until payment restores positive credit. Explicit relay shutdown drains QUIC connections before returning. Abrupt task cancellation drops MONAD sessions immediately, but Quinn's internal protocol drivers can retain the UDP socket briefly while draining. Interrupted auto-close work leaves its durable close journal available for recovery on restart. Proxy EOF preserves normal half-close and permits a reply after the request ends; a reset or other hard I/O error cancels the opposite copy direction. The client proxy observes H2 resets even when application writes are blocked, including after the application's send half has closed.
The monad-quic crate also includes a standalone QUIC echo server/client for transport testing and experimentation. The main MONAD client and relay now use shared code from this crate for QUIC support.
The echo server owns its connection and stream futures. Library callers can use
monad_quic::server::run_server_endpoint with an already-bound endpoint; closing
that endpoint stops and drains the service, while dropping the server future
cancels its application-level children.
cargo run -p monad-quic -- keygenSave the private key block to server.key, the certificate block to server.crt, and note the pinned public key hex.
RUST_LOG=info cargo run -p monad-quic -- server \
--listen 127.0.0.1:4433 \
--cert server.crt \
--key server.keyRUST_LOG=info cargo run -p monad-quic -- client \
--connect 127.0.0.1:4433 \
--pin <PINNED_PUBLIC_KEY_HEX> \
--streams 16 \
--bytes 65536This opens 16 bidirectional QUIC streams, sends 64KB of random data on each, reads the echo, and verifies correctness.
DESIGN.mdfor the high-level system design and tradeoffsARCHITECTURE.mdfor the protocol and layering modelAGENTS.mdfor repo-specific development guidance
make test-funds-lifecycle builds the client and relay CLIs and runs opt-in
actual-process wallet lifecycle tests with a retained real CDK HTTP mint.
See coverage and private failure artifacts.
make test-funds-crashes includes compile-gated fault injection and real-expiry
refund tests. Both it and make stress-funds-lifecycle build their CLIs in
target/funds-lifecycle/debug, leaving normal target/debug and target/release
binaries untouched; no normal-binary rebuild is required afterward. The isolated
instrumented client binary is test-only: never use it with real funds.
MONAD_FUNDS_SEED=20260921 MONAD_FUNDS_CYCLES=256 make stress-funds-lifecycle
runs reproducible crash/rotation cycles from one fixed purse, stopping at a
conservative capacity/fee margin rather than replenishing funds.
Run the disposable management and two-SOCKS demo with
docker compose -f compose.demo.yml up --build -d.
Management is published at 127.0.0.1:18080 for a host HTTPS proxy; SOCKS5 is
published on ports 11080 and 11081. Private child logs and process-limit
snapshots persist across container exit for diagnosis; wallets and keys do not.
The managed ratio traffic server starts inside the container for client speed
tests and requires no additional published port.
See Docker demo for startup, diagnostics, port overrides,
interactive commands, and verification.