diff --git a/.specify/v1-reference/NYM_IDENTITY.md b/.specify/v1-reference/NYM_IDENTITY.md new file mode 100644 index 00000000..4ec10566 --- /dev/null +++ b/.specify/v1-reference/NYM_IDENTITY.md @@ -0,0 +1,276 @@ +# Nym Identity System (v1 Reference) + +> This document describes the pseudonym and avatar system from v1 for chat identity. + +## Overview + +In Mostro, users never see each other's real pubkeys or identities during chat. Instead, each party is represented by: + +1. **Pseudonym (Nym)**: A human-readable "adjective-noun" handle +2. **Avatar**: A colored icon derived from the pubkey + +Both are **deterministic** — the same pubkey always produces the same nym and avatar. + +## Pseudonym Generation + +### Algorithm + +```dart +String deterministicHandleFromHexKey(String hexKey) { + // Parse 32-byte hex pubkey as BigInt + final pubKeyBigInt = BigInt.parse(hexKey, radix: 16); + + // Pick adjective using modulo + final indexAdjective = pubKeyBigInt % BigInt.from(kAdjectives.length); + + // Pick noun using integer division + modulo + final indexNoun = (pubKeyBigInt ~/ BigInt.from(kAdjectives.length)) + % BigInt.from(kNouns.length); + + return '${kAdjectives[indexAdjective.toInt()]}-${kNouns[indexNoun.toInt()]}'; +} +``` + +### Word Lists + +#### Adjectives (~46 words) +Bitcoin/Nostr/privacy themed: +- shadowy, orange, nonCustodial, trustless, unbanked, atomic, magic +- hidden, incognito, anonymous, encrypted, ghostly, silent, masked +- stealthy, free, nostalgic, ephemeral, sovereign, unstoppable +- private, censorshipResistant, hush, defiant, subversive +- fiery, subzero, burning, cosmic, mighty, whispering, cyber +- rusty, nihilistic, dark, wicked, spicy, noKYC, discreet +- loose, boosted, starving, hungry, orwellian, bullish, bearish + +#### Nouns (~85 words) +Mix of Bitcoin legends, animals, places, and culture: +- wizard, pirate, zap, node, invoice, nipster, nomad, sats +- bull, bear, whale, frog, gorilla, nostrich +- halFinney, hodlonaut, satoshi, nakamoto, samurai, sparrow +- crusader, tinkerer, nostr, pleb, warrior, ecdsa +- monkey, wolf, renegade, minotaur, phoenix, dragon +- fiatjaf, roasbeef (Bitcoin/Nostr personalities) +- berlin, tokyo, buenosAires, caracas, havana, miami, prague +- amsterdam, lugano, seoul, bitcoinBeach (cities) +- carnivore, ape, honeyBadger, mempool +- Venezuelan culture: pana, chamo, catire, arepa, cachapa, tequeño, hallaca, roraima, canaima, turpial, araguaney, cunaguaro, chiguire, mamarracho, cambur + +### Examples +- `shadowy-wizard` +- `noKYC-satoshi` +- `anonymous-nostrich` +- `bullish-tequeño` + +## Avatar Generation + +### Icon Selection + +```dart +IconData pickNymIcon(String hexPubKey) { + final pubKeyBigInt = BigInt.parse(hexPubKey, radix: 16); + final index = (pubKeyBigInt % BigInt.from(kPossibleIcons.length)).toInt(); + return kPossibleIcons[index]; +} +``` + +### Icon List (~37 icons) +Material icons: +- person, star, favorite, lock, adb, bolt, casino +- visibility, language, face, thumb_up, pets, hotel_class +- anchor, school, public, construction, emoji_emotions +- whatshot, waving_hand, nights_stay, cruelty_free +- outdoor_grill, sports_motorsports, sports_football +- skateboarding, sports_martial_arts, paragliding +- face_6, south_america, face_2, tsunami +- local_shipping, flight, directions_run +- lunch_dining, directions_boat + +### Color Selection + +```dart +Color pickNymColor(String hexPubKey) { + final pubKeyBigInt = BigInt.parse(hexPubKey, radix: 16); + final hue = (pubKeyBigInt % BigInt.from(360)).toInt().toDouble(); + return HSVColor.fromAHSV(1.0, hue, 0.6, 0.8).toColor(); +} +``` + +- Hue: 0-359 (full color wheel) +- Saturation: 0.6 (moderately saturated) +- Value: 0.8 (bright but not blinding) + +### Widget Implementation (v2 corrected) + +> Note: The v1 implementation had a bug where the icon color matched the background, +> making the icon invisible. The v2 implementation fixes this with proper contrast. + +```dart +class NymAvatar extends StatelessWidget { + final String nym; // From Rust: "shadowy-wizard" + final int iconIndex; // From Rust: index into icon list + final int colorHue; // From Rust: 0-359 + + const NymAvatar({ + super.key, + required this.nym, + required this.iconIndex, + required this.colorHue, + this.size = 32.0, + }); + + final double size; + + @override + Widget build(BuildContext context) { + final icon = kPossibleIcons[iconIndex]; + final bgColor = HSVColor.fromAHSV(1.0, colorHue.toDouble(), 0.6, 0.8).toColor(); + + return CircleAvatar( + radius: size / 2, + backgroundColor: bgColor, + child: Icon( + icon, + size: size * 0.6, + color: Colors.white, // Always white for contrast + ), + ); + } +} +``` + +## Privacy Considerations + +1. **No real identity exposure**: Users never see actual pubkeys in the UI +2. **Deterministic but not reversible**: Given a nym, you cannot derive the pubkey +3. **Collision possible**: With ~46 adjectives × ~85 nouns = ~3,910 combinations, collisions can occur across many users. This is acceptable since nyms are only used for visual distinction within a single trade, not for authentication. +4. **Trade-key-based**: Nyms are derived from trade keys, not identity keys. In privacy mode, each trade uses a different key, so the same user gets different nyms across trades. + +## v2 Implementation Notes + +### Rust Side (All Deterministic Logic) + +All nym generation **must** be in Rust for cross-platform consistency: + +```rust +// rust/src/api/nym.rs + +#[frb] +pub struct NymIdentity { + pub pseudonym: String, // "shadowy-wizard" + pub icon_index: u8, // Index into icon list (0-36) + pub color_hue: u16, // HSV hue (0-359) +} + +#[frb] +pub fn generate_nym(pubkey_hex: String) -> NymIdentity { + let pubkey_bigint = BigUint::parse_bytes(pubkey_hex.as_bytes(), 16) + .unwrap_or_default(); + + let adj_index = (&pubkey_bigint % ADJECTIVES.len()).to_usize().unwrap(); + let noun_index = ((&pubkey_bigint / ADJECTIVES.len()) % NOUNS.len()) + .to_usize().unwrap(); + let icon_index = (&pubkey_bigint % ICONS.len()).to_u8().unwrap(); + let color_hue = (&pubkey_bigint % 360u32).to_u16().unwrap(); + + NymIdentity { + pseudonym: format!("{}-{}", ADJECTIVES[adj_index], NOUNS[noun_index]), + icon_index, + color_hue, + } +} +``` + +Exposed via flutter_rust_bridge: +- `generateNym(pubkeyHex: String) -> NymIdentity` + +### Dart Side (Rendering Only) + +Dart only handles widget rendering — receives all values from Rust: + +```dart +// lib/src/widgets/nym_avatar.dart + +class NymAvatar extends StatelessWidget { + final NymIdentity identity; // From Rust + final double size; + + @override + Widget build(BuildContext context) { + final icon = kPossibleIcons[identity.iconIndex]; + final bgColor = HSVColor.fromAHSV( + 1.0, + identity.colorHue.toDouble(), + 0.6, + 0.8, + ).toColor(); + + return CircleAvatar( + radius: size / 2, + backgroundColor: bgColor, + child: Icon(icon, size: size * 0.6, color: Colors.white), + ); + } +} +``` + +### Why All Logic in Rust? + +1. **Cross-platform consistency**: Same pubkey produces identical nym on iOS, Android, Web, Desktop +2. **Single source of truth**: Word lists and algorithm defined once +3. **Testable**: Rust unit tests verify determinism +4. **No divergence risk**: Dart and Rust can't produce different results + +### Testing + +#### Rust Unit Tests +```rust +#[test] +fn test_nym_determinism() { + let pubkey = "a1b2c3..."; // Known test pubkey + let nym1 = generate_nym(pubkey.to_string()); + let nym2 = generate_nym(pubkey.to_string()); + assert_eq!(nym1.pseudonym, nym2.pseudonym); + assert_eq!(nym1.icon_index, nym2.icon_index); + assert_eq!(nym1.color_hue, nym2.color_hue); +} + +#[test] +fn test_known_nym_values() { + // Regression test with known pubkey -> known nym + let pubkey = "0000...0001"; + let nym = generate_nym(pubkey.to_string()); + assert_eq!(nym.pseudonym, "shadowy-wizard"); // Expected value + assert_eq!(nym.icon_index, 1); + assert_eq!(nym.color_hue, 1); +} +``` + +#### Flutter Widget Tests +```dart +testWidgets('NymAvatar renders correctly', (tester) async { + final identity = NymIdentity( + pseudonym: 'test-nym', + iconIndex: 0, + colorHue: 180, + ); + + await tester.pumpWidget( + MaterialApp(home: NymAvatar(identity: identity, size: 48)), + ); + + expect(find.byType(CircleAvatar), findsOneWidget); + expect(find.byType(Icon), findsOneWidget); +}); +``` + +#### Visual Regression (Golden Tests) +```dart +testWidgets('NymAvatar golden test', (tester) async { + // Test multiple hues and icons for visual consistency + await expectLater( + find.byType(NymAvatar), + matchesGoldenFile('goldens/nym_avatar_hue_0.png'), + ); +}); +``` diff --git a/specs/001-mostro-p2p-client/spec.md b/specs/001-mostro-p2p-client/spec.md index f2f0482e..69baf373 100644 --- a/specs/001-mostro-p2p-client/spec.md +++ b/specs/001-mostro-p2p-client/spec.md @@ -157,6 +157,8 @@ During an active trade, both parties can exchange encrypted messages to coordina 5. **Given** a user in an active trade, **When** they attach a file (image, document, or video up to 25MB), **Then** the file is encrypted, uploaded to a decentralized storage server, and the counterparty can download and view it. 6. **Given** a user receives an image attachment, **When** it downloads, **Then** an inline preview is shown automatically. Non-image files show a download button. 7. **Given** a user closes and reopens the app during an active trade, **When** they view the chat history, **Then** previously received messages are available (loaded from encrypted local storage) without requiring re-download from relays. +8. **Given** a trade becomes active, **When** the chat displays messages, **Then** each party is represented by a deterministic pseudonym and avatar derived from their trade pubkey (not their real identity), ensuring visual distinction without revealing identity. +9. **Given** a user views the chat, **When** looking at their counterparty's messages, **Then** they see a pseudonym in the format "adjective-noun" (e.g., "shadowy-wizard", "noKYC-satoshi") and a colored icon avatar — both derived deterministically from the counterparty's pubkey so they remain consistent throughout the trade. --- @@ -336,6 +338,9 @@ During an active trade, either party can request a cooperative cancellation. The - **FR-009**: A visual trade progress indicator MUST show the current step, completed steps, and remaining steps at all times during an active trade. - **FR-010**: The progress indicator MUST differentiate between buyer flow steps and seller flow steps. - **FR-011**: Users MUST be able to exchange encrypted peer-to-peer messages during an active trade. +- **FR-011a**: Each party in a trade chat MUST be displayed with a deterministic pseudonym (format: "adjective-noun") and avatar (colored icon) derived from their trade pubkey, ensuring consistent visual identification without revealing real identity. +- **FR-011b**: Pseudonyms MUST be generated deterministically from the pubkey using a predefined list of adjectives and nouns, allowing the same pubkey to always produce the same pseudonym. +- **FR-011c**: Avatars MUST consist of a deterministically selected icon and color derived from the pubkey, displayed as a colored circle with the icon inside. - **FR-012**: Users MUST be able to initiate a dispute during an active trade. - **FR-013**: Users MUST be able to submit text evidence during a dispute. - **FR-014**: The system MUST display admin messages and dispute resolution outcomes. @@ -388,6 +393,8 @@ During an active trade, either party can request a cooperative cancellation. The - **Identity**: The user's cryptographic identity — includes public/private keypair and mnemonic backup. One identity per app installation. Supports two privacy modes: standard mode (identity key signs the encryption seal, enabling reputation linking across trades) and privacy mode (trade key signs the seal, preventing cross-trade reputation linking). Key derivation follows a deterministic hierarchical path from the master mnemonic (identity key at index 0, trade keys at index ≥ 1). - **Order**: A buy or sell offer on the Mostro network — includes type (buy/sell), amount (fixed or min/max range), price, fiat currency, payment method, status, and creator identity. Orders transition through 15 protocol-defined states: pending, waitingBuyerInvoice, waitingPayment, active, fiatSent, settledHoldInvoice, success, paymentFailed, canceled, cooperativelyCanceled, dispute, settledByAdmin, completedByAdmin, canceledByAdmin, expired. - **Trade**: An active transaction between a buyer and seller — links an order, both parties' identities, the current progress step, and associated messages. Only one trade active at a time (v2.0 scope). +- **Nym (Pseudonym)**: A human-readable identifier for a trade participant, deterministically generated from their trade pubkey. Format: "adjective-noun" (e.g., "shadowy-wizard", "noKYC-satoshi"). Uses predefined word lists with Bitcoin/Nostr/privacy themes. The same pubkey always produces the same pseudonym, allowing consistent identification within and across trades without revealing real identity. +- **Nym Avatar**: A visual representation of a trade participant, consisting of a colored circle with an icon inside. Both the icon (from a predefined list of ~37 icons) and the color (HSV-derived from pubkey) are deterministically selected from the trade pubkey. Displayed alongside the pseudonym in chat UI. - **Message**: An encrypted communication between two parties (or between a party and admin during disputes). Uses three-layer NIP-59 encryption (Rumor inside Seal inside Gift Wrap). P2P chat messages use a shared key derived via ECDH between trade keys; admin/dispute messages use the trade key directly. Messages are stored encrypted on disk and decrypted only in active memory. Includes sender, recipient, content, timestamp, and read status. - **Relay**: A connection endpoint the app communicates through — includes URL, connection status, health metrics, and source classification (default, Mostro-discovered, user-added). Users can add, remove, and blacklist relays. Blacklisted relays are not re-added even if the daemon announces them. - **Dispute**: An exception flow on an active trade — includes initiator, evidence submissions, admin communications, and resolution outcome. Uses a separate chat channel from P2P chat, encrypted with the trade key.