灵符lingfu
L

ling-wallet

↓ 0 downloads

Ling Wallet: a post-quantum privacy-coin wallet, full node and HyperTorus miner for LNG

Windows · x86-64 ✕Linux · x86-64 ✕LingOS · x86-64LingOS · Pi 5 ARM64 ✕
Keywords wallet, cryptocurrency, lng, node, miner, ling
lingfu install ling-wallet
LicenseMIT
Authorsling-lin
READMECode / FilesVersionsDependency GraphDependentsSecurityBuildsSettings

Code / Files (116)

docs/DESIGN.md
# ling-wallet — design specification (v1, "LW-1")

A post-quantum, privacy-focused cryptocurrency (ticker **LNG**) with a
LingStation-styled desktop wallet that embeds a full node and a passive
background miner. This document is the contract between crates: every
constant, byte layout, and consensus rule below is normative.

Status labels used throughout:

- **[standard]** — a NIST-standardized or widely vetted primitive.
- **[novel]** — designed for this project, unaudited. Never the sole thing
  standing between an attacker and funds unless explicitly noted.
- **[experimental]** — research-grade construction (the ring signature).
  Clearly flagged in the UI and docs.

## 1. Components

| component | role |
|---|---|
| Ling runtime builtins (`docs/LING_API.md`) | hashing, hybrid KEM, ML-DSA-87 identities, AEAD, password KDF, lattice arithmetic, linkable ring signatures (LRS), HyperTorus PoW, and the P2P transport: authenticated PQ handshake, padded encrypted frames, signed seeds, Dandelion++, SOCKS5 (Tor) |
| `ling/core/` | chain types, serialization, consensus, mempool, wallet |
| `ling/isolates/` | node (chain, mempool, P2P), walletd (keys, scanning, signing), miner |
| `ling/backend.ling` | the UI-facing `be_*` contract (`docs/LING_BACKEND.md`), real or `--mock` |
| `ling/ui/` | desktop UI, LingStation theme |

## 2. Hashing [standard]

All hashing is SHA-3 family, domain-separated:

```
H(domain, parts…)   = SHAKE256( enc(domain) ‖ enc(part_1) ‖ … ‖ enc(part_n) ) → 32 bytes
XOF(domain, parts…) = same input, arbitrary output length
enc(x)              = u32_le(len(x)) ‖ x
```

Domains are ASCII strings starting `LW1/`. Every use site picks its own
domain; no two uses share one. (Block ids, tx ids, Merkle nodes, challenge
hashes, key derivation, etc.)

## 3. Keys and addresses

### 3.1 Seed and derivation

A wallet is a 32-byte random **master seed**, shown to the user as a
24-word BIP-39 English mnemonic (the mnemonic is only an encoding; no BIP-32).

```
spend_seed    = XOF("LW1/derive/spend",    master, 32)
view_seed     = XOF("LW1/derive/view",     master, 96)   // 32 X25519 ‖ 64 ML-KEM (d‖z)
identity_seed = XOF("LW1/derive/identity", master, 32)   // ML-DSA-87 seed
```

- **Spend key** (LRS, §5): `s_spend ← SampleSmall(spend_seed, η_spend = 2)`,
  `t_spend = A·s_spend`.
- **View key** (hybrid KEM, §4): X25519 + ML-KEM-1024 from `view_seed`.
- **Identity key** (ML-DSA-87 [standard], FIPS 204): signs the address
  bundle and payment receipts; also usable as a node identity.

### 3.2 Address bundle and short address

```
AddressBundle = version:u8 (=1)
              ‖ network:u8
              ‖ view_pk      (1600 B: 32 X25519 ‖ 1568 ML-KEM-1024 ek)
              ‖ spend_pk     (2944 B: t_spend packed, §5.2)
              ‖ identity_pk  (2592 B: ML-DSA-87)
              ‖ sig          (4627 B: ML-DSA-87 over H("LW1/bundle", all preceding fields))
```

The **short address** is what people share:

```
addr_hash     = H("LW1/address", bundle_without_sig)
short_address = "lng1" ‖ base32(addr_hash ‖ checksum4)      // RFC 4648 lowercase, no padding
checksum4     = H("LW1/address-checksum", "lng1" ‖ addr_hash)[0..4]
```

