diff --git a/puls3_flutter/README.md b/puls3_flutter/README.md index e669ec54..02db9c86 100644 --- a/puls3_flutter/README.md +++ b/puls3_flutter/README.md @@ -13,3 +13,54 @@ A great starting point for learning Serverpod is our documentation site at: To run the project, first make sure that the server is running, then do: flutter run + +## Wallet (#25): Freighter and the mock wallet + +```text +UI (WalletPanel, WalletChip, deploy/hire flows) + -> WalletController one WalletStatus: disconnected, connecting, connected, + signing, signed, rejected, wrongNetwork, error + -> WalletPort connect, disconnect, address, network, signTransaction, + signAuthEntry + |- FreighterWallet real extension, via web/freighter_bridge.js + |- MockWallet tests and demo console + '- (another wallet: implement WalletPort) +``` + +- The app only handles the public address, the network, the unsigned XDR + and the signed XDR. It never sees or stores a secret key. +- `signTransaction` sends the server-prepared XDR to the wallet unchanged and + returns the signed XDR; the server submits it. Before the prompt it refuses + a wallet not on Stellar Testnet, fee-bump envelopes, trailing bytes and + transactions for another account (#69). Mainnet is not supported. +- `signAuthEntry` signs a Soroban authorization entry for the connected + account (non-zero expiration, address credentials only). +- A wallet prompt that never answers times out (connect: 2 minutes), so a + blocked or ignored popup never traps the user. + +Freighter is the default on the web. To use the mock wallet instead: + + flutter run -d chrome --dart-define=WALLET=mock + +### Testing with Freighter + +1. Install the [Freighter](https://www.freighter.app/) extension in Chrome, + create or import a **test** account and choose **Testnet** in its settings. +2. Run `flutter run -d chrome` (or build into the server, below). +3. Tap **Connect wallet** -> **Connect Freighter** and approve in Freighter. + The sheet shows the short address and a Testnet badge. +4. Deploy an agent from Studio: Freighter shows the signing prompt. Until the + relay (#96) and deploy endpoint (#18) exist the transaction is a harmless + demo envelope that is never submitted, and the flow is labelled as a demo. +5. Also check: reject the connection, reject the signature, switch Freighter + to another network (Wrong network), Disconnect, and a browser without + Freighter (Freighter not found, with an install link). + +Build into the Serverpod web folder (served at `localhost:8082/app`): + + flutter build web --base-href /app/ --wasm -o /puls3_server/web/app + +Tests: + + flutter analyze + flutter test diff --git a/puls3_flutter/lib/main.dart b/puls3_flutter/lib/main.dart index ab472257..c44a1524 100644 --- a/puls3_flutter/lib/main.dart +++ b/puls3_flutter/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:puls3_client/puls3_client.dart'; @@ -7,7 +8,26 @@ import 'src/data/agent_repository.dart'; import 'src/data/app_config.dart'; import 'src/data/fallback_agent_repository.dart'; import 'src/data/server_agent_repository.dart'; +import 'src/wallet/freighter/create_freighter_bridge.dart'; +import 'src/wallet/freighter/freighter_wallet.dart'; import 'src/wallet/mock_wallet.dart'; +import 'src/wallet/wallet_port.dart'; + +/// Which wallet the app signs with: `freighter` (default on the web) or +/// `mock` for the demo console (#32): `--dart-define=WALLET=mock`. +const _walletKind = String.fromEnvironment( + 'WALLET', + defaultValue: kIsWeb ? 'freighter' : 'mock', +); + +WalletPort _createWallet() => switch (_walletKind) { + 'freighter' => FreighterWallet(createFreighterBridge()), + 'mock' => MockWallet(), + // A typo must not silently ship the mock wallet. + _ => throw StateError( + 'Unknown WALLET "$_walletKind": use "freighter" or "mock".', + ), +}; /// Starts the demo shell and verifies the generated Serverpod client connection. Future main() async { @@ -25,7 +45,7 @@ Future main() async { ServerAgentRepository(() => client.agent.list()), AssetAgentRepository(), ), - wallet: MockWallet(), + wallet: _createWallet(), healthCheck: client.health.check().then((health) => health.version), ), ); diff --git a/puls3_flutter/lib/src/deploy/deploy_flow_controller.dart b/puls3_flutter/lib/src/deploy/deploy_flow_controller.dart index 0d2b5d28..38561cf7 100644 --- a/puls3_flutter/lib/src/deploy/deploy_flow_controller.dart +++ b/puls3_flutter/lib/src/deploy/deploy_flow_controller.dart @@ -202,7 +202,11 @@ class DeployFlowController extends ChangeNotifier { _signed ?? await _attempt(DeployStep.awaitingSignature, () async { // The transaction names the account it was prepared for. If the - // wallet switched account since, it cannot sign it. + // wallet switched account since, it cannot sign it. After a + // network switch the wallet forgot its session: connect again. + if (_wallet.address == null) { + await _waitForWallet(_wallet.connect()); + } if (_wallet.address != _builder) throw const _AccountChanged(); return _waitForWallet( _wallet.signTransaction(prepared.unsignedTransaction), @@ -296,7 +300,17 @@ class DeployFlowController extends ChangeNotifier { kind: DeployErrorKind.signatureRejected, step: step, ), - WalletUnavailable() => DeployError( + WalletAccountChanged() => DeployError( + kind: DeployErrorKind.accountChanged, + step: step, + ), + // The wallet adapter refused the prepared transaction before signing. + WalletInvalidPayload(:final reason) => DeployError( + kind: DeployErrorKind.invalidResponse, + step: step, + detail: reason, + ), + WalletNotInstalled() || WalletUnavailable() => DeployError( kind: DeployErrorKind.walletUnavailable, step: step, ), @@ -315,7 +329,7 @@ class DeployFlowController extends ChangeNotifier { detail: message, transactionHash: transactionHash, ), - _WalletTimeout() => DeployError( + WalletTimedOut() || _WalletTimeout() => DeployError( kind: DeployErrorKind.walletTimeout, step: step, ), diff --git a/puls3_flutter/lib/src/deploy/fake_deploy_gateway.dart b/puls3_flutter/lib/src/deploy/fake_deploy_gateway.dart index 8cd11044..2db0df1d 100644 --- a/puls3_flutter/lib/src/deploy/fake_deploy_gateway.dart +++ b/puls3_flutter/lib/src/deploy/fake_deploy_gateway.dart @@ -1,5 +1,6 @@ import '../domain/agent_draft.dart'; import '../domain/stellar_format.dart'; +import '../wallet/demo_envelope.dart'; import 'deploy_gateway.dart'; /// Demo [DeployGateway]: fake ids after short delays, no network. It stands @@ -31,7 +32,7 @@ class FakeDeployGateway implements DeployGateway { await Future.delayed(prepareDelay); return PreparedDeploy( preparationId: _ids.txHash().substring(0, 16), - unsignedTransaction: 'AAAA-fake-register_full-envelope', + unsignedTransaction: demoEnvelope(builder, note: 'deploy demo'), ); } diff --git a/puls3_flutter/lib/src/screens/app_shell.dart b/puls3_flutter/lib/src/screens/app_shell.dart index 841d3e19..5c9bcbca 100644 --- a/puls3_flutter/lib/src/screens/app_shell.dart +++ b/puls3_flutter/lib/src/screens/app_shell.dart @@ -5,6 +5,7 @@ import '../state/app_scope.dart'; import '../theme/breakpoints.dart'; import '../theme/puls3_theme.dart'; import '../ui/organisms/top_bar.dart'; +import 'wallet_sheet.dart'; /// Container for every non-landing screen: wires the [TopBar] to the router /// and the wallet. On phones the destinations move to a bottom navigation @@ -45,7 +46,7 @@ class AppShell extends StatelessWidget { onNavigate: (path) => context.go(path), walletAddress: wallet.address, isWalletConnecting: wallet.isConnecting, - onConnectWallet: wallet.connect, + onConnectWallet: () => showWalletSheet(context), ), ), ), diff --git a/puls3_flutter/lib/src/screens/hire_sheet.dart b/puls3_flutter/lib/src/screens/hire_sheet.dart index 23e15a4d..8b91ce06 100644 --- a/puls3_flutter/lib/src/screens/hire_sheet.dart +++ b/puls3_flutter/lib/src/screens/hire_sheet.dart @@ -5,6 +5,8 @@ import '../domain/agent.dart'; import '../state/app_scope.dart'; import '../theme/puls3_theme.dart'; import '../ui/organisms/hire_payment_view.dart'; +import '../wallet/demo_envelope.dart'; +import '../wallet/wallet_port.dart'; /// Opens the hire and pay flow for [agent]. Future showHireSheet(BuildContext context, Agent agent) { @@ -42,9 +44,10 @@ class _HireSheetState extends State { } // Demo signing: nothing is submitted. The real flow calls the server // relay (createHire, prepareFund, submitEscrowCall) once it exists. + // Demo signing: a real, harmless Testnet envelope for the payer so a + // real wallet shows its prompt; it is never submitted. await wallet.signTransaction( - 'mock-usdc-payment:${widget.agent.stellarAddress}:' - '${widget.agent.priceUsdcStroops}', + demoEnvelope(wallet.address!, note: 'hire ${widget.agent.id}'), ); if (!mounted) return; setState(() => _phase = HirePhase.confirmed); @@ -52,9 +55,11 @@ class _HireSheetState extends State { if (!mounted) return; setState(() { _phase = HirePhase.error; - _errorMessage = e is Exception - ? e.toString().replaceFirst('Exception: ', '') - : 'Payment could not be completed'; + _errorMessage = switch (e) { + WalletException() => walletErrorMessage(e), + Exception() => e.toString().replaceFirst('Exception: ', ''), + _ => 'Payment could not be completed', + }; }); } } @@ -92,3 +97,20 @@ class _HireSheetState extends State { ); } } + +/// A human message for a wallet failure. In every case nothing was signed, +/// so no funds moved. +String walletErrorMessage(WalletException e) => switch (e) { + WalletSignatureRejected() => + 'You cancelled the payment in your wallet. No funds moved.', + WalletWrongNetwork() => + 'Switch your wallet to Stellar Testnet, then try again.', + WalletNotInstalled() => 'No wallet found. Install Freighter, then try again.', + WalletUnavailable() => 'Open or unlock your wallet, then try again.', + WalletTimedOut() => + 'Your wallet did not answer. Open it, then try again. No funds moved.', + WalletAccountChanged() => + 'Your wallet account changed. Reconnect, then try again.', + WalletInvalidPayload() => + 'This payment could not be signed safely. No funds moved.', +}; diff --git a/puls3_flutter/lib/src/screens/wallet_sheet.dart b/puls3_flutter/lib/src/screens/wallet_sheet.dart new file mode 100644 index 00000000..fcf5b6bb --- /dev/null +++ b/puls3_flutter/lib/src/screens/wallet_sheet.dart @@ -0,0 +1,64 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../state/app_scope.dart'; +import '../theme/breakpoints.dart'; +import '../theme/puls3_theme.dart'; +import '../ui/organisms/wallet_panel.dart'; + +/// Opens the wallet sheet (S09). It connects, shows the connected account +/// with Disconnect, and keeps every failure as a recoverable state. +Future showWalletSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + useSafeArea: true, + isScrollControlled: true, + builder: (_) => const WalletSheet(), + ); +} + +/// Container for [WalletPanel]: listens to the app's wallet controller. +class WalletSheet extends StatelessWidget { + const WalletSheet({super.key}); + + @override + Widget build(BuildContext context) { + final wallet = AppScope.of(context).wallet; + final narrow = + MediaQuery.sizeOf(context).width < Puls3Breakpoints.narrowGutter; + final gutter = narrow ? Puls3Spacing.md : Puls3Spacing.lg; + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB(gutter, 0, gutter, Puls3Spacing.lg), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: ListenableBuilder( + listenable: wallet, + builder: (context, _) { + final installUrl = wallet.installUrl; + return WalletPanel( + status: wallet.status, + walletName: wallet.walletName, + address: wallet.address, + isOnTestnet: wallet.isOnTestnet, + error: wallet.lastError, + onConnect: () => unawaited(wallet.tryConnect()), + onDisconnect: () => unawaited(wallet.disconnect()), + onInstall: installUrl == null + ? null + : () => unawaited( + launchUrl( + installUrl, + mode: LaunchMode.externalApplication, + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/puls3_flutter/lib/src/state/wallet_controller.dart b/puls3_flutter/lib/src/state/wallet_controller.dart index 319fa87c..60bf7bea 100644 --- a/puls3_flutter/lib/src/state/wallet_controller.dart +++ b/puls3_flutter/lib/src/state/wallet_controller.dart @@ -2,33 +2,177 @@ import 'package:flutter/foundation.dart'; import '../wallet/wallet_port.dart'; -/// Observable wrapper around a [WalletPort] for the UI. +/// The wallet as the UI sees it: one explicit state, never a mix of flags. +enum WalletStatus { + disconnected, + connecting, + connected, + + /// The wallet is showing a signing prompt. + signing, + + /// Connected, and the last transaction was signed. + signed, + + /// The user declined the connection or the signature in the wallet. + rejected, + + /// The wallet is not on Stellar Testnet; nothing can be signed. + wrongNetwork, + + /// Any other failure (not installed, locked, account changed, refused + /// payload): see [WalletController.lastError]. + error, +} + +/// Observable wrapper around a [WalletPort]: the container that owns the +/// connection state (#25). Presentational widgets get its values through +/// their constructors. It knows nothing about deploys, hires or escrow. class WalletController extends ChangeNotifier { - WalletController(this._wallet); + WalletController( + this._wallet, { + this.connectTimeout = const Duration(minutes: 2), + }); final WalletPort _wallet; - bool _connecting = false; + /// Upper bound for the connect prompt, so a popup that is ignored, + /// blocked or stuck never leaves a flow waiting forever. Null disables it. + final Duration? connectTimeout; + + /// Bumped by [disconnect], so a connect that finishes afterwards is + /// dropped instead of reconnecting behind the user's back. + int _session = 0; + + /// [WalletStatus.connecting] or [WalletStatus.signing] while the wallet + /// is busy; null otherwise. + WalletStatus? _busy; + bool _signed = false; + WalletException? _lastError; + + String get walletName => _wallet.name; + Uri? get installUrl => _wallet.installUrl; String? get address => _wallet.address; + String? get network => _wallet.network; bool get isConnected => _wallet.address != null; - bool get isConnecting => _connecting; + bool get isConnecting => _busy == WalletStatus.connecting; + bool get isOnTestnet => _wallet.network == stellarTestnetPassphrase; + + /// Why the last connection or signature failed, until the next attempt. + WalletException? get lastError => _lastError; + + WalletStatus get status { + final busy = _busy; + if (busy != null) return busy; + final error = _lastError; + if (error != null) { + return switch (error) { + WalletSignatureRejected() => WalletStatus.rejected, + WalletWrongNetwork() => WalletStatus.wrongNetwork, + _ => WalletStatus.error, + }; + } + if (isConnected) { + return _signed ? WalletStatus.signed : WalletStatus.connected; + } + return WalletStatus.disconnected; + } + /// Connects and returns the address. Throws a [WalletException] on + /// failure, for flows that map it themselves (deploy, hire). UI buttons + /// use [tryConnect] instead. Future connect() async { final existing = _wallet.address; if (existing != null) return existing; - _connecting = true; + final session = _session; + _busy = WalletStatus.connecting; + _lastError = null; + _signed = false; notifyListeners(); try { - return await _wallet.connect(); + final timeout = connectTimeout; + final attempt = _wallet.connect(); + final address = await (timeout == null + ? attempt + : attempt.timeout( + timeout, + onTimeout: () => throw const WalletTimedOut(), + )); + if (session != _session) { + // Disconnected while the prompt was open: do not stay connected. + await _wallet.disconnect(); + throw const WalletSignatureRejected(); + } + return address; + } on WalletException catch (e) { + if (session == _session) _lastError = e; + rethrow; } finally { - _connecting = false; + if (session == _session) _busy = null; notifyListeners(); } } - Future signTransaction(String unsignedXdr) async { - final hash = await _wallet.signTransaction(unsignedXdr); + /// Connects from a button: a failure becomes [lastError] (a recoverable + /// state), never an unhandled exception. Returns whether it connected. + Future tryConnect() async { + if (_busy != null) return false; + _lastError = null; + try { + await connect(); + return true; + } on WalletException { + return false; + } + } + + void clearError() { + if (_lastError == null) return; + _lastError = null; + notifyListeners(); + } + + Future disconnect() async { + _session++; + _busy = null; + await _wallet.disconnect(); + _lastError = null; + _signed = false; notifyListeners(); - return hash; + } + + /// Signs a server-prepared transaction unchanged; returns the signed XDR. + /// + /// Refuses before any prompt when the wallet is not on Testnet. If the + /// wallet switched network or account, the session is forgotten so the + /// next attempt connects again. + Future signTransaction(String unsignedXdr) => + _sign(() => _wallet.signTransaction(unsignedXdr)); + + /// Signs a Soroban authorization entry; returns the signed entry XDR. + Future signAuthEntry(String entryXdr) => + _sign(() => _wallet.signAuthEntry(entryXdr)); + + Future _sign(Future Function() sign) async { + _lastError = null; + _signed = false; + try { + if (!isConnected) throw const WalletUnavailable(); + if (!isOnTestnet) throw const WalletWrongNetwork(); + _busy = WalletStatus.signing; + notifyListeners(); + final signed = await sign(); + _signed = true; + return signed; + } on WalletException catch (e) { + _lastError = e; + if (e is WalletWrongNetwork || e is WalletAccountChanged) { + await _wallet.disconnect(); + } + rethrow; + } finally { + _busy = null; + notifyListeners(); + } } } diff --git a/puls3_flutter/lib/src/ui/molecules/wallet_chip.dart b/puls3_flutter/lib/src/ui/molecules/wallet_chip.dart index 2f5e4680..ea904240 100644 --- a/puls3_flutter/lib/src/ui/molecules/wallet_chip.dart +++ b/puls3_flutter/lib/src/ui/molecules/wallet_chip.dart @@ -37,7 +37,7 @@ class WalletChip extends StatelessWidget { ), child: InkWell( borderRadius: Puls3Radius.pillAll, - onTap: connected || isConnecting ? null : onConnect, + onTap: isConnecting ? null : onConnect, child: Padding( padding: const EdgeInsets.symmetric( horizontal: Puls3Spacing.md, diff --git a/puls3_flutter/lib/src/ui/organisms/wallet_panel.dart b/puls3_flutter/lib/src/ui/organisms/wallet_panel.dart new file mode 100644 index 00000000..9ed67295 --- /dev/null +++ b/puls3_flutter/lib/src/ui/organisms/wallet_panel.dart @@ -0,0 +1,437 @@ +import 'package:flutter/material.dart'; + +import '../../domain/stellar_format.dart'; +import '../../state/wallet_controller.dart'; +import '../../theme/puls3_theme.dart'; +import '../../wallet/wallet_port.dart'; +import '../atoms/primary_button.dart'; +import '../molecules/copyable_value_row.dart'; + +/// The wallet sheet (S09, flow F1): connect, connecting, connected (short +/// address, network, copy, disconnect) and every recoverable error. +/// Presentational: the container passes the state and the callbacks. +class WalletPanel extends StatelessWidget { + const WalletPanel({ + super.key, + required this.status, + required this.walletName, + required this.onConnect, + required this.onDisconnect, + this.address, + this.isOnTestnet = true, + this.error, + this.onInstall, + }); + + final WalletStatus status; + final String walletName; + final String? address; + final bool isOnTestnet; + final WalletException? error; + final VoidCallback onConnect; + final VoidCallback onDisconnect; + + /// Opens the wallet's install page; null when there is none. + final VoidCallback? onInstall; + + @override + Widget build(BuildContext context) { + final address = this.address; + final Widget body = switch (status) { + WalletStatus.connected || + WalletStatus.signed when address != null => _Connected( + address: address, + walletName: walletName, + isOnTestnet: isOnTestnet, + signed: status == WalletStatus.signed, + onDisconnect: onDisconnect, + ), + WalletStatus.connecting => _Connecting(walletName: walletName), + WalletStatus.signing => _Signing(walletName: walletName), + WalletStatus.rejected || + WalletStatus.wrongNetwork || + WalletStatus.error => _Error( + error: error, + walletName: walletName, + // Connected means the failure came from a signature, not a + // connection. + whileSigning: address != null, + onRetry: onConnect, + onInstall: onInstall, + ), + _ => _Disconnected(walletName: walletName, onConnect: onConnect), + }; + return AnimatedSwitcher(duration: Puls3Durations.fast, child: body); + } +} + +TextStyle get _title => Puls3Text.title.copyWith(fontSize: 17); +TextStyle get _muted => Puls3Text.bodyMuted.copyWith(fontSize: 13, height: 1.4); + +class _Header extends StatelessWidget { + const _Header({ + required this.icon, + required this.title, + required this.message, + this.color = Puls3Colors.lavender, + }); + + final IconData icon; + final String title; + final String message; + final Color color; + + @override + Widget build(BuildContext context) { + return Semantics( + liveRegion: true, + container: true, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Puls3Colors.surface, + border: Border.all(color: Puls3Colors.hairline), + ), + child: Icon(icon, color: color, size: 22), + ), + const SizedBox(width: Puls3Spacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: _title), + const SizedBox(height: 2), + Text(message, style: _muted), + ], + ), + ), + ], + ), + ); + } +} + +class _Disconnected extends StatelessWidget { + const _Disconnected({required this.walletName, required this.onConnect}); + + final String walletName; + final VoidCallback onConnect; + + @override + Widget build(BuildContext context) { + return Column( + key: const ValueKey('wallet-state-disconnected'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _Header( + icon: Icons.account_balance_wallet_outlined, + title: 'Connect a wallet', + message: + 'Your wallet signs deploys and payments. puls3 never sees ' + 'your secret key.', + ), + const SizedBox(height: Puls3Spacing.md), + PrimaryButton( + label: 'Connect $walletName', + icon: Icons.account_balance_wallet_outlined, + expand: true, + onPressed: onConnect, + ), + const SizedBox(height: Puls3Spacing.xs), + Text( + 'Stellar Testnet only. No real funds.', + textAlign: TextAlign.center, + style: _muted.copyWith(fontSize: 12), + ), + ], + ); + } +} + +class _Connecting extends StatelessWidget { + const _Connecting({required this.walletName}); + + final String walletName; + + @override + Widget build(BuildContext context) { + return Column( + key: const ValueKey('wallet-state-connecting'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Header( + icon: Icons.hourglass_top_rounded, + title: 'Waiting for $walletName…', + message: 'Approve the connection in the $walletName window.', + color: Puls3Colors.accent, + ), + const SizedBox(height: Puls3Spacing.md), + const _WaitingBar(), + ], + ); + } +} + +/// Progress shown while a wallet prompt is open. +class _WaitingBar extends StatelessWidget { + const _WaitingBar(); + + @override + Widget build(BuildContext context) { + return const ClipRRect( + borderRadius: Puls3Radius.pillAll, + child: LinearProgressIndicator( + minHeight: 3, + color: Puls3Colors.accent, + backgroundColor: Puls3Colors.hairline, + ), + ); + } +} + +class _Signing extends StatelessWidget { + const _Signing({required this.walletName}); + + final String walletName; + + @override + Widget build(BuildContext context) { + return Column( + key: const ValueKey('wallet-state-signing'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Header( + icon: Icons.draw_outlined, + title: 'Waiting for wallet confirmation…', + message: + 'Review the transaction in $walletName and approve it. ' + 'puls3 sends it exactly as prepared.', + color: Puls3Colors.accent, + ), + const SizedBox(height: Puls3Spacing.md), + const _WaitingBar(), + ], + ); + } +} + +class _Connected extends StatelessWidget { + const _Connected({ + required this.address, + required this.walletName, + required this.isOnTestnet, + required this.onDisconnect, + this.signed = false, + }); + + final String address; + final String walletName; + final bool isOnTestnet; + final bool signed; + final VoidCallback onDisconnect; + + @override + Widget build(BuildContext context) { + return Column( + key: ValueKey(signed ? 'wallet-state-signed' : 'wallet-state-connected'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Header( + icon: Icons.check_circle_rounded, + title: signed ? 'Transaction signed' : 'Connected', + message: signed + ? 'Signed with $walletName. puls3 submits it for you.' + : 'Signing with $walletName.', + color: Puls3Colors.success, + ), + const SizedBox(height: Puls3Spacing.md), + Container( + padding: const EdgeInsets.only( + left: Puls3Spacing.sm, + top: Puls3Spacing.xxs, + bottom: Puls3Spacing.xxs, + ), + decoration: BoxDecoration( + color: Puls3Colors.surface, + borderRadius: Puls3Radius.mdAll, + border: Border.all(color: Puls3Colors.hairline), + ), + child: Column( + children: [ + CopyableValueRow( + label: 'Address', + value: address, + display: shortenAddress(address), + copyLabel: 'Copy address', + ), + Padding( + padding: const EdgeInsets.only( + right: Puls3Spacing.sm, + bottom: Puls3Spacing.xs, + ), + child: Row( + children: [ + Expanded( + child: Text( + 'Network', + style: _muted.copyWith(fontSize: 12), + ), + ), + _NetworkBadge(onTestnet: isOnTestnet), + ], + ), + ), + ], + ), + ), + const SizedBox(height: Puls3Spacing.md), + PrimaryButton( + label: 'Disconnect', + icon: Icons.logout_rounded, + variant: PrimaryButtonVariant.outline, + expand: true, + onPressed: onDisconnect, + ), + ], + ); + } +} + +class _NetworkBadge extends StatelessWidget { + const _NetworkBadge({required this.onTestnet}); + + final bool onTestnet; + + @override + Widget build(BuildContext context) { + final color = onTestnet ? Puls3Colors.success : Puls3Colors.accent; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + borderRadius: Puls3Radius.pillAll, + border: Border.all(color: color), + ), + child: Text( + onTestnet ? 'Testnet' : 'Wrong network', + style: Puls3Text.caption.copyWith(fontSize: 12, color: color), + ), + ); + } +} + +class _Error extends StatelessWidget { + const _Error({ + required this.error, + required this.walletName, + required this.onRetry, + this.whileSigning = false, + this.onInstall, + }); + + final WalletException? error; + final String walletName; + final bool whileSigning; + final VoidCallback onRetry; + final VoidCallback? onInstall; + + @override + Widget build(BuildContext context) { + final (key, icon, title, message) = switch (error) { + WalletSignatureRejected() when whileSigning => ( + 'rejected', + Icons.block_rounded, + 'Signature rejected', + 'You rejected the transaction in $walletName. Nothing was sent.', + ), + WalletSignatureRejected() => ( + 'rejected', + Icons.block_rounded, + 'Connection cancelled', + 'You declined the request in $walletName. Nothing was shared.', + ), + WalletWrongNetwork() => ( + 'wrong-network', + Icons.swap_horiz_rounded, + 'Wrong network', + 'Your wallet is on another network. Switch $walletName to ' + 'Stellar Testnet to continue.', + ), + WalletAccountChanged() => ( + 'account-changed', + Icons.manage_accounts_outlined, + 'Account changed', + 'The account in $walletName changed. Connect again to continue.', + ), + WalletTimedOut() => ( + 'timed-out', + Icons.hourglass_disabled_rounded, + 'No answer from $walletName', + 'The $walletName window was closed or never opened. Open ' + '$walletName, then try again.', + ), + WalletInvalidPayload() => ( + 'invalid-payload', + Icons.gpp_bad_outlined, + 'Transaction refused', + 'puls3 refused to ask $walletName to sign this transaction. ' + 'Nothing was signed.', + ), + WalletNotInstalled() => ( + 'not-installed', + Icons.extension_off_outlined, + '$walletName not found', + 'Install the $walletName extension, reload this page, then ' + 'connect.', + ), + WalletUnavailable() => ( + 'unavailable', + Icons.lock_outline_rounded, + '$walletName is locked', + 'Unlock $walletName, then try again.', + ), + _ => ( + 'failed', + Icons.error_outline_rounded, + 'Wallet connection failed', + 'Something went wrong with $walletName. Try again.', + ), + }; + final install = error is WalletNotInstalled ? onInstall : null; + return Column( + key: ValueKey('wallet-state-$key'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Header( + icon: icon, + title: title, + message: message, + color: Puls3Colors.accent, + ), + const SizedBox(height: Puls3Spacing.md), + if (install != null) ...[ + PrimaryButton( + label: 'Install $walletName', + icon: Icons.open_in_new_rounded, + expand: true, + onPressed: install, + ), + const SizedBox(height: Puls3Spacing.xs), + ], + PrimaryButton( + label: 'Try again', + icon: Icons.refresh_rounded, + variant: install == null + ? PrimaryButtonVariant.filled + : PrimaryButtonVariant.outline, + expand: true, + onPressed: onRetry, + ), + ], + ); + } +} diff --git a/puls3_flutter/lib/src/wallet/demo_envelope.dart b/puls3_flutter/lib/src/wallet/demo_envelope.dart new file mode 100644 index 00000000..3ee10d35 --- /dev/null +++ b/puls3_flutter/lib/src/wallet/demo_envelope.dart @@ -0,0 +1,31 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; + +/// An unsigned Stellar Testnet transaction for [source] that does nothing +/// harmful: one `manage_data` entry named "puls3" with [note]. +/// +/// The demo gateways hand it to the wallet so a real wallet (Freighter) shows +/// a real signing prompt before the server relay exists (#18, #96). It is +/// never submitted: its sequence number is 0, so the network would reject +/// it anyway. +/// +/// Simulated wallets may use addresses that are not real Stellar keys; for +/// those it returns a placeholder, since they never parse the payload. +String demoEnvelope(String source, {required String note}) { + final bytes = utf8.encode(note); + // manage_data values are at most 64 bytes. + final value = Uint8List.fromList( + bytes.length > 64 ? bytes.sublist(0, 64) : bytes, + ); + try { + final tx = TransactionBuilder(Account(source, BigInt.zero)) + .addOperation(ManageDataOperationBuilder('puls3', value).build()) + .addMemo(MemoText('puls3 demo')) + .build(); + return tx.toEnvelopeXdrBase64(); + } on Object { + return 'demo-unsigned:$note'; + } +} diff --git a/puls3_flutter/lib/src/wallet/freighter/create_freighter_bridge.dart b/puls3_flutter/lib/src/wallet/freighter/create_freighter_bridge.dart new file mode 100644 index 00000000..1c5f088e --- /dev/null +++ b/puls3_flutter/lib/src/wallet/freighter/create_freighter_bridge.dart @@ -0,0 +1,6 @@ +/// The Freighter bridge for this platform: JS interop on the web, a +/// "not installed" stub elsewhere (VM tests, desktop). +library; + +export 'freighter_bridge_stub.dart' + if (dart.library.js_interop) 'freighter_bridge_web.dart'; diff --git a/puls3_flutter/lib/src/wallet/freighter/freighter_bridge.dart b/puls3_flutter/lib/src/wallet/freighter/freighter_bridge.dart new file mode 100644 index 00000000..b81f1508 --- /dev/null +++ b/puls3_flutter/lib/src/wallet/freighter/freighter_bridge.dart @@ -0,0 +1,51 @@ +/// What Freighter reports for the active account. +class FreighterSession { + const FreighterSession({ + required this.address, + required this.networkPassphrase, + }); + + final String address; + final String networkPassphrase; +} + +/// Freighter's answer to an auth-entry preimage signing request. +class AuthEntrySignature { + const AuthEntrySignature({required this.signature, this.signerAddress}); + + /// Base64 of the raw 64-byte ed25519 signature. + final String signature; + + /// Signer reported by the wallet, when it reports one. + final String? signerAddress; +} + +/// The calls [FreighterWallet] makes into the Freighter extension. The web +/// implementation goes through JS interop; tests use a fake. +/// +/// Every method throws a `WalletException` on failure (not installed, +/// rejected, wrong network, account changed), never a raw JS error. +abstract interface class FreighterBridge { + /// Asks for access and returns the active account and network. + Future connect(); + + /// The active account and network, without prompting. + Future currentSession(); + + /// Signs [xdr] for [address] on [networkPassphrase]; returns the signed + /// envelope XDR. + Future signTransaction( + String xdr, + String networkPassphrase, + String address, + ); + + /// Mirrors Freighter's `signAuthEntry`: [preimageXdr] is the base64 XDR of + /// a `HashIdPreimage`, and the wallet answers with an ed25519 signature + /// over sha256(preimage), not with a signed entry. + Future signAuthEntryPreimage( + String preimageXdr, + String networkPassphrase, + String address, + ); +} diff --git a/puls3_flutter/lib/src/wallet/freighter/freighter_bridge_stub.dart b/puls3_flutter/lib/src/wallet/freighter/freighter_bridge_stub.dart new file mode 100644 index 00000000..ccfe40cb --- /dev/null +++ b/puls3_flutter/lib/src/wallet/freighter/freighter_bridge_stub.dart @@ -0,0 +1,31 @@ +import '../wallet_port.dart'; +import 'freighter_bridge.dart'; + +/// Off the web (tests on the Dart VM, desktop builds) there is no Freighter: +/// every call reports [WalletNotInstalled]. +FreighterBridge createFreighterBridge() => const _NoFreighter(); + +final class _NoFreighter implements FreighterBridge { + const _NoFreighter(); + + @override + Future connect() async => throw const WalletNotInstalled(); + + @override + Future currentSession() async => + throw const WalletNotInstalled(); + + @override + Future signTransaction( + String xdr, + String networkPassphrase, + String address, + ) async => throw const WalletNotInstalled(); + + @override + Future signAuthEntryPreimage( + String preimageXdr, + String networkPassphrase, + String address, + ) async => throw const WalletNotInstalled(); +} diff --git a/puls3_flutter/lib/src/wallet/freighter/freighter_bridge_web.dart b/puls3_flutter/lib/src/wallet/freighter/freighter_bridge_web.dart new file mode 100644 index 00000000..a9b06daf --- /dev/null +++ b/puls3_flutter/lib/src/wallet/freighter/freighter_bridge_web.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; +import 'dart:js_interop'; + +import 'package:flutter/foundation.dart'; + +import '../wallet_port.dart'; +import 'freighter_bridge.dart'; + +@JS('puls3FreighterConnect') +external JSPromise? _connect(); + +@JS('puls3FreighterCurrentSession') +external JSPromise _currentSession(); + +@JS('puls3FreighterSignTransaction') +external JSPromise _signTransaction( + JSString xdr, + JSString passphrase, + JSString address, +); + +@JS('puls3FreighterSignAuthEntryPreimage') +external JSPromise _signAuthEntryPreimage( + JSString xdr, + JSString passphrase, + JSString address, +); + +/// The web [FreighterBridge]: calls `web/freighter_bridge.js` through JS +/// interop and turns its ": message" errors into [WalletException]s. +FreighterBridge createFreighterBridge() => _FreighterWebBridge(); + +final class _FreighterWebBridge implements FreighterBridge { + @override + Future connect() async { + final JSPromise? promise; + try { + promise = _connect(); + } on Object { + // The bridge script did not load: no extension API to talk to. + throw const WalletNotInstalled(); + } + if (promise == null) throw const WalletNotInstalled(); + return _session(await _call(promise)); + } + + @override + Future currentSession() async => + _session(await _call(_currentSession())); + + @override + Future signTransaction( + String xdr, + String networkPassphrase, + String address, + ) => _call(_signTransaction(xdr.toJS, networkPassphrase.toJS, address.toJS)); + + @override + Future signAuthEntryPreimage( + String preimageXdr, + String networkPassphrase, + String address, + ) async { + final raw = await _call( + _signAuthEntryPreimage( + preimageXdr.toJS, + networkPassphrase.toJS, + address.toJS, + ), + ); + final Object? value; + try { + value = jsonDecode(raw); + } on FormatException { + throw const WalletInvalidPayload('Freighter returned a bad signature.'); + } + if (value case { + 'signature': final String signature, + 'signerAddress': final String? signerAddress, + }) { + return AuthEntrySignature( + signature: signature, + signerAddress: signerAddress, + ); + } + throw const WalletInvalidPayload('Freighter returned a bad signature.'); + } + + FreighterSession _session(String raw) { + final Object? value; + try { + value = jsonDecode(raw); + } on FormatException { + throw const WalletUnavailable(); + } + if (value case { + 'address': final String address, + 'networkPassphrase': final String passphrase, + }) { + return FreighterSession(address: address, networkPassphrase: passphrase); + } + throw const WalletUnavailable(); + } + + Future _call(JSPromise promise) async { + try { + return (await promise.toDart).toDart; + } on WalletException { + rethrow; + } on Object catch (error) { + throw _translate(error.toString()); + } + } + + static WalletException _translate(String message) { + if (message.contains('not_installed:')) return const WalletNotInstalled(); + if (message.contains('wrong_network:')) return const WalletWrongNetwork(); + if (message.contains('account_changed:')) { + return const WalletAccountChanged(); + } + if (message.contains('unavailable:')) return const WalletUnavailable(); + if (message.contains('invalid:')) { + return const WalletInvalidPayload('Freighter returned bad data.'); + } + // Freighter reports a declined prompt as an error result ("rejected:"). + if (message.contains('rejected:')) return const WalletSignatureRejected(); + // Anything else (a JS error, an extension crash) is not the user saying + // no. Log only that it happened: messages may carry payload data. + debugPrint('Freighter bridge failed with an unexpected error.'); + return const WalletUnavailable(); + } +} diff --git a/puls3_flutter/lib/src/wallet/freighter/freighter_wallet.dart b/puls3_flutter/lib/src/wallet/freighter/freighter_wallet.dart new file mode 100644 index 00000000..df91efde --- /dev/null +++ b/puls3_flutter/lib/src/wallet/freighter/freighter_wallet.dart @@ -0,0 +1,261 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; + +import '../wallet_port.dart'; +import 'freighter_bridge.dart'; + +/// [WalletPort] over the Freighter browser extension (ADR-0003), ported from +/// the #70 spike with the #69 pre-prompt checks. +/// +/// Before Freighter is ever prompted it verifies that the active account and +/// network are still the ones connected, that the XDR is canonical (no +/// trailing bytes), that the payload belongs to the connected account, and +/// that it is a kind this app signs. After signing it verifies that the +/// transaction body is unchanged and carries a valid new signature from the +/// connected account on Testnet. +final class FreighterWallet implements WalletPort { + FreighterWallet(this._bridge); + + final FreighterBridge _bridge; + FreighterSession? _session; + + @override + String get name => 'Freighter'; + + @override + Uri? get installUrl => Uri.parse('https://www.freighter.app/'); + + @override + String? get address => _session?.address; + + @override + String? get network => _session?.networkPassphrase; + + @override + Future connect() async { + final session = await _bridge.connect(); + if (session.networkPassphrase != stellarTestnetPassphrase) { + // Not kept: the app must not look connected on the wrong network. + throw const WalletWrongNetwork(); + } + _session = session; + return session.address; + } + + @override + Future disconnect() async { + // Freighter has no programmatic disconnect; the app forgets the session. + _session = null; + } + + @override + Future signTransaction(String unsignedXdr) async { + final session = _requireSession(); + await _assertActiveSession(session); + final unsigned = _parse(unsignedXdr, canonical: true); + // #69 (R3-001): fee-bump and other envelopes are refused before the + // prompt, so the wallet never shows another payer's payload. + if (unsigned is! Transaction) { + throw const WalletInvalidPayload( + 'Only standard transaction envelopes can be signed.', + ); + } + _assertSourceBinding(unsigned, session); + + final signedXdr = await _bridge.signTransaction( + unsignedXdr, + stellarTestnetPassphrase, + session.address, + ); + final signed = _parse(signedXdr); + if (signed is! Transaction) { + throw const WalletInvalidPayload('The wallet returned another envelope.'); + } + if (unsigned.toXdrBase64() != signed.toXdrBase64()) { + throw const WalletInvalidPayload('The wallet changed the transaction.'); + } + final before = unsigned.signatures + .map((s) => s.toBase64EncodedXdrString()) + .toList(); + final added = signed.signatures.where((s) { + final encoded = s.toBase64EncodedXdrString(); + return !before.remove(encoded); + }).toList(); + if (added.isEmpty) { + throw const WalletInvalidPayload('The wallet did not add a signature.'); + } + final key = KeyPair.fromAccountId(session.address); + final hash = signed.hash(Network.TESTNET); + if (!added.any((s) => key.verify(hash, s.signature.signature))) { + throw const WalletInvalidPayload( + 'The signature is not from the connected account.', + ); + } + return signedXdr; + } + + /// Freighter's `signAuthEntry` signs a `HashIdPreimage`, not an entry, so + /// this builds the preimage for the entry's arm (legacy ADDRESS or + /// CAP-71 ADDRESS_V2), verifies the returned signature against the + /// connected account and attaches it like `SorobanAuthorizationEntry.sign`. + @override + Future signAuthEntry(String entryXdr) async { + final session = _requireSession(); + await _assertActiveSession(session); + final entry = _parseAuthEntry(entryXdr); + final credentials = _requireOwnAddressCredentials(entry, session); + // #69: an unset expiration would make the signature unusable on chain. + if (credentials.signatureExpirationLedger == 0) { + throw const WalletInvalidPayload( + 'The authorization entry has no expiration ledger.', + ); + } + + final XdrHashIDPreimage preimage; + try { + preimage = entry.buildPreimage(Network.TESTNET); + } on Object { + throw const WalletInvalidPayload( + 'Cannot build the authorization preimage.', + ); + } + final stream = XdrDataOutputStream(); + XdrHashIDPreimage.encode(stream, preimage); + final preimageBytes = Uint8List.fromList(stream.bytes); + + final answer = await _bridge.signAuthEntryPreimage( + base64Encode(preimageBytes), + stellarTestnetPassphrase, + session.address, + ); + final signer = answer.signerAddress; + if (signer != null && signer != session.address) { + throw const WalletAccountChanged(); + } + final signature = _decodeSignature(answer.signature); + final key = KeyPair.fromAccountId(session.address); + if (!key.verify(Util.hash(preimageBytes), signature)) { + throw const WalletInvalidPayload( + 'The signature is not from the connected account.', + ); + } + credentials.signature = XdrSCVal.forVec([ + ...?credentials.signature.vec, + AccountEd25519Signature(key.xdrPublicKey, signature).toXdrSCVal(), + ]); + return entry.toBase64EncodedXdrString(); + } + + SorobanAuthorizationEntry _parseAuthEntry(String xdr) { + final trimmed = xdr.trim(); + if (trimmed.isEmpty) { + throw const WalletInvalidPayload('The authorization entry is empty.'); + } + try { + final entry = SorobanAuthorizationEntry.fromBase64EncodedXdr(trimmed); + // Rejects trailing bytes and non-canonical encodings. + if (entry.toBase64EncodedXdrString() != trimmed) { + throw const FormatException('non-canonical'); + } + return entry; + } on Object { + throw const WalletInvalidPayload( + 'The authorization entry XDR is malformed.', + ); + } + } + + /// Legacy ADDRESS and ADDRESS_V2 credentials of the connected G… account + /// are signed; everything else is refused before the prompt. + SorobanAddressCredentials _requireOwnAddressCredentials( + SorobanAuthorizationEntry entry, + FreighterSession session, + ) { + final credentials = switch (entry.credentials.arm) { + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS || + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2 => + entry.credentials.innerAddressCredentials, + XdrSorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT => + throw const WalletInvalidPayload( + 'Source-account credentials need no entry signature.', + ), + _ => throw const WalletInvalidPayload( + 'Delegated credentials are not supported.', + ), + }; + if (credentials == null) { + throw const WalletInvalidPayload( + 'The authorization entry has no credentials.', + ); + } + final address = credentials.address; + if (address.type != Address.TYPE_ACCOUNT || + address.accountId != session.address) { + throw const WalletInvalidPayload( + 'The authorization entry belongs to another address.', + ); + } + return credentials; + } + + Uint8List _decodeSignature(String encoded) { + final Uint8List bytes; + try { + bytes = base64Decode(encoded); + } on FormatException { + throw const WalletInvalidPayload('The wallet returned a bad signature.'); + } + if (bytes.length != 64) { + throw const WalletInvalidPayload('The wallet returned a bad signature.'); + } + return bytes; + } + + /// The account or network may change in Freighter after connecting. + Future _assertActiveSession(FreighterSession expected) async { + final active = await _bridge.currentSession(); + if (active.networkPassphrase != stellarTestnetPassphrase) { + throw const WalletWrongNetwork(); + } + if (active.address != expected.address) { + throw const WalletAccountChanged(); + } + } + + /// The transaction source and every explicit operation source must be the + /// connected account (muxed M… sources compare by their G… account). + void _assertSourceBinding(Transaction tx, FreighterSession session) { + if (tx.sourceAccount.ed25519AccountId != session.address) { + throw const WalletInvalidPayload( + 'The transaction was prepared for another account.', + ); + } + for (final operation in tx.operations) { + final source = operation.sourceAccount; + if (source != null && source.ed25519AccountId != session.address) { + throw const WalletInvalidPayload( + 'An operation is for another account.', + ); + } + } + } + + FreighterSession _requireSession() => + _session ?? (throw const WalletUnavailable()); + + /// With [canonical], the XDR must re-encode to the same base64, so trailing + /// bytes or non-canonical encodings never reach the wallet. + AbstractTransaction _parse(String xdr, {bool canonical = false}) { + try { + final parsed = AbstractTransaction.fromEnvelopeXdrString(xdr); + if (canonical && parsed.toEnvelopeXdrBase64() != xdr) { + throw const FormatException('non-canonical'); + } + return parsed; + } on Object { + throw const WalletInvalidPayload('The transaction XDR is malformed.'); + } + } +} diff --git a/puls3_flutter/lib/src/wallet/mock_wallet.dart b/puls3_flutter/lib/src/wallet/mock_wallet.dart index bb5363ce..0cb84c06 100644 --- a/puls3_flutter/lib/src/wallet/mock_wallet.dart +++ b/puls3_flutter/lib/src/wallet/mock_wallet.dart @@ -1,8 +1,9 @@ import '../domain/stellar_format.dart'; import 'wallet_port.dart'; -/// Fake wallet for tests and the demo. It never touches a network or a real -/// key: its "signed" envelopes are placeholders a real server would reject. +/// Fake wallet for tests and the demo console (#32). It never touches a +/// network or a real key: its "signed" payloads are placeholders a real +/// server would reject. class MockWallet implements WalletPort { MockWallet({ FakeLedgerIds? ids, @@ -10,46 +11,81 @@ class MockWallet implements WalletPort { this.signDelay = const Duration(milliseconds: 1200), }) : _ids = ids ?? FakeLedgerIds(); - /// Prefix of every envelope this wallet "signs". + /// Prefix of every payload this wallet "signs". static const signedPrefix = 'mock-signed:'; final FakeLedgerIds _ids; final Duration connectDelay; final Duration signDelay; + @override + String get name => 'Demo wallet'; + + @override + Uri? get installUrl => null; + + /// The network the simulated wallet is on. Set it to the public network + /// passphrase to reproduce the wrong-network error. + String walletNetwork = stellarTestnetPassphrase; + + /// While true, connection requests are declined, as if the user pressed + /// "Reject" in the wallet's connect prompt. + bool rejectConnection = false; + /// While true, every signature request is declined with - /// [WalletSignatureRejected], as if the user pressed "Reject". + /// [WalletSignatureRejected]. bool rejectSignatures = false; /// When set, connection and signature requests fail with it, for example - /// [WalletUnavailable] or [WalletWrongNetwork]. + /// [WalletNotInstalled], [WalletUnavailable] or [WalletWrongNetwork]. WalletException? failure; + /// The account inside the simulated wallet; it survives a disconnect, + /// as in a real wallet. + String? _account; String? _address; @override String? get address => _address; + @override + String? get network => _address == null ? null : walletNetwork; + + @override + Future disconnect() async => _address = null; + /// Simulates the user switching to another account in the wallet. - void switchAccount() => _address = _ids.accountAddress(); + void switchAccount() => _address = _account = _ids.accountAddress(); @override Future connect() async { await Future.delayed(connectDelay); final failure = this.failure; if (failure != null) throw failure; - return _address ??= _ids.accountAddress(); + if (rejectConnection) throw const WalletSignatureRejected(); + if (walletNetwork != stellarTestnetPassphrase) { + throw const WalletWrongNetwork(); + } + return _address = _account ??= _ids.accountAddress(); } /// Returns [unsignedXdr] marked as signed: a stand-in for the signed - /// envelope a real wallet returns. It is not a valid Stellar signature. + /// envelope a real wallet returns, not a valid Stellar signature. @override - Future signTransaction(String unsignedXdr) async { + Future signTransaction(String unsignedXdr) => _sign(unsignedXdr); + + @override + Future signAuthEntry(String entryXdr) => _sign(entryXdr); + + Future _sign(String payload) async { if (_address == null) await connect(); await Future.delayed(signDelay); final failure = this.failure; if (failure != null) throw failure; if (rejectSignatures) throw const WalletSignatureRejected(); - return '$signedPrefix$unsignedXdr'; + if (walletNetwork != stellarTestnetPassphrase) { + throw const WalletWrongNetwork(); + } + return '$signedPrefix$payload'; } } diff --git a/puls3_flutter/lib/src/wallet/wallet_port.dart b/puls3_flutter/lib/src/wallet/wallet_port.dart index eb63f7b1..1435ac51 100644 --- a/puls3_flutter/lib/src/wallet/wallet_port.dart +++ b/puls3_flutter/lib/src/wallet/wallet_port.dart @@ -1,25 +1,48 @@ -/// Port for anything that can hold a Stellar account and sign transactions. +/// The Stellar Testnet passphrase: the only network the MVP accepts. +const stellarTestnetPassphrase = 'Test SDF Network ; September 2015'; + +/// Port for anything that can hold a Stellar account and sign transactions +/// (#25, ADR-0003). /// -/// The demo ships only [MockWallet]; a real adapter (Freighter, #25) can -/// implement this without touching the UI. It is the only signing boundary: -/// keys and seeds never leave the wallet, and the wallet never submits -/// (API contract #77, Decision A: the server relays). +/// Adapters: [FreighterWallet] in Chrome and [MockWallet] for tests and the +/// demo console (#32). It is the only signing boundary: keys and seeds never +/// leave the wallet, and the app never submits a transaction itself (API +/// contract #77, Decision A). abstract interface class WalletPort { + /// The wallet's name for the UI, for example "Freighter". + String get name; + + /// Where to install the wallet when it is missing, if it has such a page. + Uri? get installUrl; + /// Connects the wallet and returns the public Stellar address. /// - /// Throws a [WalletException] when the wallet cannot be used. + /// Throws a [WalletException]: [WalletWrongNetwork] when the wallet is not + /// on Stellar Testnet, [WalletSignatureRejected] when the user declines, + /// [WalletNotInstalled] when there is no wallet. Future connect(); - /// The connected public address, or null while disconnected. + /// Forgets the connection. Nothing is signed or revoked on chain. + Future disconnect(); + + /// The connected public address (`G…`), or null while disconnected. String? get address; + /// The connected wallet's network passphrase, or null while disconnected. + String? get network; + /// Signs the server-prepared transaction [unsignedXdr] unchanged and - /// returns the signed envelope as base64 XDR. It does not submit it: the - /// app sends it back to the server, which submits. + /// returns the signed envelope as base64 XDR. The server submits it. /// /// Throws a [WalletException], for example [WalletSignatureRejected] when - /// the user declines the request. + /// the user declines or [WalletInvalidPayload] when the envelope is not + /// one the connected account can safely sign. Future signTransaction(String unsignedXdr); + + /// Signs a server-simulated Soroban authorization entry for the connected + /// account (ADR-0003: the builder's step of `set_agent_wallet`, #18) and + /// returns the signed entry as base64 XDR. + Future signAuthEntry(String entryXdr); } /// Why the wallet could not connect or sign. Nothing was signed, so the @@ -36,12 +59,41 @@ final class WalletSignatureRejected extends WalletException { const WalletSignatureRejected(); } -/// No wallet is installed, unlocked or connected. +/// No supported wallet is installed in this browser. +final class WalletNotInstalled extends WalletException { + const WalletNotInstalled(); +} + +/// A wallet is installed but cannot be used now (locked, not connected). final class WalletUnavailable extends WalletException { const WalletUnavailable(); } -/// The wallet is on another network than the app (for example mainnet). +/// The wallet is on another network than Stellar Testnet. final class WalletWrongNetwork extends WalletException { const WalletWrongNetwork(); } + +/// The active wallet account is not the one the app connected to. +final class WalletAccountChanged extends WalletException { + const WalletAccountChanged(); +} + +/// The payload is not one the connected account can safely sign: malformed +/// XDR, an unsupported envelope, another account's transaction, or a wallet +/// answer that changed it. Rejected before the wallet prompt when possible. +/// The wallet did not answer a prompt in time (ignored, blocked or stuck +/// popup). Nothing was signed. +final class WalletTimedOut extends WalletException { + const WalletTimedOut(); +} + +final class WalletInvalidPayload extends WalletException { + const WalletInvalidPayload(this.reason); + + /// A short technical reason, safe to show under "details". + final String reason; + + @override + String toString() => 'WalletInvalidPayload: $reason'; +} diff --git a/puls3_flutter/pubspec.yaml b/puls3_flutter/pubspec.yaml index f4179bc3..b29bf066 100644 --- a/puls3_flutter/pubspec.yaml +++ b/puls3_flutter/pubspec.yaml @@ -36,6 +36,8 @@ dependencies: path: ../puls3_client serverpod_auth_idp_flutter: 4.0.4 serverpod_flutter: 4.0.4 + # Pinned like the #70 spike: XDR checks in the Freighter adapter. + stellar_flutter_sdk: 3.8.0 url_launcher: ^6.3.2 dev_dependencies: diff --git a/puls3_flutter/test/deploy/deploy_flow_test.dart b/puls3_flutter/test/deploy/deploy_flow_test.dart index dbd09ac0..d8bd27da 100644 --- a/puls3_flutter/test/deploy/deploy_flow_test.dart +++ b/puls3_flutter/test/deploy/deploy_flow_test.dart @@ -427,6 +427,9 @@ void main() { h.wallet.failure = null; await tester.tap(find.text('Try again')); + // The wallet forgot the wrong-network session: it reconnects first, + // with the same account, then signs the same transaction. + await h.settle(const Duration(milliseconds: 600)); await h.sign(); expect(h.statusOf(DeployStep.registering), StepStatus.active); expect(h.gateway.prepares, hasLength(1)); diff --git a/puls3_flutter/test/screens/hire_sheet_test.dart b/puls3_flutter/test/screens/hire_sheet_test.dart index c4d537be..1ff0319c 100644 --- a/puls3_flutter/test/screens/hire_sheet_test.dart +++ b/puls3_flutter/test/screens/hire_sheet_test.dart @@ -24,6 +24,21 @@ class TestWallet implements WalletPort { return _address = 'GTESTUSERWALLET1234567890'; } + @override + Future disconnect() async => _address = null; + + @override + String get name => 'Test wallet'; + + @override + Uri? get installUrl => null; + + @override + String? get network => address == null ? null : stellarTestnetPassphrase; + + @override + Future signAuthEntry(String entryXdr) async => entryXdr; + @override Future signTransaction(String unsignedXdr) async { if (shouldFail) { diff --git a/puls3_flutter/test/screens/market_screen_test.dart b/puls3_flutter/test/screens/market_screen_test.dart index a43ca009..ee19eca2 100644 --- a/puls3_flutter/test/screens/market_screen_test.dart +++ b/puls3_flutter/test/screens/market_screen_test.dart @@ -36,8 +36,13 @@ void main() { testWidgets('Connect wallet shows a shortened address', (tester) async { await pumpApp(tester, location: '/market'); + // The chip opens the wallet sheet (S09); connecting happens there. await tester.tap(find.text('Connect wallet')); + await advance(tester, const Duration(milliseconds: 400)); + await tester.tap(find.text('Connect Demo wallet')); await advance(tester, const Duration(milliseconds: 700)); + Navigator.of(tester.element(find.text('Disconnect'))).pop(); + await advance(tester, const Duration(milliseconds: 400)); expect(find.text('Connect wallet'), findsNothing); expect(find.textContaining('…'), findsWidgets); diff --git a/puls3_flutter/test/wallet/freighter_wallet_test.dart b/puls3_flutter/test/wallet/freighter_wallet_test.dart new file mode 100644 index 00000000..142d79d2 --- /dev/null +++ b/puls3_flutter/test/wallet/freighter_wallet_test.dart @@ -0,0 +1,386 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:puls3_flutter/src/wallet/demo_envelope.dart'; +import 'package:puls3_flutter/src/wallet/freighter/freighter_bridge.dart'; +import 'package:puls3_flutter/src/wallet/freighter/freighter_wallet.dart'; +import 'package:puls3_flutter/src/wallet/wallet_port.dart'; +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; + +/// Stands in for the Freighter extension; signs with a real test key. +class FakeBridge implements FreighterBridge { + FakeBridge(this.key); + + final KeyPair key; + String network = stellarTestnetPassphrase; + String? activeAddress; + Object? failure; + String Function(String xdr)? signer; + AuthEntrySignature Function(String preimage)? authSigner; + int signCalls = 0; + int authCalls = 0; + + FreighterSession get _session => FreighterSession( + address: activeAddress ?? key.accountId, + networkPassphrase: network, + ); + + @override + Future connect() async { + if (failure case final f?) throw f; + return _session; + } + + @override + Future currentSession() async => _session; + + @override + Future signTransaction( + String xdr, + String networkPassphrase, + String address, + ) async { + signCalls++; + if (failure case final f?) throw f; + final custom = signer; + if (custom != null) return custom(xdr); + final tx = AbstractTransaction.fromEnvelopeXdrString(xdr) as Transaction; + tx.sign(key, Network.TESTNET); + return tx.toEnvelopeXdrBase64(); + } + + @override + Future signAuthEntryPreimage( + String preimageXdr, + String networkPassphrase, + String address, + ) async { + authCalls++; + if (failure case final f?) throw f; + return authSigner?.call(preimageXdr) ?? + AuthEntrySignature( + signature: base64Encode( + key.sign(Util.hash(base64Decode(preimageXdr))), + ), + signerAddress: key.accountId, + ); + } +} + +const _destination = 'GCL2Q3PX6FDMF7XMSE7C6UEHYVEER2ZTVIQEJVUSSL4IFQX7YOIZEPOV'; + +Transaction _payment(String source, {String? operationSource}) { + final op = PaymentOperationBuilder(_destination, AssetTypeNative(), '1'); + if (operationSource != null) op.setSourceAccount(operationSource); + return (TransactionBuilder( + Account(source, BigInt.one), + )..addOperation(op.build())).build(); +} + +SorobanAuthorizationEntry _authEntry( + String address, { + int expiration = 4953189, + SorobanCredentials? credentials, +}) => SorobanAuthorizationEntry( + credentials ?? + SorobanCredentials.forAddressLegacy( + Address.forAccountId(address), + BigInt.from(424242), + expiration, + XdrSCVal.forVoid(), + ), + SorobanAuthorizedInvocation( + SorobanAuthorizedFunction( + contractFn: XdrInvokeContractArgs( + Address.forContractId( + 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC', + ).toXdr(), + 'transfer', + [ + Address.forAccountId(address).toXdrSCVal(), + Address.forAccountId(_destination).toXdrSCVal(), + XdrSCVal.forI128Parts(BigInt.zero, BigInt.from(10000000)), + ], + ), + ), + ), +); + +void main() { + late KeyPair key; + late FakeBridge bridge; + late FreighterWallet wallet; + + setUp(() { + key = KeyPair.random(); + bridge = FakeBridge(key); + wallet = FreighterWallet(bridge); + }); + + group('connect and network guard', () { + test('connects on Testnet and exposes address and network', () async { + expect(await wallet.connect(), key.accountId); + expect(wallet.address, key.accountId); + expect(wallet.network, stellarTestnetPassphrase); + expect(wallet.name, 'Freighter'); + }); + + test( + 'refuses a wallet on another network and stays disconnected', + () async { + bridge.network = Network.PUBLIC.networkPassphrase; + // Settle the future before checking the state it leaves behind. + await expectLater(wallet.connect(), throwsA(isA())); + expect(wallet.address, isNull); + }, + ); + + test('a declined connection is a typed, recoverable error', () async { + bridge.failure = const WalletSignatureRejected(); + await expectLater( + wallet.connect(), + throwsA(isA()), + ); + bridge.failure = null; + expect(await wallet.connect(), key.accountId); + }); + + test('disconnect forgets the session', () async { + await wallet.connect(); + await wallet.disconnect(); + expect(wallet.address, isNull); + expect(wallet.network, isNull); + }); + }); + + group('signTransaction returns signed XDR', () { + test('signs a server-prepared envelope unchanged', () async { + await wallet.connect(); + final unsigned = _payment(key.accountId); + final signedXdr = await wallet.signTransaction( + unsigned.toEnvelopeXdrBase64(), + ); + final signed = + AbstractTransaction.fromEnvelopeXdrString(signedXdr) as Transaction; + expect(signed.toXdrBase64(), unsigned.toXdrBase64()); + expect( + key.verify( + signed.hash(Network.TESTNET), + signed.signatures.single.signature.signature, + ), + isTrue, + ); + }); + + test('the demo envelope is valid for the connected account', () async { + await wallet.connect(); + final signed = await wallet.signTransaction( + demoEnvelope(key.accountId, note: 'deploy Visa Scout'), + ); + expect(bridge.signCalls, 1); + expect( + AbstractTransaction.fromEnvelopeXdrString(signed), + isA(), + ); + }); + + test('refuses before the prompt when not connected', () async { + await expectLater( + wallet.signTransaction(_payment(key.accountId).toEnvelopeXdrBase64()), + throwsA(isA()), + ); + expect(bridge.signCalls, 0); + }); + + test('refuses another account\'s transaction before the prompt', () async { + await wallet.connect(); + await expectLater( + wallet.signTransaction( + _payment(KeyPair.random().accountId).toEnvelopeXdrBase64(), + ), + throwsA(isA()), + ); + await expectLater( + wallet.signTransaction( + _payment( + key.accountId, + operationSource: KeyPair.random().accountId, + ).toEnvelopeXdrBase64(), + ), + throwsA(isA()), + ); + expect(bridge.signCalls, 0); + }); + + test('refuses malformed XDR before the prompt', () async { + await wallet.connect(); + final valid = base64Decode(_payment(key.accountId).toEnvelopeXdrBase64()); + for (final xdr in [ + '', + 'not base64!', + base64Encode(valid.sublist(0, valid.length - 8)), + base64Encode([...valid, 0, 0, 0, 0]), + ]) { + await expectLater( + wallet.signTransaction(xdr), + throwsA(isA()), + ); + } + expect(bridge.signCalls, 0); + }); + + test('#69: refuses a fee-bump envelope before the prompt', () async { + await wallet.connect(); + final foreign = KeyPair.random(); + final inner = _payment(foreign.accountId)..sign(foreign, Network.TESTNET); + final feeBump = FeeBumpTransactionBuilder( + inner, + ).setBaseFee(200).setFeeAccount(key.accountId).build(); + await expectLater( + wallet.signTransaction(feeBump.toEnvelopeXdrBase64()), + throwsA(isA()), + ); + expect(bridge.signCalls, 0); + }); + + test('detects an account or network switch before the prompt', () async { + await wallet.connect(); + bridge.activeAddress = KeyPair.random().accountId; + await expectLater( + wallet.signTransaction(_payment(key.accountId).toEnvelopeXdrBase64()), + throwsA(isA()), + ); + bridge.activeAddress = null; + bridge.network = Network.PUBLIC.networkPassphrase; + await expectLater( + wallet.signTransaction(_payment(key.accountId).toEnvelopeXdrBase64()), + throwsA(isA()), + ); + expect(bridge.signCalls, 0); + }); + + test('rejects an answer that changed the transaction', () async { + await wallet.connect(); + bridge.signer = (_) { + final other = _payment(key.accountId, operationSource: key.accountId) + ..sign(key, Network.TESTNET); + return other.toEnvelopeXdrBase64(); + }; + await expectLater( + wallet.signTransaction(_payment(key.accountId).toEnvelopeXdrBase64()), + throwsA(isA()), + ); + }); + + test('rejects a signature from another key or for mainnet', () async { + await wallet.connect(); + bridge.signer = (xdr) { + final tx = + AbstractTransaction.fromEnvelopeXdrString(xdr) as Transaction; + tx.sign(KeyPair.random(), Network.TESTNET); + return tx.toEnvelopeXdrBase64(); + }; + await expectLater( + wallet.signTransaction(_payment(key.accountId).toEnvelopeXdrBase64()), + throwsA(isA()), + ); + bridge.signer = (xdr) { + final tx = + AbstractTransaction.fromEnvelopeXdrString(xdr) as Transaction; + tx.sign(key, Network.PUBLIC); + return tx.toEnvelopeXdrBase64(); + }; + await expectLater( + wallet.signTransaction(_payment(key.accountId).toEnvelopeXdrBase64()), + throwsA(isA()), + ); + }); + + test('a declined signature is a typed error', () async { + await wallet.connect(); + bridge.failure = const WalletSignatureRejected(); + await expectLater( + wallet.signTransaction(_payment(key.accountId).toEnvelopeXdrBase64()), + throwsA(isA()), + ); + }); + }); + + group('signAuthEntry', () { + test('signs the preimage like the SDK signer', () async { + await wallet.connect(); + final signedXdr = await wallet.signAuthEntry( + _authEntry(key.accountId).toBase64EncodedXdrString(), + ); + final expected = _authEntry(key.accountId)..sign(key, Network.TESTNET); + expect(signedXdr, expected.toBase64EncodedXdrString()); + }); + + test('#69: refuses an entry without an expiration ledger', () async { + await wallet.connect(); + await expectLater( + wallet.signAuthEntry( + _authEntry(key.accountId, expiration: 0).toBase64EncodedXdrString(), + ), + throwsA(isA()), + ); + expect(bridge.authCalls, 0); + }); + + test('#69: refuses trailing bytes before the prompt', () async { + await wallet.connect(); + final bytes = base64Decode( + _authEntry(key.accountId).toBase64EncodedXdrString(), + ); + await expectLater( + wallet.signAuthEntry(base64Encode([...bytes, 0, 0, 0, 0])), + throwsA(isA()), + ); + expect(bridge.authCalls, 0); + }); + + test('#69: refuses contract-address credentials', () async { + await wallet.connect(); + final entry = _authEntry( + key.accountId, + credentials: SorobanCredentials.forAddressLegacy( + Address.forContractId( + 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC', + ), + BigInt.one, + 4953189, + XdrSCVal.forVoid(), + ), + ); + await expectLater( + wallet.signAuthEntry(entry.toBase64EncodedXdrString()), + throwsA(isA()), + ); + expect(bridge.authCalls, 0); + }); + + test('refuses another account\'s entry and a foreign signature', () async { + await wallet.connect(); + await expectLater( + wallet.signAuthEntry( + _authEntry(KeyPair.random().accountId).toBase64EncodedXdrString(), + ), + throwsA(isA()), + ); + expect(bridge.authCalls, 0); + + final foreign = KeyPair.random(); + bridge.authSigner = (preimage) => AuthEntrySignature( + signature: base64Encode( + foreign.sign(Util.hash(base64Decode(preimage))), + ), + ); + await expectLater( + wallet.signAuthEntry( + _authEntry(key.accountId).toBase64EncodedXdrString(), + ), + throwsA(isA()), + ); + }); + }); +} diff --git a/puls3_flutter/test/wallet/wallet_connection_test.dart b/puls3_flutter/test/wallet/wallet_connection_test.dart new file mode 100644 index 00000000..1c4c6753 --- /dev/null +++ b/puls3_flutter/test/wallet/wallet_connection_test.dart @@ -0,0 +1,440 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:puls3_flutter/src/state/wallet_controller.dart'; +import 'package:puls3_flutter/src/theme/puls3_theme.dart'; +import 'package:puls3_flutter/src/ui/organisms/wallet_panel.dart'; +import 'package:puls3_flutter/src/wallet/mock_wallet.dart'; +import 'package:puls3_flutter/src/wallet/wallet_port.dart'; +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; + +import '../helpers.dart'; + +Finder _state(String name) => find.byKey(ValueKey('wallet-state-$name')); + +/// Opens the wallet sheet from the top bar chip. +Future _openSheet(WidgetTester tester) async { + await tester.tap(find.text('Connect wallet')); + await advance(tester, const Duration(milliseconds: 400)); +} + +void main() { + group('wallet sheet states (MockWallet)', () { + testWidgets('disconnected: a clear connect action', (tester) async { + await pumpApp(tester, location: '/market'); + await _openSheet(tester); + + expect(_state('disconnected'), findsOneWidget); + expect(find.text('Connect a wallet'), findsOneWidget); + expect(find.text('Connect Demo wallet'), findsOneWidget); + }); + + testWidgets('connecting, then connected with a short address, Testnet ' + 'and Disconnect', (tester) async { + final wallet = MockWallet(); + await pumpApp(tester, location: '/market', wallet: wallet); + await _openSheet(tester); + + await tester.tap(find.text('Connect Demo wallet')); + await tester.pump(const Duration(milliseconds: 100)); + expect(_state('connecting'), findsOneWidget); + expect(find.text('Waiting for Demo wallet…'), findsOneWidget); + + await advance(tester, const Duration(milliseconds: 600)); + expect(_state('connected'), findsOneWidget); + expect(find.text('Testnet'), findsOneWidget); + final address = wallet.address!; + final short = + '${address.substring(0, 4)}…${address.substring(address.length - 4)}'; + // In the sheet and in the app bar chip. + expect(find.text(short), findsNWidgets(2)); + + await tester.tap(find.text('Disconnect')); + await advance(tester, const Duration(milliseconds: 300)); + expect(_state('disconnected'), findsOneWidget); + expect(wallet.address, isNull); + expect(find.text('Connect wallet'), findsOneWidget); + }); + + testWidgets('rejected: recoverable, no crash', (tester) async { + final wallet = MockWallet()..rejectConnection = true; + await pumpApp(tester, location: '/market', wallet: wallet); + await _openSheet(tester); + + await tester.tap(find.text('Connect Demo wallet')); + await advance(tester, const Duration(milliseconds: 700)); + expect(_state('rejected'), findsOneWidget); + expect(find.text('Connection cancelled'), findsOneWidget); + expect(tester.takeException(), isNull); + + wallet.rejectConnection = false; + await tester.tap(find.text('Try again')); + await advance(tester, const Duration(milliseconds: 700)); + expect(_state('connected'), findsOneWidget); + }); + + testWidgets('wrong network: a clear error, then recovery', (tester) async { + final wallet = MockWallet() + ..walletNetwork = Network.PUBLIC.networkPassphrase; + await pumpApp(tester, location: '/market', wallet: wallet); + await _openSheet(tester); + + await tester.tap(find.text('Connect Demo wallet')); + await advance(tester, const Duration(milliseconds: 700)); + expect(_state('wrong-network'), findsOneWidget); + expect(find.text('Wrong network'), findsOneWidget); + // Never shown as connected on the wrong network. + expect(wallet.address, isNull); + + wallet.walletNetwork = stellarTestnetPassphrase; + await tester.tap(find.text('Try again')); + await advance(tester, const Duration(milliseconds: 700)); + expect(_state('connected'), findsOneWidget); + }); + + testWidgets('not installed: says so and offers to install', ( + tester, + ) async { + final wallet = MockWallet()..failure = const WalletNotInstalled(); + await pumpApp(tester, location: '/market', wallet: wallet); + await _openSheet(tester); + + await tester.tap(find.text('Connect Demo wallet')); + await advance(tester, const Duration(milliseconds: 700)); + expect(_state('not-installed'), findsOneWidget); + expect(find.text('Demo wallet not found'), findsOneWidget); + }); + + testWidgets('a connected chip opens the sheet to disconnect', ( + tester, + ) async { + final wallet = MockWallet(); + await pumpApp(tester, location: '/market', wallet: wallet); + await _openSheet(tester); + await tester.tap(find.text('Connect Demo wallet')); + await advance(tester, const Duration(milliseconds: 700)); + Navigator.of(tester.element(_state('connected'))).pop(); + await advance(tester, const Duration(milliseconds: 400)); + + final address = wallet.address!; + await tester.tap( + find.text( + '${address.substring(0, 4)}…${address.substring(address.length - 4)}', + ), + ); + await advance(tester, const Duration(milliseconds: 400)); + expect(_state('connected'), findsOneWidget); + expect(find.text('Disconnect'), findsOneWidget); + }); + + testWidgets('on a phone the sheet fits without overflow', (tester) async { + await pumpApp(tester, location: '/market', size: const Size(360, 800)); + await tester.tap(find.text('Connect')); + await advance(tester, const Duration(milliseconds: 400)); + await tester.tap(find.text('Connect Demo wallet')); + await advance(tester, const Duration(milliseconds: 700)); + expect(_state('connected'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); + + group('WalletPanel (presentational)', () { + Future pumpPanel(WidgetTester tester, WalletPanel panel) async { + Puls3Fonts.useGoogleFonts = false; + await tester.pumpWidget( + MaterialApp( + theme: Puls3Theme.dark(), + home: Scaffold(body: panel), + ), + ); + } + + testWidgets('not installed shows an install action when there is a page', ( + tester, + ) async { + var installs = 0; + await pumpPanel( + tester, + WalletPanel( + status: WalletStatus.error, + walletName: 'Freighter', + error: const WalletNotInstalled(), + onConnect: () {}, + onDisconnect: () {}, + onInstall: () => installs++, + ), + ); + expect(find.text('Freighter not found'), findsOneWidget); + await tester.tap(find.text('Install Freighter')); + expect(installs, 1); + }); + + testWidgets('a locked wallet asks to unlock', (tester) async { + await pumpPanel( + tester, + WalletPanel( + status: WalletStatus.error, + walletName: 'Freighter', + error: const WalletUnavailable(), + onConnect: () {}, + onDisconnect: () {}, + ), + ); + expect(find.text('Freighter is locked'), findsOneWidget); + }); + }); + + group('WalletController', () { + test('tryConnect keeps a failure as state instead of throwing', () async { + final controller = WalletController( + MockWallet(connectDelay: Duration.zero)..rejectConnection = true, + ); + expect(await controller.tryConnect(), isFalse); + expect(controller.status, WalletStatus.rejected); + expect(controller.lastError, isA()); + }); + + test('connect still throws for flows that map errors themselves', () { + final controller = WalletController( + MockWallet(connectDelay: Duration.zero) + ..walletNetwork = Network.PUBLIC.networkPassphrase, + ); + expect(controller.connect(), throwsA(isA())); + }); + }); + + group('signing (WalletController + MockWallet)', () { + const unsignedXdr = 'AAAA-server-prepared-envelope'; + + Future connected(MockWallet wallet) async { + final controller = WalletController(wallet); + expect(await controller.tryConnect(), isTrue); + expect(controller.status, WalletStatus.connected); + return controller; + } + + MockWallet fastWallet() => MockWallet( + connectDelay: Duration.zero, + signDelay: const Duration(milliseconds: 10), + ); + + test( + 'signing, then signed: the XDR reaches the wallet unchanged', + () async { + final controller = await connected(fastWallet()); + final signing = controller.signTransaction(unsignedXdr); + expect(controller.status, WalletStatus.signing); + + final signed = await signing; + // MockWallet marks exactly what it was given. + expect(signed, '${MockWallet.signedPrefix}$unsignedXdr'); + expect(controller.status, WalletStatus.signed); + }, + ); + + test('a rejected signature is recoverable', () async { + final wallet = fastWallet(); + final controller = await connected(wallet); + wallet.rejectSignatures = true; + + await expectLater( + controller.signTransaction(unsignedXdr), + throwsA(isA()), + ); + expect(controller.status, WalletStatus.rejected); + // Still connected: dismissing returns to the account. + expect(await controller.tryConnect(), isTrue); + expect(controller.status, WalletStatus.connected); + }); + + test('a signing error is a typed error state', () async { + final wallet = fastWallet(); + final controller = await connected(wallet); + wallet.failure = const WalletUnavailable(); + + await expectLater( + controller.signTransaction(unsignedXdr), + throwsA(isA()), + ); + expect(controller.status, WalletStatus.error); + }); + + test('a wrong network blocks signing before any prompt', () async { + final wallet = fastWallet(); + final controller = await connected(wallet); + wallet.walletNetwork = Network.PUBLIC.networkPassphrase; + + await expectLater( + controller.signTransaction(unsignedXdr), + throwsA(isA()), + ); + expect(controller.status, WalletStatus.wrongNetwork); + // The session is forgotten: the user must reconnect on Testnet. + expect(controller.address, isNull); + }); + + test('disconnect returns to disconnected', () async { + final controller = await connected(fastWallet()); + await controller.signTransaction(unsignedXdr); + await controller.disconnect(); + expect(controller.status, WalletStatus.disconnected); + expect(controller.address, isNull); + }); + }); + + group('WalletPanel signing states', () { + Future pumpPanel(WidgetTester tester, WalletPanel panel) async { + Puls3Fonts.useGoogleFonts = false; + await tester.pumpWidget( + MaterialApp( + theme: Puls3Theme.dark(), + home: Scaffold(body: panel), + ), + ); + } + + const address = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H'; + + testWidgets('signing waits for the wallet', (tester) async { + await pumpPanel( + tester, + WalletPanel( + status: WalletStatus.signing, + walletName: 'Freighter', + address: address, + onConnect: () {}, + onDisconnect: () {}, + ), + ); + expect(_state('signing'), findsOneWidget); + expect(find.text('Waiting for wallet confirmation…'), findsOneWidget); + }); + + testWidgets('signed keeps the account and says it was signed', ( + tester, + ) async { + await pumpPanel( + tester, + WalletPanel( + status: WalletStatus.signed, + walletName: 'Freighter', + address: address, + onConnect: () {}, + onDisconnect: () {}, + ), + ); + expect(_state('signed'), findsOneWidget); + expect(find.text('Transaction signed'), findsOneWidget); + expect(find.text('Disconnect'), findsOneWidget); + }); + + testWidgets('a rejected signature says so and offers a retry', ( + tester, + ) async { + var retries = 0; + await pumpPanel( + tester, + WalletPanel( + status: WalletStatus.rejected, + walletName: 'Freighter', + address: address, + error: const WalletSignatureRejected(), + onConnect: () => retries++, + onDisconnect: () {}, + ), + ); + expect(find.text('Signature rejected'), findsOneWidget); + expect( + find.text( + 'You rejected the transaction in Freighter. Nothing was sent.', + ), + findsOneWidget, + ); + await tester.tap(find.text('Try again')); + expect(retries, 1); + }); + + testWidgets('any other failure is a recoverable error', (tester) async { + await pumpPanel( + tester, + WalletPanel( + status: WalletStatus.error, + walletName: 'Freighter', + error: const WalletAccountChanged(), + onConnect: () {}, + onDisconnect: () {}, + ), + ); + expect(find.text('Account changed'), findsOneWidget); + expect(find.text('Try again'), findsOneWidget); + }); + }); + + group('wallet prompts that never settle (#113 review)', () { + test('a connect that never answers times out instead of hanging', () async { + // The prompt answers after 1 s; the limit is 20 ms. + final controller = WalletController( + MockWallet(connectDelay: const Duration(seconds: 1)), + connectTimeout: const Duration(milliseconds: 20), + ); + await expectLater(controller.connect(), throwsA(isA())); + expect(controller.status, WalletStatus.error); + expect(controller.isConnecting, isFalse); + }); + + test('disconnecting during a pending connect stays disconnected', () async { + final wallet = MockWallet( + connectDelay: const Duration(milliseconds: 20), + ); + final controller = WalletController(wallet); + final connecting = controller.connect(); + expect(controller.status, WalletStatus.connecting); + + await controller.disconnect(); + await expectLater(connecting, throwsA(isA())); + expect(wallet.address, isNull); + expect(controller.status, WalletStatus.disconnected); + }); + + test('an account switch while signing forgets the session', () async { + final wallet = MockWallet( + connectDelay: Duration.zero, + signDelay: Duration.zero, + ); + final controller = WalletController(wallet); + await controller.connect(); + wallet.failure = const WalletAccountChanged(); + + await expectLater( + controller.signTransaction('AAAA-unsigned'), + throwsA(isA()), + ); + expect(controller.status, WalletStatus.error); + expect(controller.lastError, isA()); + // The next attempt must connect again. + expect(controller.address, isNull); + }); + + testWidgets('a timed-out prompt says so and offers a retry', ( + tester, + ) async { + Puls3Fonts.useGoogleFonts = false; + await tester.pumpWidget( + MaterialApp( + theme: Puls3Theme.dark(), + home: Scaffold( + body: WalletPanel( + status: WalletStatus.error, + walletName: 'Freighter', + error: const WalletTimedOut(), + onConnect: () {}, + onDisconnect: () {}, + ), + ), + ), + ); + expect(_state('timed-out'), findsOneWidget); + expect(find.text('No answer from Freighter'), findsOneWidget); + expect(find.text('Try again'), findsOneWidget); + }); + }); +} diff --git a/puls3_flutter/web/freighter_bridge.js b/puls3_flutter/web/freighter_bridge.js new file mode 100644 index 00000000..6116f08b --- /dev/null +++ b/puls3_flutter/web/freighter_bridge.js @@ -0,0 +1,73 @@ +// Freighter bridge for the Flutter app (#25), from the #70 spike. +// +// @stellar/freighter-api v6.0.1 is loaded from a pinned CDN URL, as in the +// spike. Production must bundle it locally and enforce a CSP. +// v6.0.1 is a CommonJS/UMD bundle: esm.sh exposes its API as the default export. +import freighterApi from 'https://esm.sh/@stellar/freighter-api@6.0.1?bundle'; + +const { isConnected, requestAccess, getAddress, getNetworkDetails, signTransaction, signAuthEntry } = freighterApi; + +// Every failure leaves here as ": " so Dart can type it. +const fail = (code, message) => { throw new Error(`${code}: ${message}`); }; +const checked = (result, code) => { + if (result?.error) fail(code, result.error.message ?? String(result.error)); + return result; +}; + +const session = async () => { + const account = checked(await getAddress(), 'unavailable'); + const network = checked(await getNetworkDetails(), 'unavailable'); + if (!account.address) fail('unavailable', 'Freighter has no connected account'); + return JSON.stringify({ address: account.address, networkPassphrase: network.networkPassphrase }); +}; + +window.puls3FreighterConnect = async () => { + let connected; + try { + connected = await isConnected(); + } catch (e) { + fail('not_installed', 'Freighter is not installed'); + } + if (!connected?.isConnected) fail('not_installed', 'Freighter is not installed'); + checked(await requestAccess(), 'rejected'); + return session(); +}; + +window.puls3FreighterCurrentSession = session; + +const requireSession = async (expectedNetwork, expectedAddress) => { + const network = checked(await getNetworkDetails(), 'unavailable'); + const account = checked(await getAddress(), 'unavailable'); + if (network.networkPassphrase !== expectedNetwork) fail('wrong_network', 'Freighter network changed'); + if (account.address !== expectedAddress) fail('account_changed', 'Freighter account changed'); +}; + +window.puls3FreighterSignTransaction = async (xdr, networkPassphrase, address) => { + await requireSession(networkPassphrase, address); + return checked(await signTransaction(xdr, { networkPassphrase, address }), 'rejected').signedTxXdr; +}; + +// Freighter's signAuthEntry signs a HashIdPreimage and answers with the raw +// ed25519 signature over sha256(preimage). Its runtime shape is not pinned: +// accept base64, Uint8Array/ArrayBuffer or a serialized Buffer; return base64. +const bytesToBase64 = (bytes) => { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +}; +const signatureToBase64 = (value) => { + if (typeof value === 'string') return value; + if (value instanceof Uint8Array) return bytesToBase64(value); + if (value instanceof ArrayBuffer) return bytesToBase64(new Uint8Array(value)); + if (ArrayBuffer.isView(value)) return bytesToBase64(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)); + if (value?.type === 'Buffer' && Array.isArray(value.data)) return bytesToBase64(Uint8Array.from(value.data)); + fail('invalid', 'Freighter returned an unsupported auth-entry signature shape'); +}; + +window.puls3FreighterSignAuthEntryPreimage = async (preimageXdr, networkPassphrase, address) => { + await requireSession(networkPassphrase, address); + const result = checked(await signAuthEntry(preimageXdr, { networkPassphrase, address }), 'rejected'); + if (!result.signedAuthEntry) fail('rejected', 'Freighter returned no auth-entry signature'); + if (result.signerAddress && result.signerAddress !== address) fail('account_changed', 'Freighter signed with another account'); + return JSON.stringify({ signature: signatureToBase64(result.signedAuthEntry), signerAddress: result.signerAddress || null }); +}; diff --git a/puls3_flutter/web/index.html b/puls3_flutter/web/index.html index 7c75b379..735e4544 100644 --- a/puls3_flutter/web/index.html +++ b/puls3_flutter/web/index.html @@ -76,6 +76,19 @@ setTimeout(function () { el.remove(); }, 350); }, { once: true }); - +