From 4f996faf2401008036c12d3195af553fe29b5b80 Mon Sep 17 00:00:00 2001 From: Pericena Date: Wed, 7 Oct 2026 00:26:50 -0400 Subject: [PATCH 1/3] feat(flutter): wallet connection with Freighter and a mock wallet Refs #25. Built on the deploy flow (#112). The real-wallet acceptance on Testnet (screenshot) and the server relay (#96/#18) are pending. - WalletPort keeps one signing boundary: connect, disconnect, address, network, signTransaction (returns the signed XDR, the server submits) and signAuthEntry. Typed errors: rejected, not installed, locked/ unavailable, wrong network, account changed, invalid payload. - FreighterWallet over a small JS bridge (freighter-api 6.0.1). Before any prompt it checks the payload (#69): Testnet passphrase, no fee-bump envelopes, no trailing bytes, auth entries with a non-zero expiration and address credentials only. After signing it checks the account did not change. - MockWallet for tests and the demo console (--dart-define=WALLET=mock); Freighter is the default on the web. - Wallet sheet (S09): disconnected, connecting, connected (Testnet badge, copyable address, disconnect), cancelled, wrong network, not installed (install link), locked, with a retry in every error. - Demo flows (deploy, hire) sign a harmless, never-submitted Testnet envelope so a real wallet shows its prompt; they stay labelled as demos. Wallet errors show human messages. Tests: 19 for the Freighter adapter checks, 11 for the connection states, chip and phone layout. 151 passing, analyze clean. Co-Authored-By: Claude Opus 5.5 --- puls3_flutter/lib/main.dart | 17 +- .../src/deploy/deploy_flow_controller.dart | 12 +- .../lib/src/deploy/fake_deploy_gateway.dart | 3 +- puls3_flutter/lib/src/screens/app_shell.dart | 3 +- puls3_flutter/lib/src/screens/hire_sheet.dart | 30 +- .../lib/src/screens/wallet_sheet.dart | 62 +++ .../lib/src/state/wallet_controller.dart | 61 ++- .../lib/src/ui/molecules/wallet_chip.dart | 2 +- .../lib/src/ui/organisms/wallet_panel.dart | 361 +++++++++++++++++ .../lib/src/wallet/demo_envelope.dart | 31 ++ .../freighter/create_freighter_bridge.dart | 6 + .../wallet/freighter/freighter_bridge.dart | 51 +++ .../freighter/freighter_bridge_stub.dart | 31 ++ .../freighter/freighter_bridge_web.dart | 126 ++++++ .../wallet/freighter/freighter_wallet.dart | 261 ++++++++++++ puls3_flutter/lib/src/wallet/mock_wallet.dart | 49 ++- puls3_flutter/lib/src/wallet/wallet_port.dart | 70 +++- puls3_flutter/pubspec.yaml | 2 + .../test/screens/hire_sheet_test.dart | 15 + .../test/screens/market_screen_test.dart | 5 + .../test/wallet/freighter_wallet_test.dart | 382 ++++++++++++++++++ .../test/wallet/wallet_connection_test.dart | 204 ++++++++++ puls3_flutter/web/freighter_bridge.js | 73 ++++ puls3_flutter/web/index.html | 15 +- 24 files changed, 1838 insertions(+), 34 deletions(-) create mode 100644 puls3_flutter/lib/src/screens/wallet_sheet.dart create mode 100644 puls3_flutter/lib/src/ui/organisms/wallet_panel.dart create mode 100644 puls3_flutter/lib/src/wallet/demo_envelope.dart create mode 100644 puls3_flutter/lib/src/wallet/freighter/create_freighter_bridge.dart create mode 100644 puls3_flutter/lib/src/wallet/freighter/freighter_bridge.dart create mode 100644 puls3_flutter/lib/src/wallet/freighter/freighter_bridge_stub.dart create mode 100644 puls3_flutter/lib/src/wallet/freighter/freighter_bridge_web.dart create mode 100644 puls3_flutter/lib/src/wallet/freighter/freighter_wallet.dart create mode 100644 puls3_flutter/test/wallet/freighter_wallet_test.dart create mode 100644 puls3_flutter/test/wallet/wallet_connection_test.dart create mode 100644 puls3_flutter/web/freighter_bridge.js diff --git a/puls3_flutter/lib/main.dart b/puls3_flutter/lib/main.dart index ab472257..7466ba1f 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,21 @@ 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() => _walletKind == 'freighter' + ? FreighterWallet(createFreighterBridge()) + : MockWallet(); /// Starts the demo shell and verifies the generated Serverpod client connection. Future main() async { @@ -25,7 +40,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..ed8bc80d 100644 --- a/puls3_flutter/lib/src/deploy/deploy_flow_controller.dart +++ b/puls3_flutter/lib/src/deploy/deploy_flow_controller.dart @@ -296,7 +296,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, ), 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..c9bfce3d 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,18 @@ 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.', + 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..ce6f648a --- /dev/null +++ b/puls3_flutter/lib/src/screens/wallet_sheet.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../state/app_scope.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 < 600; + 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..468e7fea 100644 --- a/puls3_flutter/lib/src/state/wallet_controller.dart +++ b/puls3_flutter/lib/src/state/wallet_controller.dart @@ -2,21 +2,45 @@ 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. +enum WalletStatus { disconnected, connecting, connected, error } + +/// Observable wrapper around a [WalletPort]: the container that owns the +/// connection state (#25). Presentational widgets get its values through +/// their constructors. class WalletController extends ChangeNotifier { WalletController(this._wallet); final WalletPort _wallet; bool _connecting = 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 isOnTestnet => _wallet.network == stellarTestnetPassphrase; + + /// Why the last connection attempt from the UI failed, until the next one. + WalletException? get lastError => _lastError; + + WalletStatus get status { + if (_connecting) return WalletStatus.connecting; + if (isConnected) return WalletStatus.connected; + if (_lastError != null) return WalletStatus.error; + 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; + _lastError = null; notifyListeners(); try { return await _wallet.connect(); @@ -26,9 +50,40 @@ class WalletController extends ChangeNotifier { } } + /// Connects from a button: a failure becomes [lastError] (a recoverable + /// state), never an unhandled exception. Returns whether it connected. + Future tryConnect() async { + if (_connecting) return false; + try { + await connect(); + return true; + } on WalletException catch (e) { + _lastError = e; + notifyListeners(); + return false; + } + } + + void clearError() { + if (_lastError == null) return; + _lastError = null; + notifyListeners(); + } + + Future disconnect() async { + await _wallet.disconnect(); + _lastError = null; + notifyListeners(); + } + + /// Signs a server-prepared transaction unchanged; returns the signed XDR. Future signTransaction(String unsignedXdr) async { - final hash = await _wallet.signTransaction(unsignedXdr); + final signed = await _wallet.signTransaction(unsignedXdr); notifyListeners(); - return hash; + return signed; } + + /// Signs a Soroban authorization entry; returns the signed entry XDR. + Future signAuthEntry(String entryXdr) => + _wallet.signAuthEntry(entryXdr); } 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..7e5e5dae --- /dev/null +++ b/puls3_flutter/lib/src/ui/organisms/wallet_panel.dart @@ -0,0 +1,361 @@ +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 when address != null => _Connected( + address: address, + walletName: walletName, + isOnTestnet: isOnTestnet, + onDisconnect: onDisconnect, + ), + WalletStatus.connecting => _Connecting(walletName: walletName), + WalletStatus.error => _Error( + error: error, + walletName: walletName, + 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 ClipRRect( + borderRadius: Puls3Radius.pillAll, + child: LinearProgressIndicator( + minHeight: 3, + color: Puls3Colors.accent, + backgroundColor: Puls3Colors.hairline, + ), + ), + ], + ); + } +} + +class _Connected extends StatelessWidget { + const _Connected({ + required this.address, + required this.walletName, + required this.isOnTestnet, + required this.onDisconnect, + }); + + final String address; + final String walletName; + final bool isOnTestnet; + final VoidCallback onDisconnect; + + @override + Widget build(BuildContext context) { + return Column( + key: const ValueKey('wallet-state-connected'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Header( + icon: Icons.check_circle_rounded, + title: 'Connected', + message: '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.onInstall, + }); + + final WalletException? error; + final String walletName; + final VoidCallback onRetry; + final VoidCallback? onInstall; + + @override + Widget build(BuildContext context) { + final (key, icon, title, message) = switch (error) { + WalletSignatureRejected() => ( + 'rejected', + Icons.block_rounded, + 'Connection cancelled', + 'You declined the request in $walletName. Nothing was shared.', + ), + WalletWrongNetwork() => ( + 'wrong-network', + Icons.swap_horiz_rounded, + 'Switch your wallet to Testnet', + '$walletName is on another network. Open its settings, choose ' + 'Testnet, then try again.', + ), + 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, + 'Could not connect', + '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..3ee0afa1 --- /dev/null +++ b/puls3_flutter/lib/src/wallet/freighter/freighter_bridge_web.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; +import 'dart:js_interop'; + +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:"). + return const WalletSignatureRejected(); + } +} 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..65a326d2 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,19 +11,33 @@ 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; String? _address; @@ -30,6 +45,12 @@ class MockWallet implements WalletPort { @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(); @@ -38,18 +59,30 @@ class MockWallet implements WalletPort { await Future.delayed(connectDelay); final failure = this.failure; if (failure != null) throw failure; + if (rejectConnection) throw const WalletSignatureRejected(); + if (walletNetwork != stellarTestnetPassphrase) { + throw const WalletWrongNetwork(); + } return _address ??= _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..23e365b7 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,35 @@ 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. +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/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..6cc0954f --- /dev/null +++ b/puls3_flutter/test/wallet/freighter_wallet_test.dart @@ -0,0 +1,382 @@ +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', () { + bridge.network = Network.PUBLIC.networkPassphrase; + expect(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', () { + expect( + 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..f1d5cfcd --- /dev/null +++ b/puls3_flutter/test/wallet/wallet_connection_test.dart @@ -0,0 +1,204 @@ +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('Switch your wallet to Testnet'), 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.error); + 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())); + }); + }); +} 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 }); - + From 04fe5f820a98e779f9791bd30b6b3d0bdc55e8f1 Mon Sep 17 00:00:00 2001 From: Pericena Date: Wed, 7 Oct 2026 00:49:35 -0400 Subject: [PATCH 2/3] feat(flutter): explicit wallet states for signing and a Freighter test guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #25. - WalletStatus is one explicit state: disconnected, connecting, connected, signing, signed, rejected, wrongNetwork, error. No mix of flags. - WalletController blocks signing before any prompt when the wallet is not on Testnet, for every adapter. A network or account switch during signing forgets the session, so the next attempt connects again; the deploy flow reconnects before re-signing. - Wallet sheet: "Waiting for wallet confirmation…", "Transaction signed", "Signature rejected", "Wrong network", "Account changed", "Transaction refused" and "Wallet connection failed", each with a retry. - MockWallet keeps its account across a disconnect, like a real wallet. - README: wallet architecture, how to test with Freighter, and the commands to build and test. Tests: signing, signed, rejected signature, signing error, wrong network blocks signing, disconnect, and the panel states. 160 passing, analyze clean. Co-Authored-By: Claude Opus 5.5 --- puls3_flutter/README.md | 46 +++++ .../src/deploy/deploy_flow_controller.dart | 4 +- .../lib/src/state/wallet_controller.dart | 104 ++++++++--- .../lib/src/ui/organisms/wallet_panel.dart | 82 ++++++++- puls3_flutter/lib/src/wallet/mock_wallet.dart | 7 +- .../test/deploy/deploy_flow_test.dart | 3 + .../test/wallet/wallet_connection_test.dart | 171 +++++++++++++++++- 7 files changed, 383 insertions(+), 34 deletions(-) diff --git a/puls3_flutter/README.md b/puls3_flutter/README.md index e669ec54..d77b88cb 100644 --- a/puls3_flutter/README.md +++ b/puls3_flutter/README.md @@ -13,3 +13,49 @@ 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 + |- 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. + +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/src/deploy/deploy_flow_controller.dart b/puls3_flutter/lib/src/deploy/deploy_flow_controller.dart index ed8bc80d..d4d6d798 100644 --- a/puls3_flutter/lib/src/deploy/deploy_flow_controller.dart +++ b/puls3_flutter/lib/src/deploy/deploy_flow_controller.dart @@ -202,7 +202,9 @@ 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 _wallet.connect(); if (_wallet.address != _builder) throw const _AccountChanged(); return _waitForWallet( _wallet.signTransaction(prepared.unsignedTransaction), diff --git a/puls3_flutter/lib/src/state/wallet_controller.dart b/puls3_flutter/lib/src/state/wallet_controller.dart index 468e7fea..459be83b 100644 --- a/puls3_flutter/lib/src/state/wallet_controller.dart +++ b/puls3_flutter/lib/src/state/wallet_controller.dart @@ -2,17 +2,41 @@ import 'package:flutter/foundation.dart'; import '../wallet/wallet_port.dart'; -/// The wallet as the UI sees it. -enum WalletStatus { disconnected, connecting, connected, error } +/// 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. +/// their constructors. It knows nothing about deploys, hires or escrow. class WalletController extends ChangeNotifier { WalletController(this._wallet); final WalletPort _wallet; - bool _connecting = false; + + /// [WalletStatus.connecting] or [WalletStatus.signing] while the wallet + /// is busy; null otherwise. + WalletStatus? _busy; + bool _signed = false; WalletException? _lastError; String get walletName => _wallet.name; @@ -20,16 +44,26 @@ class WalletController extends ChangeNotifier { 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 attempt from the UI failed, until the next one. + /// Why the last connection or signature failed, until the next attempt. WalletException? get lastError => _lastError; WalletStatus get status { - if (_connecting) return WalletStatus.connecting; - if (isConnected) return WalletStatus.connected; - if (_lastError != null) return WalletStatus.error; + 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; } @@ -39,13 +73,17 @@ class WalletController extends ChangeNotifier { Future connect() async { final existing = _wallet.address; if (existing != null) return existing; - _connecting = true; + _busy = WalletStatus.connecting; _lastError = null; + _signed = false; notifyListeners(); try { return await _wallet.connect(); + } on WalletException catch (e) { + _lastError = e; + rethrow; } finally { - _connecting = false; + _busy = null; notifyListeners(); } } @@ -53,13 +91,12 @@ class WalletController extends ChangeNotifier { /// Connects from a button: a failure becomes [lastError] (a recoverable /// state), never an unhandled exception. Returns whether it connected. Future tryConnect() async { - if (_connecting) return false; + if (_busy != null) return false; + _lastError = null; try { await connect(); return true; - } on WalletException catch (e) { - _lastError = e; - notifyListeners(); + } on WalletException { return false; } } @@ -73,17 +110,42 @@ class WalletController extends ChangeNotifier { Future disconnect() async { await _wallet.disconnect(); _lastError = null; + _signed = false; notifyListeners(); } /// Signs a server-prepared transaction unchanged; returns the signed XDR. - Future signTransaction(String unsignedXdr) async { - final signed = await _wallet.signTransaction(unsignedXdr); - notifyListeners(); - return signed; - } + /// + /// 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) => - _wallet.signAuthEntry(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/organisms/wallet_panel.dart b/puls3_flutter/lib/src/ui/organisms/wallet_panel.dart index 7e5e5dae..d909b7b0 100644 --- a/puls3_flutter/lib/src/ui/organisms/wallet_panel.dart +++ b/puls3_flutter/lib/src/ui/organisms/wallet_panel.dart @@ -38,16 +38,24 @@ class WalletPanel extends StatelessWidget { Widget build(BuildContext context) { final address = this.address; final Widget body = switch (status) { - WalletStatus.connected when address != null => _Connected( + 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, ), @@ -176,29 +184,66 @@ class _Connecting extends StatelessWidget { } } +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 ClipRRect( + borderRadius: Puls3Radius.pillAll, + child: LinearProgressIndicator( + minHeight: 3, + color: Puls3Colors.accent, + backgroundColor: Puls3Colors.hairline, + ), + ), + ], + ); + } +} + 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: const ValueKey('wallet-state-connected'), + key: ValueKey(signed ? 'wallet-state-signed' : 'wallet-state-connected'), crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _Header( icon: Icons.check_circle_rounded, - title: 'Connected', - message: 'Signing with $walletName.', + 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), @@ -281,17 +326,25 @@ class _Error extends StatelessWidget { 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, @@ -301,9 +354,22 @@ class _Error extends StatelessWidget { WalletWrongNetwork() => ( 'wrong-network', Icons.swap_horiz_rounded, - 'Switch your wallet to Testnet', - '$walletName is on another network. Open its settings, choose ' - 'Testnet, then try again.', + '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.', + ), + WalletInvalidPayload() => ( + 'invalid-payload', + Icons.gpp_bad_outlined, + 'Transaction refused', + 'puls3 refused to ask $walletName to sign this transaction. ' + 'Nothing was signed.', ), WalletNotInstalled() => ( 'not-installed', @@ -321,7 +387,7 @@ class _Error extends StatelessWidget { _ => ( 'failed', Icons.error_outline_rounded, - 'Could not connect', + 'Wallet connection failed', 'Something went wrong with $walletName. Try again.', ), }; diff --git a/puls3_flutter/lib/src/wallet/mock_wallet.dart b/puls3_flutter/lib/src/wallet/mock_wallet.dart index 65a326d2..0cb84c06 100644 --- a/puls3_flutter/lib/src/wallet/mock_wallet.dart +++ b/puls3_flutter/lib/src/wallet/mock_wallet.dart @@ -40,6 +40,9 @@ class MockWallet implements WalletPort { /// [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 @@ -52,7 +55,7 @@ class MockWallet implements WalletPort { 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 { @@ -63,7 +66,7 @@ class MockWallet implements WalletPort { if (walletNetwork != stellarTestnetPassphrase) { throw const WalletWrongNetwork(); } - return _address ??= _ids.accountAddress(); + return _address = _account ??= _ids.accountAddress(); } /// Returns [unsignedXdr] marked as signed: a stand-in for the signed 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/wallet/wallet_connection_test.dart b/puls3_flutter/test/wallet/wallet_connection_test.dart index f1d5cfcd..d09fbac5 100644 --- a/puls3_flutter/test/wallet/wallet_connection_test.dart +++ b/puls3_flutter/test/wallet/wallet_connection_test.dart @@ -81,7 +81,7 @@ void main() { await tester.tap(find.text('Connect Demo wallet')); await advance(tester, const Duration(milliseconds: 700)); expect(_state('wrong-network'), findsOneWidget); - expect(find.text('Switch your wallet to Testnet'), findsOneWidget); + expect(find.text('Wrong network'), findsOneWidget); // Never shown as connected on the wrong network. expect(wallet.address, isNull); @@ -189,7 +189,7 @@ void main() { MockWallet(connectDelay: Duration.zero)..rejectConnection = true, ); expect(await controller.tryConnect(), isFalse); - expect(controller.status, WalletStatus.error); + expect(controller.status, WalletStatus.rejected); expect(controller.lastError, isA()); }); @@ -201,4 +201,171 @@ void main() { 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); + }); + }); } From 0d3978fee8ff72232c7840ad4ed8852858360d10 Mon Sep 17 00:00:00 2001 From: Pericena Date: Wed, 7 Oct 2026 23:35:49 -0400 Subject: [PATCH 3/3] fix(flutter): wallet prompts that never answer and review cleanups Review of #113. - WalletController.connect() times out (2 minutes) with WalletTimedOut, so an ignored, blocked or stuck Freighter popup never traps the deploy flow, the hire sheet or the wallet sheet. The deploy flow also times out its reconnect before re-signing and can be closed while any wallet prompt is open (from #112). - Disconnecting during a pending connect stays disconnected instead of reconnecting when the prompt settles. - The Freighter bridge maps only "rejected:" to a rejection; any other error is WalletUnavailable with a log line (no payload data), not "you rejected". - An unknown --dart-define=WALLET value fails at startup instead of silently using the mock wallet. - Wallet sheet uses Puls3Breakpoints.narrowGutter; one shared waiting bar; README lists signAuthEntry and the timeout. Tests: two Freighter tests now await the future before checking state, so they can fail; new tests for a connect that never answers, disconnect during connect, an account switch while signing, and the timed-out panel. 165 passing, analyze clean. Co-Authored-By: Claude Opus 5.5 --- puls3_flutter/README.md | 7 +- puls3_flutter/lib/main.dart | 11 ++- .../src/deploy/deploy_flow_controller.dart | 6 +- puls3_flutter/lib/src/screens/hire_sheet.dart | 2 + .../lib/src/screens/wallet_sheet.dart | 4 +- .../lib/src/state/wallet_controller.dart | 35 ++++++++-- .../lib/src/ui/organisms/wallet_panel.dart | 42 ++++++----- .../freighter/freighter_bridge_web.dart | 8 ++- puls3_flutter/lib/src/wallet/wallet_port.dart | 6 ++ .../test/wallet/freighter_wallet_test.dart | 18 +++-- .../test/wallet/wallet_connection_test.dart | 69 +++++++++++++++++++ 11 files changed, 173 insertions(+), 35 deletions(-) diff --git a/puls3_flutter/README.md b/puls3_flutter/README.md index d77b88cb..02db9c86 100644 --- a/puls3_flutter/README.md +++ b/puls3_flutter/README.md @@ -20,7 +20,8 @@ To run the project, first make sure that the server is running, then do: UI (WalletPanel, WalletChip, deploy/hire flows) -> WalletController one WalletStatus: disconnected, connecting, connected, signing, signed, rejected, wrongNetwork, error - -> WalletPort connect, disconnect, address, network, signTransaction + -> WalletPort connect, disconnect, address, network, signTransaction, + signAuthEntry |- FreighterWallet real extension, via web/freighter_bridge.js |- MockWallet tests and demo console '- (another wallet: implement WalletPort) @@ -32,6 +33,10 @@ UI (WalletPanel, WalletChip, deploy/hire flows) 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: diff --git a/puls3_flutter/lib/main.dart b/puls3_flutter/lib/main.dart index 7466ba1f..c44a1524 100644 --- a/puls3_flutter/lib/main.dart +++ b/puls3_flutter/lib/main.dart @@ -20,9 +20,14 @@ const _walletKind = String.fromEnvironment( defaultValue: kIsWeb ? 'freighter' : 'mock', ); -WalletPort _createWallet() => _walletKind == 'freighter' - ? FreighterWallet(createFreighterBridge()) - : MockWallet(); +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 { diff --git a/puls3_flutter/lib/src/deploy/deploy_flow_controller.dart b/puls3_flutter/lib/src/deploy/deploy_flow_controller.dart index d4d6d798..38561cf7 100644 --- a/puls3_flutter/lib/src/deploy/deploy_flow_controller.dart +++ b/puls3_flutter/lib/src/deploy/deploy_flow_controller.dart @@ -204,7 +204,9 @@ class DeployFlowController extends ChangeNotifier { // The transaction names the account it was prepared for. If the // wallet switched account since, it cannot sign it. After a // network switch the wallet forgot its session: connect again. - if (_wallet.address == null) await _wallet.connect(); + if (_wallet.address == null) { + await _waitForWallet(_wallet.connect()); + } if (_wallet.address != _builder) throw const _AccountChanged(); return _waitForWallet( _wallet.signTransaction(prepared.unsignedTransaction), @@ -327,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/screens/hire_sheet.dart b/puls3_flutter/lib/src/screens/hire_sheet.dart index c9bfce3d..8b91ce06 100644 --- a/puls3_flutter/lib/src/screens/hire_sheet.dart +++ b/puls3_flutter/lib/src/screens/hire_sheet.dart @@ -107,6 +107,8 @@ String walletErrorMessage(WalletException e) => switch (e) { '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() => diff --git a/puls3_flutter/lib/src/screens/wallet_sheet.dart b/puls3_flutter/lib/src/screens/wallet_sheet.dart index ce6f648a..fcf5b6bb 100644 --- a/puls3_flutter/lib/src/screens/wallet_sheet.dart +++ b/puls3_flutter/lib/src/screens/wallet_sheet.dart @@ -4,6 +4,7 @@ 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'; @@ -25,7 +26,8 @@ class WalletSheet extends StatelessWidget { @override Widget build(BuildContext context) { final wallet = AppScope.of(context).wallet; - final narrow = MediaQuery.sizeOf(context).width < 600; + 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), diff --git a/puls3_flutter/lib/src/state/wallet_controller.dart b/puls3_flutter/lib/src/state/wallet_controller.dart index 459be83b..60bf7bea 100644 --- a/puls3_flutter/lib/src/state/wallet_controller.dart +++ b/puls3_flutter/lib/src/state/wallet_controller.dart @@ -29,10 +29,21 @@ enum WalletStatus { /// 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; + /// 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; @@ -73,17 +84,31 @@ class WalletController extends ChangeNotifier { Future connect() async { final existing = _wallet.address; if (existing != null) return existing; + 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) { - _lastError = e; + if (session == _session) _lastError = e; rethrow; } finally { - _busy = null; + if (session == _session) _busy = null; notifyListeners(); } } @@ -108,6 +133,8 @@ class WalletController extends ChangeNotifier { } Future disconnect() async { + _session++; + _busy = null; await _wallet.disconnect(); _lastError = null; _signed = false; diff --git a/puls3_flutter/lib/src/ui/organisms/wallet_panel.dart b/puls3_flutter/lib/src/ui/organisms/wallet_panel.dart index d909b7b0..9ed67295 100644 --- a/puls3_flutter/lib/src/ui/organisms/wallet_panel.dart +++ b/puls3_flutter/lib/src/ui/organisms/wallet_panel.dart @@ -171,19 +171,29 @@ class _Connecting extends StatelessWidget { color: Puls3Colors.accent, ), const SizedBox(height: Puls3Spacing.md), - const ClipRRect( - borderRadius: Puls3Radius.pillAll, - child: LinearProgressIndicator( - minHeight: 3, - color: Puls3Colors.accent, - backgroundColor: Puls3Colors.hairline, - ), - ), + 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}); @@ -204,14 +214,7 @@ class _Signing extends StatelessWidget { color: Puls3Colors.accent, ), const SizedBox(height: Puls3Spacing.md), - const ClipRRect( - borderRadius: Puls3Radius.pillAll, - child: LinearProgressIndicator( - minHeight: 3, - color: Puls3Colors.accent, - backgroundColor: Puls3Colors.hairline, - ), - ), + const _WaitingBar(), ], ); } @@ -364,6 +367,13 @@ class _Error extends StatelessWidget { '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, diff --git a/puls3_flutter/lib/src/wallet/freighter/freighter_bridge_web.dart b/puls3_flutter/lib/src/wallet/freighter/freighter_bridge_web.dart index 3ee0afa1..a9b06daf 100644 --- a/puls3_flutter/lib/src/wallet/freighter/freighter_bridge_web.dart +++ b/puls3_flutter/lib/src/wallet/freighter/freighter_bridge_web.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:js_interop'; +import 'package:flutter/foundation.dart'; + import '../wallet_port.dart'; import 'freighter_bridge.dart'; @@ -121,6 +123,10 @@ final class _FreighterWebBridge implements FreighterBridge { return const WalletInvalidPayload('Freighter returned bad data.'); } // Freighter reports a declined prompt as an error result ("rejected:"). - return const WalletSignatureRejected(); + 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/wallet_port.dart b/puls3_flutter/lib/src/wallet/wallet_port.dart index 23e365b7..1435ac51 100644 --- a/puls3_flutter/lib/src/wallet/wallet_port.dart +++ b/puls3_flutter/lib/src/wallet/wallet_port.dart @@ -82,6 +82,12 @@ final class WalletAccountChanged extends WalletException { /// 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); diff --git a/puls3_flutter/test/wallet/freighter_wallet_test.dart b/puls3_flutter/test/wallet/freighter_wallet_test.dart index 6cc0954f..142d79d2 100644 --- a/puls3_flutter/test/wallet/freighter_wallet_test.dart +++ b/puls3_flutter/test/wallet/freighter_wallet_test.dart @@ -125,11 +125,15 @@ void main() { expect(wallet.name, 'Freighter'); }); - test('refuses a wallet on another network and stays disconnected', () { - bridge.network = Network.PUBLIC.networkPassphrase; - expect(wallet.connect(), throwsA(isA())); - expect(wallet.address, isNull); - }); + 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(); @@ -180,8 +184,8 @@ void main() { ); }); - test('refuses before the prompt when not connected', () { - expect( + test('refuses before the prompt when not connected', () async { + await expectLater( wallet.signTransaction(_payment(key.accountId).toEnvelopeXdrBase64()), throwsA(isA()), ); diff --git a/puls3_flutter/test/wallet/wallet_connection_test.dart b/puls3_flutter/test/wallet/wallet_connection_test.dart index d09fbac5..1c4c6753 100644 --- a/puls3_flutter/test/wallet/wallet_connection_test.dart +++ b/puls3_flutter/test/wallet/wallet_connection_test.dart @@ -368,4 +368,73 @@ void main() { 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); + }); + }); }