To pay a short address the wallet fetches the full bundle over P2P (any
peer may answer), then **rejects it unless** `H(bundle)` equals the address
hash and the ML-DSA-87 signature verifies under the bundle's identity key.
A peer can withhold a bundle but cannot forge or redirect one. Bundles can
also be exchanged out of band as `lngkey1…` (base64url of the bundle).

The UI always shows a **fingerprint** of the recipient before sending: the
first 8 bytes of `addr_hash` as four 4-hex groups plus an identicon, so a
user can confirm it with the recipient over a second channel.

## 4. Hybrid KEM [standard]

X25519 ‖ ML-KEM-1024 (FIPS 203, category 5).

```
pk = x25519_pk (32) ‖ mlkem_ek (1568)                         = 1600 B
ct = x25519_eph_pk (32) ‖ mlkem_ct (1568)                     = 1600 B
ss = H("LW1/kem", context, ss_x25519, ss_mlkem, ct, pk)       = 32 B
```

Secure if *either* component holds. `context` binds the use (e.g. the
transaction prefix domain).

## 5. Linkable ring signature "LRS-87" [experimental]

Lyubashevsky-style Fiat-Shamir-with-aborts ring signature (AOS ring
structure) over the ML-DSA ring `R_q = Z_q[X]/(X^256 + 1)`,
`q = 8380417`. Key images give linkability (double-spend detection).

### 5.1 Parameters

| name | value | note |
|---|---|---|
| n | 256 | ring degree |
| q | 8380417 | ML-DSA modulus |
| k | 4 | rows of A (public key polys) |
| m | 4 | rows of B (key image polys) |
| l | 15 | secret length; l − (k+m) = 7 polys ⇒ MLWE dim 1792, same as ML-DSA-87 |
| η_spend | 2 | spend secret coefficients ∈ [−2, 2] |
| η_offset | 2 | one-time offset coefficients ∈ [−2, 2] |
| η | 4 | one-time secret bound (= η_spend + η_offset) |
| τ | 60 | challenge weight (±1 coefficients) |
| β | τ·η = 240 | ‖c·s‖∞ bound |
| γ | 2^19 | mask range: y ∈ [−(γ−1), γ−1] |
| bound | γ − β | accept iff ‖z‖∞ < γ − β |
| RING_SIZE | 11 | members per input (see §7.4 for early-chain rule) |
| MAX_RING_LEN | 64 | rings of 1..=64 members are signable/verifiable; others are rejected |

`A ∈ R_q^{k×l}` and `B ∈ R_q^{m×l}` are expanded once from the fixed seeds
`"LW1/lrs/A"` / `"LW1/lrs/B"` with SHAKE128 rejection sampling (uniform mod
q), exactly like ML-DSA's ExpandA but with these seeds. Precisely: entry
`(r, c)` (row r, column c) is RejNTTPoly over
`SHAKE128(seed_ascii ‖ u8(c) ‖ u8(r))`: read 3-byte groups, candidate
`b0 + 2^8·b1 + 2^16·(b2 & 0x7F)`, keep it iff `< q`, until 256 coefficients.
The result is the matrix in the **NTT domain** (FIPS 204 NTT: ζ = 1753,
`zetas[k] = ζ^{brv8(k)}`, bit-reversed output order), as in ML-DSA.

`SampleSmall(seed, η = 2)` (32-byte seed → l polys): poly `r` is ML-DSA's
RejBoundedPoly for η = 2 over `XOF("LW1/lrs/small", seed, u32_le(r))`:
each byte gives the low nibble then the high nibble; a nibble `b < 15`
yields the coefficient `2 − (b mod 5)`, larger nibbles are skipped.

### 5.2 Encodings

All bit packing is little-endian: bit `b` of the stream is bit `b mod 8` of
byte `⌊b/8⌋`; coefficient `i` of poly `p` is the `(256·p + i)`-th field.

