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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions puls3_flutter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,54 @@ A great starting point for learning Serverpod is our documentation site at:
To run the project, first make sure that the server is running, then do:

flutter run

## Wallet (#25): Freighter and the mock wallet

```text
UI (WalletPanel, WalletChip, deploy/hire flows)
-> WalletController one WalletStatus: disconnected, connecting, connected,
signing, signed, rejected, wrongNetwork, error
-> WalletPort connect, disconnect, address, network, signTransaction,
signAuthEntry
|- FreighterWallet real extension, via web/freighter_bridge.js
|- MockWallet tests and demo console
'- (another wallet: implement WalletPort)
```

- The app only handles the public address, the network, the unsigned XDR
and the signed XDR. It never sees or stores a secret key.
- `signTransaction` sends the server-prepared XDR to the wallet unchanged and
returns the signed XDR; the server submits it. Before the prompt it refuses
a wallet not on Stellar Testnet, fee-bump envelopes, trailing bytes and
transactions for another account (#69). Mainnet is not supported.
- `signAuthEntry` signs a Soroban authorization entry for the connected
account (non-zero expiration, address credentials only).
- A wallet prompt that never answers times out (connect: 2 minutes), so a
blocked or ignored popup never traps the user.

Freighter is the default on the web. To use the mock wallet instead:

flutter run -d chrome --dart-define=WALLET=mock

### Testing with Freighter

1. Install the [Freighter](https://www.freighter.app/) extension in Chrome,
create or import a **test** account and choose **Testnet** in its settings.
2. Run `flutter run -d chrome` (or build into the server, below).
3. Tap **Connect wallet** -> **Connect Freighter** and approve in Freighter.
The sheet shows the short address and a Testnet badge.
4. Deploy an agent from Studio: Freighter shows the signing prompt. Until the
relay (#96) and deploy endpoint (#18) exist the transaction is a harmless
demo envelope that is never submitted, and the flow is labelled as a demo.
5. Also check: reject the connection, reject the signature, switch Freighter
to another network (Wrong network), Disconnect, and a browser without
Freighter (Freighter not found, with an install link).

Build into the Serverpod web folder (served at `localhost:8082/app`):

flutter build web --base-href /app/ --wasm -o <absolute path>/puls3_server/web/app

Tests:

flutter analyze
flutter test
22 changes: 21 additions & 1 deletion puls3_flutter/lib/main.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -7,7 +8,26 @@ import 'src/data/agent_repository.dart';
import 'src/data/app_config.dart';
import 'src/data/fallback_agent_repository.dart';
import 'src/data/server_agent_repository.dart';
import 'src/wallet/freighter/create_freighter_bridge.dart';
import 'src/wallet/freighter/freighter_wallet.dart';
import 'src/wallet/mock_wallet.dart';
import 'src/wallet/wallet_port.dart';

/// Which wallet the app signs with: `freighter` (default on the web) or
/// `mock` for the demo console (#32): `--dart-define=WALLET=mock`.
const _walletKind = String.fromEnvironment(
'WALLET',
defaultValue: kIsWeb ? 'freighter' : 'mock',
);

WalletPort _createWallet() => switch (_walletKind) {
'freighter' => FreighterWallet(createFreighterBridge()),
'mock' => MockWallet(),
// A typo must not silently ship the mock wallet.
_ => throw StateError(
'Unknown WALLET "$_walletKind": use "freighter" or "mock".',
),
};

/// Starts the demo shell and verifies the generated Serverpod client connection.
Future<void> main() async {
Expand All @@ -25,7 +45,7 @@ Future<void> main() async {
ServerAgentRepository(() => client.agent.list()),
AssetAgentRepository(),
),
wallet: MockWallet(),
wallet: _createWallet(),
healthCheck: client.health.check().then((health) => health.version),
),
);
Expand Down
20 changes: 17 additions & 3 deletions puls3_flutter/lib/src/deploy/deploy_flow_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,11 @@ class DeployFlowController extends ChangeNotifier {
_signed ??
await _attempt<String>(DeployStep.awaitingSignature, () async {
// The transaction names the account it was prepared for. If the
// wallet switched account since, it cannot sign it.
// wallet switched account since, it cannot sign it. After a
// network switch the wallet forgot its session: connect again.
if (_wallet.address == null) {
await _waitForWallet(_wallet.connect());
}
if (_wallet.address != _builder) throw const _AccountChanged();
return _waitForWallet(
_wallet.signTransaction(prepared.unsignedTransaction),
Expand Down Expand Up @@ -296,7 +300,17 @@ class DeployFlowController extends ChangeNotifier {
kind: DeployErrorKind.signatureRejected,
step: step,
),
WalletUnavailable() => DeployError(
WalletAccountChanged() => DeployError(
kind: DeployErrorKind.accountChanged,
step: step,
),
// The wallet adapter refused the prepared transaction before signing.
WalletInvalidPayload(:final reason) => DeployError(
kind: DeployErrorKind.invalidResponse,
step: step,
detail: reason,
),
WalletNotInstalled() || WalletUnavailable() => DeployError(
kind: DeployErrorKind.walletUnavailable,
step: step,
),
Expand All @@ -315,7 +329,7 @@ class DeployFlowController extends ChangeNotifier {
detail: message,
transactionHash: transactionHash,
),
_WalletTimeout() => DeployError(
WalletTimedOut() || _WalletTimeout() => DeployError(
kind: DeployErrorKind.walletTimeout,
step: step,
),
Expand Down
3 changes: 2 additions & 1 deletion puls3_flutter/lib/src/deploy/fake_deploy_gateway.dart
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -31,7 +32,7 @@ class FakeDeployGateway implements DeployGateway {
await Future<void>.delayed(prepareDelay);
return PreparedDeploy(
preparationId: _ids.txHash().substring(0, 16),
unsignedTransaction: 'AAAA-fake-register_full-envelope',
unsignedTransaction: demoEnvelope(builder, note: 'deploy demo'),
);
}

Expand Down
3 changes: 2 additions & 1 deletion puls3_flutter/lib/src/screens/app_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
),
),
),
Expand Down
32 changes: 27 additions & 5 deletions puls3_flutter/lib/src/screens/hire_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> showHireSheet(BuildContext context, Agent agent) {
Expand Down Expand Up @@ -42,19 +44,22 @@ class _HireSheetState extends State<HireSheet> {
}
// 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);
} catch (e) {
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',
};
});
}
}
Expand Down Expand Up @@ -92,3 +97,20 @@ class _HireSheetState extends State<HireSheet> {
);
}
}

