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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions lib/core/app_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import 'package:mostro/features/rate/screens/rate_counterpart_screen.dart';
import 'package:mostro/features/about/screens/about_screen.dart';
import 'package:mostro/features/about/screens/node_technical_data_screen.dart';
import 'package:mostro/features/settings/screens/nwc_wallet_screen.dart';
import 'package:mostro/features/reputation/screens/import_reputation_screen.dart';
import 'package:mostro/features/reputation/screens/reputation_screen.dart';
import 'package:mostro/features/settings/screens/log_report_screen.dart';
import 'package:mostro/features/settings/screens/notification_settings_screen.dart';
import 'package:mostro/features/settings/screens/settings_screen.dart';
Expand Down Expand Up @@ -58,6 +60,8 @@ abstract final class AppRoute {
static const disputeDetails = '/dispute_details/:disputeId';
static const notificationSettings = '/notification_settings';
static const logs = '/logs';
static const importReputation = '/import_reputation';
static const reputation = '/reputation';
static const disputeChat = '/dispute_chat/:disputeId';

/// Embedded Cashu wallet. Only reachable from Settings when the active node
Expand Down Expand Up @@ -249,6 +253,14 @@ final GoRouter appRouter = GoRouter(
builder: (_, __) => const NotificationSettingsScreen(),
),
GoRoute(path: AppRoute.logs, builder: (_, __) => const LogReportScreen()),
GoRoute(
path: AppRoute.importReputation,
builder: (_, __) => const ImportReputationScreen(),
),
GoRoute(
path: AppRoute.reputation,
builder: (_, __) => const ReputationScreen(),
),
GoRoute(
path: AppRoute.disputeChat,
builder:
Expand All @@ -261,9 +273,9 @@ final GoRouter appRouter = GoRouter(
),
GoRoute(
path: AppRoute.lockEscrow,
builder: (context, state) => LockEscrowScreen(
orderId: state.pathParameters['orderId']!,
),
builder:
(context, state) =>
LockEscrowScreen(orderId: state.pathParameters['orderId']!),
),
],
);
14 changes: 14 additions & 0 deletions lib/core/automation/automation_ids.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ class AutomationIds {
// App bar / navigation
static const String appBarDrawer = 'appbar.drawer';
static const String appBarBack = 'appbar.back';

// Reputation portability (#674)
static const String settingsReputation = 'settings.reputation';
static const String reputationOpenImport = 'reputation.open_import';
static const String reputationExport = 'reputation.export';
static const String reputationExportConfirm = 'reputation.export.confirm';
static const String reputationExported = 'reputation.export.result';
static const String reputationRebindIdentity = 'reputation.rebind.identity';
static const String reputationRebindSign = 'reputation.rebind.sign';
static const String reputationImportOpenBot = 'reputation.import.open_bot';
static const String reputationImportInput = 'reputation.import.input';
static const String reputationImportCheck = 'reputation.import.check';
static const String reputationImportFigures = 'reputation.import.figures';
static const String reputationImportConfirm = 'reputation.import.confirm';
static const String navOrderBook = 'nav.order_book';
static const String navTrades = 'nav.trades';
static const String navChat = 'nav.chat';
Expand Down
37 changes: 37 additions & 0 deletions lib/features/reputation/lnp2pbot.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import 'dart:convert';

/// lnp2pBot issues reputation attestations through Telegram
/// (MostroP2P/protocol reputation_attestation.md, "Telegram hand-off").
const lnp2pbotUsername = 'lnp2pbot';

final _hexKey = RegExp(r'^[0-9a-f]{64}$');

/// The link that asks lnp2pBot to attest the user's reputation for
/// [identityHex], or null for a value that is not a hex key. The identity
/// travels as 43 characters of unpadded base64url so the start parameter
/// fits Telegram's 64-character cap.
Uri? lnp2pbotExportUri(String identityHex) {
final hex = identityHex.toLowerCase();
if (!_hexKey.hasMatch(hex)) return null;
final bytes = [
for (var i = 0; i < hex.length; i += 2)
int.parse(hex.substring(i, i + 2), radix: 16),
];
final encoded = base64Url.encode(bytes).replaceAll('=', '');
return Uri.parse('https://t.me/$lnp2pbotUsername?start=rep_$encoded');
}

/// The attestation event JSON inside pasted text: lnp2pBot sends it as a
/// message of its own, but a paste may carry surrounding text or whitespace.
/// Returns null when no JSON object is found.
String? extractAttestationJson(String text) {
final start = text.indexOf('{');
final end = text.lastIndexOf('}');
if (start < 0 || end <= start) return null;
final candidate = text.substring(start, end + 1);
try {
return jsonDecode(candidate) is Map<String, dynamic> ? candidate : null;
} on FormatException {
return null;
}
}
44 changes: 44 additions & 0 deletions lib/features/reputation/npub.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/// A 64-character hex public key as an `npub` (NIP-19 bech32), for showing
/// the user which identity they confirm. Returns null for anything else.
String? hexToNpub(String hex) {
if (!RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(hex)) return null;
final bytes = [
for (var i = 0; i < 64; i += 2)
int.parse(hex.substring(i, i + 2), radix: 16),
];
// Regroup 8-bit bytes into 5-bit words.
final words = <int>[];
var acc = 0, bits = 0;
for (final b in bytes) {
acc = (acc << 8) | b;
bits += 8;
Comment thread
grunch marked this conversation as resolved.
while (bits >= 5) {
bits -= 5;
words.add((acc >> bits) & 31);
}
}
if (bits > 0) words.add((acc << (5 - bits)) & 31);
const hrp = 'npub';
const charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
int polymod(List<int> values) {
const gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
var chk = 1;
for (final v in values) {
final top = chk >> 25;
chk = ((chk & 0x1ffffff) << 5) ^ v;
for (var i = 0; i < 5; i++) {
if ((top >> i) & 1 == 1) chk ^= gen[i];
}
}
return chk;
}

final expanded = [
for (final c in hrp.codeUnits) c >> 5,
0,
for (final c in hrp.codeUnits) c & 31,
];
final mod = polymod([...expanded, ...words, 0, 0, 0, 0, 0, 0]) ^ 1;
final checksum = [for (var i = 0; i < 6; i++) (mod >> (5 * (5 - i))) & 31];
return '${hrp}1${[...words, ...checksum].map((w) => charset[w]).join()}';
}
39 changes: 39 additions & 0 deletions lib/features/reputation/reputation_api.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'package:mostro/src/rust/api/identity.dart' as identity_api;
import 'package:mostro/src/rust/api/reputation_transfer.dart' as rust;

/// The reputation portability calls the screens make, behind one seam so the
/// widget tests never reach the Rust core.
class ReputationApi {
const ReputationApi();

rust.ReputationSupportInfo support() => rust.getReputationSupport();

rust.ReputationAttestationInfo parse(String json) =>
rust.parseReputationAttestation(json: json);

Future<rust.ReputationAttestationInfo> import(String json) =>
rust.importReputation(attestationJson: json);

Future<rust.ReputationAttestationInfo> export({
String? destination,
String? rebind,
}) => rust.exportReputation(destination: destination, rebind: rebind);

Future<String> signRebind({
required String issuer,
required String newIdentity,
}) => rust.signReputationRebind(issuer: issuer, newIdentity: newIdentity);

Future<rust.ReputationAttestationInfo?> pending() =>
rust.getPendingReputationAttestation();

/// The user's identity public key, hex, or null without one.
Future<String?> identity() async =>
(await identity_api.getIdentity())?.publicKey;
}

final reputationApiProvider = Provider<ReputationApi>(
(ref) => const ReputationApi(),
);
34 changes: 34 additions & 0 deletions lib/features/reputation/reputation_errors.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import 'package:mostro/core/daemon_errors.dart';
import 'package:mostro/l10n/app_localizations.dart';

/// User-facing text for a failed reputation export or import: the markers
/// `api::reputation_transfer` raises, then the shared daemon markers
/// (`NoDaemonResponse`, node capability), then [fallback].
String localizedReputationError(
AppLocalizations l10n,
Object error, {
String? fallback,
}) {
final raw = error.toString();
final byMarker = <String, String>{
'PrivacyModeEnabled': l10n.reputationIdentityRequired,
'ReputationIdentityRequired': l10n.reputationIdentityRequired,
'ReputationExportUnsupported': l10n.reputationNodeDoesNotExport,
'ReputationImportUnsupported': l10n.reputationNodeDoesNotImport,
'NotEligibleForReputationExport': l10n.reputationNotEligible,
'ReputationBoundToOtherIdentity': l10n.reputationBoundToOtherIdentity,
'InvalidReputationRebind': l10n.reputationInvalidRebind,
'ExpiredReputationAttestation': l10n.reputationExpiredAttestation,
'UntrustedReputationIssuer': l10n.reputationUntrustedIssuer,
'ReputationIdentityMismatch': l10n.reputationIdentityMismatch,
'ReputationAlreadyImported': l10n.reputationAlreadyImported,
};
for (final entry in byMarker.entries) {
if (raw.contains(entry.key)) return entry.value;
}
return localizedDaemonError(
l10n,
error,
fallback: fallback ?? l10n.reputationInvalidAttestation,
);
}
Loading
Loading