- Public key / key image (`k` or `m` polys, coefficients in [0, q)): 23 bits
  per coefficient ⇒ 4·256·23/8 = **2944 B**. Decoders reject any length
  other than 2944 and any field `≥ q`, so the encoding is canonical.
- Response `z_i` (l polys, coefficients in (−γ, γ)): stored as `γ − 1 − z`
  in 20 bits ⇒ 15·256·20/8 = **9600 B**. Only `|z| < γ − β` is valid;
  decoders (and `verify`) reject any field encoding `|z| ≥ γ − β`, i.e. any
  field outside `[β, 2γ − β − 2]`.
- Signature: `ch0 ‖ z_0 ‖ … ‖ z_{N−1}` = 32 + 9600·N bytes (ring 11: 105 632 B).
- Challenge: 32-byte hash `ch`; the polynomial `c = SampleInBall(ch, τ)`
  is ML-DSA's SampleInBall (FIPS 204 Alg. 29) over raw `SHAKE256(ch)`
  (8 sign bytes, then byte-wise rejection), τ = 60. `ch` is itself a
  domain-separated hash output, so no extra domain is used here.

### 5.3 One-time keys (stealth)

Sender, for output index `j`, recipient bundle `(view_pk, t_spend)`:

```
(ct, ss) = KEM.encaps(view_pk, context = "LW1/output")
δ        = SampleSmall(XOF("LW1/onetime", ss, u32_le(j)) → 32 B, η_offset)   // l polys
t_o      = t_spend + A·δ
```

Recipient: `ss = KEM.decaps(ct)`, `s_o = s_spend + δ`; checks `A·s_o == t_o`.
Key image: `I = B·s_o`. Only the holder of `s_spend` can compute `I`.

### 5.4 Sign (message μ = 32-byte tx prefix hash, ring `t_0..t_{N−1}`, signer π)

```
R  = H("LW1/lrs/ring", t_0 ‖ … ‖ t_{N−1})      // ONE part: the concatenation
loop:
  y ← uniform [−(γ−1), γ−1]^{l·256}
  ch_{π+1} = H("LW1/lrs/ch", μ, R, I, u32_le(π), A·y, B·y)
  for i = π+1, …, π−1 (mod N):
      z_i ← uniform [−(γ−β−1), γ−β−1]^{l·256}
      c_i = SampleInBall(ch_i)
      ch_{i+1} = H("LW1/lrs/ch", μ, R, I, u32_le(i), A·z_i − c_i·t_i, B·z_i − c_i·I)
  z_π = y + c_π·s_o            where c_π = SampleInBall(ch_π)
  if ‖z_π‖∞ ≥ γ − β: restart
σ = (ch_0, z_0, …, z_{N−1})
```

Hash inputs are separate `enc()` parts in the order shown: μ (32 B), R
(32 B), I and every ring polynomial vector packed as in §5.2 (23-bit, from
coefficients reduced to [0, q)), `u32_le(i)` the 0-based position of the
member whose commitment is hashed. The position binds each commitment to
its slot (standard AOS form); without it, a ring containing a duplicate
member admits a rotated second valid signature (malleability).

Verify: `1 ≤ N ≤ MAX_RING_LEN`, exactly N responses, I and all t_i decode
canonically, all `‖z_i‖∞ < γ − β`; recompute the chain from `ch_0`; accept
iff it closes back to `ch_0`.

Signing randomness (implementation rule, not checkable by verifiers): `y`
and the simulated `z_i` are drawn by 20-bit rejection sampling from
`XOF("LW1/lrs/sign-rng", seed)`, `seed = H("LW1/lrs/sign-seed", s_o, μ, R,
I, 32 B OS randomness)` (hedged: a broken OS RNG neither repeats `y` across
different messages nor leaks `s_o`). Rejection probability per attempt is
`1 − ((2(γ−β)−1)/(2γ−1))^{3840} ≈ 0.83` (≈ 5.8 attempts expected) and is
independent of `s_o` and π, because `‖c·s_o‖∞ ≤ τ·η = β` always holds;
accepted `z_π` is then exactly uniform on the same set as the simulated
`z_i`.

### 5.5 Security notes (why "experimental")