/// A human message for a wallet failure. In every case nothing was signed,
/// so no funds moved.
String walletErrorMessage(WalletException e) => switch (e) {
WalletSignatureRejected() =>
'You cancelled the payment in your wallet. No funds moved.',
WalletWrongNetwork() =>
'Switch your wallet to Stellar Testnet, then try again.',
WalletNotInstalled() => 'No wallet found. Install Freighter, then try again.',
WalletUnavailable() => 'Open or unlock your wallet, then try again.',
WalletTimedOut() =>
'Your wallet did not answer. Open it, then try again. No funds moved.',
WalletAccountChanged() =>
'Your wallet account changed. Reconnect, then try again.',
WalletInvalidPayload() =>
'This payment could not be signed safely. No funds moved.',
};
64 changes: 64 additions & 0 deletions puls3_flutter/lib/src/screens/wallet_sheet.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';

import '../state/app_scope.dart';
import '../theme/breakpoints.dart';
import '../theme/puls3_theme.dart';
import '../ui/organisms/wallet_panel.dart';

/// Opens the wallet sheet (S09). It connects, shows the connected account
/// with Disconnect, and keeps every failure as a recoverable state.
Future<void> showWalletSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
useSafeArea: true,
isScrollControlled: true,
builder: (_) => const WalletSheet(),
);
}

/// Container for [WalletPanel]: listens to the app's wallet controller.
class WalletSheet extends StatelessWidget {
const WalletSheet({super.key});

@override
Widget build(BuildContext context) {
final wallet = AppScope.of(context).wallet;
final narrow =
MediaQuery.sizeOf(context).width < Puls3Breakpoints.narrowGutter;
final gutter = narrow ? Puls3Spacing.md : Puls3Spacing.lg;
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(gutter, 0, gutter, Puls3Spacing.lg),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: ListenableBuilder(
listenable: wallet,
builder: (context, _) {
final installUrl = wallet.installUrl;
return WalletPanel(
status: wallet.status,
walletName: wallet.walletName,
address: wallet.address,
isOnTestnet: wallet.isOnTestnet,
error: wallet.lastError,
onConnect: () => unawaited(wallet.tryConnect()),
onDisconnect: () => unawaited(wallet.disconnect()),
onInstall: installUrl == null
? null
: () => unawaited(
launchUrl(
installUrl,
mode: LaunchMode.externalApplication,
),
),
);
},
),
),
),
);
}
}
Loading
Loading