- Unforgeability heuristically reduces to Module-SIS on `[A; B]` (8×15),
  which has *fewer* columns than ML-DSA-87's (8×15 incl. identity) — no
  weaker. Anonymity relies on `[A;B]·s` being indistinguishable from
  uniform (MLWE-like, secret excess 7 polys).
- **Linkability gap:** extraction yields a *relaxed* witness, so a forger
  with lattice-reduction power beyond the honest norm bound might produce a
  second key image for one output. This is the known open issue for lattice
  LRS schemes. Consequence would be a double-spend (inflation), not theft
  of others' funds. Mitigations: conservative parameters, consensus-level
  monitoring, and the chain supply audit (§7.7).
- **Sender spend-tracing (known, unfixed):** `I = B·s_spend + B·δ` and the
  sender knows δ, so for every on-chain key image they can compute
  `I − B·δ`. If one sender (or colluding senders) created ≥ 2 outputs for
  the same recipient — which happens in most payments, since amounts are
  split into several denomination outputs — they can match
  `I_a − B·δ_1 == I_b − B·δ_2` over all key images and learn exactly which
  transactions spend those outputs (and that they belong to one wallet),
  defeating ring anonymity against that sender. It does not leak `s_spend`
  (it reveals only `B·s_spend`, already covered by the MLWE assumption
  above) and does not enable theft. Making `B` output-dependent does not fix
  it: each spent output would then reveal 4 new linear equations in
  `s_spend`, and after ~3 such spends `s_spend` is determined (key
  recovery). A real fix needs a non-linear key image or a fresh per-output
  secret the sender cannot know; until then, wallets should treat payers as
  able to see when their payment is spent.
- No independent cryptanalysis has been done. The UI says so.

## 6. HyperTorus proof-of-work [novel]

Integer-only, deterministic, memory-hard. All arithmetic is wrapping
(mod 2^32 on u32, mod 2^64 on u64); all byte↔integer conversions are
little-endian. This section and the PoW KATs in `ling/tests/crypto/t_crypto.ling`
(§3) are normative; changing either is a hard fork.

```
SCRATCH   = 2^21 words of u64 (16 MiB), mask = SCRATCH − 1
STEPS     = 2^18
input     = block header bytes (nonce included)
seed      = XOF("LW1/pow/seed", input) → 64 bytes       // seed[48..64] unused
fill      :
  B = blake3::keyed_hash XOF, key = seed[0..32], message = ASCII "LW1/pow/fill"
      (12 bytes, no length prefix), first 8·SCRATCH output bytes
  scratch[i] = u64_le(B[8i .. 8i+8])                     for i in 0..SCRATCH
  for i in 1..SCRATCH (in order, in place):
      r = scratch[i]
      j = (r · i) >> 64   (u128 product)                 // uniform in [0, i)
      scratch[i] = (rotl64(scratch[i−1], 23) ^ r) + scratch[j]
state     : p = (x, y, z, w) ∈ (Z/2^32)^4, x = u32_le(seed[32..36]),
            y = u32_le(seed[36..40]), z = u32_le(seed[40..44]),
            w = u32_le(seed[44..48]);  twist = 0 (u32)
step s = 0 .. STEPS−1:
  v      = TETRA[w >> 30]                                // top 2 bits of w
  proj   = x·v[0] + y·v[1] + z·v[2] + w·v[3]  mod 2^64   // coords zero-extended to u64
  idx    = (proj ^ (proj >> 29)) & mask
  m      = scratch[idx]
  (x', cx) = x + (lo32(m) | 1)                    with carry-out cx
  y'       = (cx ? !y : y) ^ rotl32(hi32(m), x' & 31)    // Möbius: x seam reflects y
  (z', cz) = z + (rotl32(x', 13) ^ y')            with carry-out cz
  w'       = (cz ? !w : w) · 0x9E3779B1 ^ z'             // z seam reflects w
  twist   ^= cx | (cz << 1)
  p        = (x', y', z', w')
  scratch[idx] = rotl64(m, 17) ^ ((x' << 32) | w') ^ s   // u64
pow_hash = H("LW1/pow/final", input, P, T, A, B) with
  P = u32_le(x) ‖ u32_le(y) ‖ u32_le(z) ‖ u32_le(w)   (16 bytes, final p)
  T = u32_le(twist)                                    (4 bytes)
  A = u64_le(scratch[x & mask]),  B = u64_le(scratch[z & mask])  (after the walk)
```

`TETRA` = the four vertices of a regular tetrahedron inscribed in the
4-cube, `(1,1,1,−1)`, `(1,−1,−1,1)`, `(−1,1,−1,1)`, `(−1,−1,1,1)`, with
coordinate i scaled by the odd constant C_i so the projection mixes bits:
`TETRA[k][i] = sign_k[i] · C_i mod 2^64` (−c means 2^64 − c), where

```
C_0 = 0x9E3779B97F4A7C15   C_1 = 0xBF58476D1CE4E5B9
C_2 = 0x94D049BB133111EB   C_3 = 0xD6E8FEB86659FD93
```

The fill's dependency pass stops a miner from recomputing an unwritten
scratchpad word on demand from the counter-mode blake3 stream (which would
otherwise let it skip storing the 16 MiB); its read addresses depend only on
the blake3 stream, so the pass costs mostly bandwidth, not latency.
The walk's addresses depend on the evolving state, so the 2^18 reads are a
serial latency chain.

Wallet visualization (`hash_traced(input, every, …)`) samples `(p, twist)`
after step s whenever `(s + 1) % every == 0`; it does not affect pow_hash.

Valid iff `u256_le(pow_hash) · difficulty < 2^256` (difficulty is u128;
difficulty 0 never passes, 1 always passes).
Security of the chain only needs pow_hash to be unpredictable (it ends in
SHAKE256); a flaw in the walk could at worst give a mining speed-up, never
a way to steal or forge transactions.

## 7. Chain and consensus

### 7.1 Units and denominations

1 LNG = 10^8 atomic units. Every output amount must be a **denomination**:
`d · 10^e` with `d ∈ {1, 2, 5}`, `0 ≤ e ≤ 15`. Fees are any non-negative u64.

### 7.2 Emission

```
MONEY_SUPPLY   = 21_000_000 · 10^8
TAIL_REWARD    = 0.3 · 10^8
reward(gen)    = max( (MONEY_SUPPLY − gen) >> 20, TAIL_REWARD )   // gen = already generated
```

Coinbase pays `reward + fees`, decomposed greedily into denominations,
rounded *down* to the smallest denomination; the unpaid remainder is burned.

### 7.3 Blocks

```
BlockHeader = version:u8 ‖ network:u8 ‖ height:u64 ‖ prev_id:32 ‖ timestamp:u64
            ‖ merkle_root:32 ‖ nonce:u64
block_id    = H("LW1/block", header)
merkle      = binary tree, leaves H("LW1/merkle/leaf", txid), nodes H("LW1/merkle/node", L, R);
              odd node promoted unchanged
Block       = header ‖ varint(tx_count) ‖ txs   (first tx is the coinbase)
```

- Target block time 120 s. Difficulty: LWMA-1, window 60 blocks.
  Genesis difficulty chosen so one CPU thread finds a block in ~2 min.
- Timestamp > median of last 11, and ≤ local time + 2 h.
- Max block size 4 MiB. Fork choice: highest cumulative difficulty.
  Reorgs deeper than 720 blocks are refused.

### 7.4 Transactions

```
Tx = version:u8 ‖ varint(n_in) ‖ ref_height:u64 (regular txs only) ‖ inputs ‖ varint(n_out) ‖ outputs ‖ fee:u64
     ‖ varint(len) ‖ extra ‖ signatures (one LRS per input, same order)
Input  = denom:u64 ‖ varint(ring_len) ‖ ring global indices (varint, delta-encoded, sorted)
         ‖ key_image (2944 B)
Output = denom:u64 ‖ t_o (2944 B) ‖ kem_ct (1600 B) ‖ memo (AEAD, 48 B)
Coinbase = version ‖ n_in = 0 ‖ height:u64 ‖ outputs ‖ fee = 0 ‖ extra
prefix_hash = H("LW1/tx/prefix", tx without signatures)
txid        = H("LW1/tx/id", full tx)
```

Outputs of each denomination get a sequential **global index** in order of
appearance in the chain.

Validity:
1. Every output denom is a valid denomination; inputs and outputs nonempty
   (except coinbase inputs).
2. `Σ input denoms = Σ output denoms + fee`, no overflow;
   `fee ≥ size_bytes · FEE_PER_BYTE` (FEE_PER_BYTE = 10 atomic).
0. `ref_height < height` and `height − ref_height ≤ 2160`. Ring
   eligibility below is judged **at `ref_height`**, so a transaction that
   was valid when signed stays valid while it waits in the mempool.
3. Each ring has exactly `RING_SIZE` distinct members of the input's denom,
   each eligible at `ref_height` (created ≥ 10 blocks before it; coinbase:
   60), **unless** fewer than `RING_SIZE` such outputs exist at `ref_height` — then the ring must
   contain *all* eligible ones (bootstrap rule, flagged in the UI as
   reduced anonymity).
4. Key images unique within the tx and never seen on-chain.
5. Each LRS verifies over `prefix_hash` with its ring's `t_o` values.

### 7.5 Memo

`memo = AEAD(key = XOF("LW1/memo", ss, j, 32), nonce = 0, aad = t_o)` over
a 32-byte plaintext (`u64 amount_hint ‖ 24 bytes payment id / zero`).
Lets the recipient attach payment ids (used by ling-market for escrow
orders).

### 7.6 Mempool

Validated txs only; conflict on key image ⇒ keep higher fee-per-byte.
Expire after 72 h.

### 7.7 Supply audit

Because amounts are public denominations, every node tracks, per
denomination, how many outputs exist and how many key images have been
spent. **Consensus rule:** a block is invalid if it would make
`key_images[d] > outputs[d]` for any denomination `d`. Total coinbase is
fixed by §7.2. Together these bound the damage of an LRS linkability break:
an attacker could at worst spend each denomination's *existing* supply
twice over, never mint beyond it, and the node's supply panel shows the
spent/created ratio for every denomination so anomalies are visible.

## 8. P2P protocol "LWP-1"

TCP (optionally through SOCKS5, e.g. Tor at 127.0.0.1:9050). All integers
little-endian. This section is normative for the Ling runtime's P2P transport
(`../ling/src/runtime/p2p.rs`).

### 8.1 Handshake (mutually authenticated, hybrid PQ)

All three messages have fixed sizes (no length fields to parse).

```
msg1 I → R (1641 B) : "LWP1" ‖ magic:4 ‖ network:u8 ‖ nonce_i:32 ‖ kem_pk_i:1600
                      (kem_pk_i = ephemeral X25519 ‖ ML-KEM-1024 ek, §4 layout)
msg2 R → I (8867 B) : nonce_r:32 ‖ kem_ct:1600 ‖ AEAD(k_hs_r, 0^24, aad = th1, id_pk_r ‖ sig_r)
msg3 I → R (7235 B) : AEAD(k_hs_i, 0^24, aad = th2, id_pk_i ‖ sig_i)
then R → I          : the first encrypted frame (§8.2) must be ACCEPT (kind 0x07, empty)

ss    = §4 hybrid KEM secret, context "LW1/p2p/kem"   (binds ss_x25519, ss_mlkem, ct, pk)
th1   = H("LW1/p2p/th1", msg1, nonce_r, kem_ct)
k_hs  = XOF("LW1/p2p/hs-keys", ss, th1, 64)           = k_hs_r ‖ k_hs_i
sig_r = ML-DSA-87, context "p2p-hs-r", msg = th1 ‖ id_pk_r
th2   = H("LW1/p2p/th2", th1, id_pk_r, sig_r)
sig_i = ML-DSA-87, context "p2p-hs-i", msg = th2 ‖ id_pk_i
th3   = H("LW1/p2p/th3", th2, id_pk_i, sig_i)
keys  = XOF("LW1/p2p/keys", ss, th3, 4×32)            = k_i2r ‖ k_r2i ‖ dice_i2r ‖ dice_r2i
```

AEAD = XChaCha20-Poly1305; each handshake key encrypts exactly one message,
so the zero nonce is safe. Both identities travel encrypted, so a passive
observer learns neither (only the initiator, holding the ephemeral
decapsulation key, can open msg2). ACCEPT gives the initiator key
confirmation and tells it the responder admitted it; the initiator reports
the peer as connected only after ACCEPT.

The responder drops the connection on: wrong `"LWP1"`/magic/network, an
invalid ML-KEM key, an identity blob that fails to decrypt or parse, a bad
signature, `nonce_i` equal to a nonce of one of our own in-flight
handshakes, or `id_pk_i` equal to our long-term identity (self-connection).
The initiator applies the symmetric checks and, if the target is a seed,
drops any identity other than the pinned one (no TOFU re-learning). The
whole handshake has a 10 s deadline enforced across partial reads
(slowloris-safe).

Outbound connections may use an **ephemeral** identity (default) so a node
can't be tracked across IP changes.

### 8.2 Frames

```
frame  = u32_le(L) ‖ outer                                        L = |outer| = bucket + 72
outer  = DICE42(dice_dir).seal(dnonce, inner)                     = dnonce:24 ‖ ct ‖ tag:32
inner  = XChaCha20-Poly1305(k_dir, nonce = dir ‖ u64_le(ctr) ‖ 0^15,
                            aad = "LWP1/frame" ‖ dir ‖ u64_le(ctr) ‖ u32_le(L), body)
body   = kind:u8 ‖ u32_le(plen) ‖ payload ‖ random padding        |body| = bucket
dnonce = XOF("LW1/p2p/dice-nonce", nkey_dir, dir, u64_le(ctr), 24)
nkey   = H("LW1/p2p/dice-nonce-key", dice_dir)
dir    = 0x01 (I→R) or 0x02 (R→I)
bucket ∈ {1 KiB, 4 KiB, 16 KiB, 64 KiB, 256 KiB, 1 MiB, 4 MiB + 64 KiB}
```

- `ctr` starts at 0 per direction, is never transmitted, and increments by
  one per frame. The DICE-42 nonce is a PRF of it, so frames carry no
  visible counter, yet out-of-order or replayed frames fail at once.
- The body must use the *smallest* bucket that fits `5 + plen`; `L` must be
  exactly `bucket + 72` and is checked before any payload is buffered. The
  top bucket is 4 MiB + 64 KiB so a maximum-size (4 MiB) block plus a small
  header fits: max payload = 4 MiB + 64 KiB − 5.
- Any length, order, or authentication failure closes the connection (no
  ban score, because an on-path attacker could forge such failures).
- **Rekey:** before a key epoch would exceed 2^20 frames, or after 1 h, the
  sender sends REKEY (kind 0x06, empty) as the epoch's last frame and then
  ratchets `aead' ‖ dice' = XOF("LW1/p2p/rekey", dir, aead, dice, 64)`; the
  receiver ratchets when it receives REKEY. Counters keep counting across
  epochs. A receiver drops a peer that sends more than 2^20 frames in one
  epoch or keeps a key longer than 2 h + 120 s.
- **Cover traffic:** after a random 5–30 s without sending, a link sends a
  PADDING frame (random 0–3000 byte payload, so the 1 KiB or 4 KiB bucket).
  PING (8 random bytes) every 45 s is answered with PONG (echo). No frame
  at all for 120 s ⇒ the peer is dead; a frame that has started must
  complete within 120 s.

Control kinds (0x00–0x0F are reserved; applications use ≥ 0x10):

| kind | name | payload |
|---|---|---|
| 0x00 | PADDING | ignored |
| 0x01 | PING | 8 bytes |
| 0x02 | PONG | echo of the PING |
| 0x03 | GETADDR | empty; sent once by the initiator; answered only to inbound peers, once per connection |
| 0x04 | ADDR | `count:u16 ‖ count × (len:u8 ‖ addr string)`, count ≤ 64; accepted only as the answer to our GETADDR |
| 0x05 | STEM | Dandelion stem tx (opaque), rate-limited to 10/s, burst 50, per peer |
| 0x06 | REKEY | empty |
| 0x07 | ACCEPT | empty; only as the responder's first frame |
| 0x08 | ANNOUNCE | `port:u16`, the initiator's listening port (sent only when listening and not proxied) |

### 8.3 Peers, seeds, eclipse resistance

- Signed seed list `seeds.lwseeds`:
  `body = "LWSD" ‖ version:u8 (=1) ‖ network:u8 ‖ issued_at:u64 ‖ count:u16
  ‖ count × (addr_len:u8 ‖ addr ‖ identity_pk:2592)`, `file = body ‖ sig:4627`.
  It is signed by the **seed authority** ML-DSA-87 key (compiled into the
  binary) with context `"seed-list"` over `body`. count ≤ 256; parsing is
  exact (no trailing bytes). Unsigned or wrongly signed lists are ignored.
- Address forms: `a.b.c.d:port`, `[v6]:port`, `name:port`, `x.onion:port`.
  With a SOCKS5 proxy, every outbound connection goes through it and names
  are sent as SOCKS5 domain names (never resolved locally). The client
  offers username/password with random per-connection credentials, so Tor
  puts each peer on its own circuit. Onion addresses are only dialed with a
  proxy. With a proxy we listen only if `listen` is set explicitly.
- Outbound slots (8) are filled from distinct /16 (IPv4) or /32 (IPv6)
  groups; for direct connections the observed peer IP is re-checked after
  connecting. Loopback, private, and link-local addresses form one group
  *per address and port*, so a single-machine test network works; such
  addresses are never accepted from gossip in production. Each onion
  address and each DNS name is its own group, so diversity gives no
  protection there. Manual peers are always retried, outside the slot and
  group rules. Seeds are tried when the address book has fewer than 8
  entries or we have no outbound peer.
- 2 anchor peers (the longest-lived non-manual outbound peers) are
  persisted in `anchors.dat` and tried first after a restart. The address
  book (`peers.dat`, ≤ 4096 entries, ≤ 32 per group) and bans (`bans.dat`)
  also live in `data_dir`. Max 32 inbound, ≤ 2 inbound per IP (loopback is
  exempt because all Tor inbound arrives from 127.0.0.1), and ≤ 16
  concurrent inbound handshakes.
- Gossip carries only publicly routable IP addresses: never onion
  addresses, DNS names, manual peers, or seeds (those are not stored in the
  address book).
- A node compares chain tips across ≥3 groups (`Network::peer_netgroup`)
  before trusting a sync target. Misbehaving peers get ban scores. At 100
  the peer is disconnected and banned for 24 h by identity, by dialed
  address, and by IP if that IP is publicly routable. Loopback and private
  sources are not IP-banned, so one bad Tor or local peer cannot deafen the
  others.

### 8.4 Dandelion++

Per 10-minute epoch, up to two stem peers are chosen among outbound peers.
Each source (each inbound edge, and "local") is mapped to one of them for
the epoch, never back to the peer the tx came from. Locally created txs are
always stemmed; stem-received txs are stemmed with probability 0.9 and
otherwise fluffed (broadcast to all peers except the sender). Each stemmed
tx gets a 30–60 s embargo timer: unless `mark_fluffed` is called first, the
node fluffs it itself. Duplicate tx ids are ignored. With no usable stem
peer, the tx is fluffed. Blocks found by the local miner are also
stem-relayed one hop first.

## 9. Wallet storage

`wallet.lw` = `magic "LWW1" ‖ argon2id params ‖ salt:16 ‖ nonce:24 ‖
AEAD(key = Argon2id(password, salt, m=256 MiB, t=3, p=1), aad = header,
plaintext = master seed ‖ metadata)`. Keys are zeroized after use; the
wallet auto-locks after inactivity.

## 10. Escrow (ling-market integration, later phase)

ling-market runs its own wallet (view + spend) and gives each order a
payment id; the buyer pays the market's address with that id in the memo.
Escrow is **custodial** (the market holds funds until release) because
lattice multisig is not yet practical. Release/refund are ordinary
payments from the market wallet.

← Back to index