portal_flutter 0.1.0-alpha.3
portal_flutter: ^0.1.0-alpha.3 copied to clipboard
Flutter SDK for Portal - Stablecoin wallet infrastructure
example/lib/main.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:portal_flutter/portal_flutter.dart';
void main() {
runApp(const MyApp());
}
// ==================== Environment Configuration ====================
enum AppEnv { staging, production }
class AppSettings {
static AppEnv currentEnv = AppEnv.production;
static bool shouldBackupWithPortal = false;
static bool isAccountAbstraction = false;
static String get apiHost {
switch (currentEnv) {
case AppEnv.staging:
return 'api.portalhq.dev';
case AppEnv.production:
return 'api.portalhq.io';
}
}
static String get mpcHost {
switch (currentEnv) {
case AppEnv.staging:
return 'mpc.portalhq.dev';
case AppEnv.production:
return 'mpc.portalhq.io';
}
}
static String get custodianServerUrl {
switch (currentEnv) {
case AppEnv.staging:
if (shouldBackupWithPortal) {
return 'https://staging-portalex-backup-with-portal.onrender.com';
} else {
return 'https://staging-portalex-mpc-service.onrender.com';
}
case AppEnv.production:
if (shouldBackupWithPortal) {
return 'https://prod-portalex-backup-with-portal.onrender.com';
} else {
return 'https://portalex-mpc.portalhq.io';
}
}
}
static String get envName {
switch (currentEnv) {
case AppEnv.staging:
return 'Staging';
case AppEnv.production:
return 'Production';
}
}
static String get passkeyRelyingPartyId {
switch (currentEnv) {
case AppEnv.staging:
return 'portalhq.dev';
case AppEnv.production:
return 'portalhq.io';
}
}
static String get passkeyWebAuthnHost {
switch (currentEnv) {
case AppEnv.staging:
return 'backup.portalhq.dev';
case AppEnv.production:
return 'backup.web.portalhq.io';
}
}
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Portal Flutter SDK',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const AuthScreen(),
);
}
}
// ==================== User Session ====================
class UserSession {
static final UserSession _instance = UserSession._internal();
factory UserSession() => _instance;
UserSession._internal();
UserData? user;
bool get isLoggedIn => user != null;
String get clientApiKey => user?.clientApiKey ?? '';
String get username => user?.username ?? '';
}
class UserData {
final String clientApiKey;
final String clientId;
final int exchangeUserId;
final String username;
UserData({
required this.clientApiKey,
required this.clientId,
required this.exchangeUserId,
required this.username,
});
factory UserData.fromJson(Map<String, dynamic> json) {
return UserData(
clientApiKey: json['clientApiKey'] as String? ?? '',
clientId: json['clientId'] as String? ?? '',
exchangeUserId: json['exchangeUserId'] as int? ?? 0,
username: json['username'] as String? ?? '',
);
}
}
// ==================== Portal API Service ====================
class PortalApiService {
String get _baseUrl => AppSettings.custodianServerUrl;
Future<UserData> signUp(String username, {bool isAccountAbstracted = false}) async {
final response = await http.post(
Uri.parse('$_baseUrl/mobile/signup'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'username': username,
'isAccountAbstracted': isAccountAbstracted,
}),
);
if (response.statusCode == 200 || response.statusCode == 201) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
return UserData.fromJson(json);
} else {
throw Exception('Signup failed: ${response.body}');
}
}
Future<UserData> signIn(String username) async {
final response = await http.post(
Uri.parse('$_baseUrl/mobile/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'username': username}),
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
return UserData.fromJson(json);
} else {
throw Exception('Login failed: ${response.body}');
}
}
/// Store cipherText with custodian (for non-backup-with-Portal mode)
Future<void> storeCipherText({
required int exchangeUserId,
required String cipherText,
required String backupMethod,
}) async {
final response = await http.post(
Uri.parse('$_baseUrl/mobile/$exchangeUserId/cipher-text'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'cipherText': cipherText,
'backupMethod': backupMethod,
}),
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception('Failed to store cipher text: ${response.body}');
}
}
/// Retrieve cipherText from custodian (for non-backup-with-Portal mode)
Future<String?> getCipherText({
required int exchangeUserId,
required String backupMethod,
}) async {
final uri = Uri.parse('$_baseUrl/mobile/$exchangeUserId/cipher-text/fetch')
.replace(queryParameters: {'backupMethod': backupMethod});
final response = await http.get(
uri,
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
return json['cipherText'] as String?;
} else if (response.statusCode == 404) {
return null; // No cipher text found
} else {
throw Exception('Failed to get cipher text: ${response.body}');
}
}
/// Enable eject for a wallet (required before calling ejectPrivateKeys with backup-with-Portal)
Future<String> enableEject({
required int exchangeUserId,
required String walletId,
}) async {
final response = await http.patch(
Uri.parse('$_baseUrl/mobile/$exchangeUserId/enable-eject'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'walletId': walletId,
}),
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
return json['ejectableUntil'] as String;
} else {
throw Exception('Failed to enable eject: ${response.body}');
}
}
}
// ==================== Auth Screen ====================
class AuthScreen extends StatefulWidget {
const AuthScreen({super.key});
@override
State<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
final _usernameController = TextEditingController();
final _apiService = PortalApiService();
bool _isLoading = false;
String? _error;
Future<void> _signUp() async {
final username = _usernameController.text.trim();
if (username.isEmpty) {
setState(() => _error = 'Please enter a username');
return;
}
setState(() {
_isLoading = true;
_error = null;
});
try {
final userData = await _apiService.signUp(
username,
isAccountAbstracted: AppSettings.isAccountAbstraction,
);
UserSession().user = userData;
_navigateToMain();
} catch (e) {
setState(() => _error = e.toString());
} finally {
setState(() => _isLoading = false);
}
}
Future<void> _signIn() async {
final username = _usernameController.text.trim();
if (username.isEmpty) {
setState(() => _error = 'Please enter a username');
return;
}
setState(() {
_isLoading = true;
_error = null;
});
try {
final userData = await _apiService.signIn(username);
UserSession().user = userData;
_navigateToMain();
} catch (e) {
setState(() => _error = e.toString());
} finally {
setState(() => _isLoading = false);
}
}
void _navigateToMain() {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const MainScreen()),
);
}
@override
void dispose() {
_usernameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Portal Flutter SDK'),
centerTitle: true,
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(
Icons.account_balance_wallet,
size: 80,
color: Colors.deepPurple,
),
const SizedBox(height: 32),
const Text(
'Welcome to Portal',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text(
'Sign in or create an account to get started',
style: TextStyle(fontSize: 14, color: Colors.grey),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Environment Toggle
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
const Text(
'Environment',
style: TextStyle(fontSize: 14),
),
const SizedBox(height: 8),
SegmentedButton<AppEnv>(
segments: const [
ButtonSegment<AppEnv>(
value: AppEnv.staging,
label: Text('Staging'),
),
ButtonSegment<AppEnv>(
value: AppEnv.production,
label: Text('Prod'),
),
],
selected: {AppSettings.currentEnv},
onSelectionChanged: _isLoading
? null
: (Set<AppEnv> selection) {
setState(() {
AppSettings.currentEnv = selection.first;
});
},
),
],
),
),
const SizedBox(height: 16),
// Backup with Portal Toggle
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Backup with Portal',
style: TextStyle(fontSize: 14),
),
Switch(
value: AppSettings.shouldBackupWithPortal,
onChanged: _isLoading
? null
: (bool value) {
setState(() {
AppSettings.shouldBackupWithPortal = value;
});
},
),
],
),
),
const SizedBox(height: 16),
// Account Abstraction Toggle
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Account Abstraction',
style: TextStyle(fontSize: 14),
),
Switch(
value: AppSettings.isAccountAbstraction,
onChanged: _isLoading
? null
: (bool value) {
setState(() {
AppSettings.isAccountAbstraction = value;
});
},
),
],
),
const Text(
'Enables gas sponsorship for transactions',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
],
),
),
const SizedBox(height: 24),
TextField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
enabled: !_isLoading,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _signIn(),
),
if (_error != null) ...[
const SizedBox(height: 16),
Text(
_error!,
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _signIn,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Sign In'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: _isLoading ? null : _signUp,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text('Create Account'),
),
const SizedBox(height: 24),
Text(
'Server: ${AppSettings.custodianServerUrl}',
style: const TextStyle(fontSize: 10, color: Colors.grey),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
}
// ==================== Main Screen ====================
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
final _portal = Portal();
final _apiService = PortalApiService();
final _passwordController = TextEditingController();
String _status = 'Ready to initialize Portal';
String? _ethereumAddress;
String? _solanaAddress;
bool _isInitialized = false;
bool _hasWallet = false;
String? _lastBackupCipherText; // Store cipher text from backup for eject
// Chain IDs
static const String _sepoliaChainId = 'eip155:11155111';
static const String _solanaDevnetChainId = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1';
// Test recipient addresses
static const String _testRecipient =
'0x4cd042bba0da4b3f37ea36e8a2737dce2ed70db7';
// Solana Devnet test recipient (Portal's devnet faucet address)
static const String _testSolanaRecipient =
'GPsPXxoQA51aTJJkNHtFDFYui5hN5UxcFPnheJEHa5Du';
void _log(String message) {
debugPrint('[PortalExample] $message');
if (mounted) {
setState(() => _status = message);
}
}
// ==================== Initialization ====================
Future<void> _initializePortal() async {
final apiKey = UserSession().clientApiKey;
if (apiKey.isEmpty) {
_log('No API key available. Please sign in first.');
return;
}
_log('Initializing Portal (${AppSettings.envName})...');
try {
await _portal.initialize(
apiKey: apiKey,
rpcConfig: {
_sepoliaChainId: 'https://rpc.sepolia.org',
_solanaDevnetChainId: 'https://api.devnet.solana.com',
},
autoApprove: true,
apiHost: AppSettings.apiHost,
mpcHost: AppSettings.mpcHost,
);
_isInitialized = true;
// Check if wallet already exists on device
final addressResult = await _portal.getAddresses();
if (addressResult.hasWallet) {
_ethereumAddress = addressResult.addresses.ethereum;
_solanaAddress = addressResult.addresses.solana;
_hasWallet = true;
_log(
'Portal initialized (${AppSettings.envName})!\nExisting wallet found:\nETH: ${_truncate(_ethereumAddress!)}\nSOL: ${_truncate(_solanaAddress!)}');
} else {
final recoverable = await _portal.isPasswordRecoverAvailable();
_log(
'Portal initialized (${AppSettings.envName})!\nNo wallet on device. Recovery available: $recoverable');
}
} on PortalException catch (e) {
_log('Initialization failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Initialization failed: $e');
}
}
// ==================== Wallet Operations ====================
Future<void> _createWallet() async {
if (!_isInitialized) {
_log('Please initialize Portal first');
return;
}
_log('Creating wallet...');
try {
final addresses = await _portal.createWallet();
_ethereumAddress = addresses.ethereum;
_solanaAddress = addresses.solana;
_hasWallet = true;
_log(
'Wallet created!\nETH: ${_truncate(_ethereumAddress!)}\nSOL: ${_truncate(_solanaAddress!)}');
} on PortalException catch (e) {
_log('Create wallet failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Create wallet failed: $e');
}
}
// ==================== Backup & Recovery ====================
Future<void> _backupWallet() async {
final password = _passwordController.text.trim();
if (password.isEmpty) {
_log('Please enter a password');
return;
}
if (!_isInitialized || !_hasWallet) {
_log('Please create a wallet first');
return;
}
_log('Backing up wallet with password...');
try {
final backupResponse = await _portal.backupWallet(
method: PortalBackupMethod.password,
password: password,
);
// If not using backup with Portal, store cipherText with custodian
if (!AppSettings.shouldBackupWithPortal) {
_log('Storing cipher text with custodian...');
await _apiService.storeCipherText(
exchangeUserId: UserSession().user!.exchangeUserId,
cipherText: backupResponse.cipherText,
backupMethod: 'PASSWORD',
);
}
_lastBackupCipherText = backupResponse.cipherText;
_log('Wallet backed up successfully!');
} on PortalException catch (e) {
_log('Backup failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Backup failed: $e');
}
}
Future<void> _backupWithICloud() async {
if (!_isInitialized || !_hasWallet) {
_log('Please create a wallet first');
return;
}
_log('Backing up wallet with iCloud...');
try {
await _portal.configureICloudStorage();
await _portal.backupWallet(method: PortalBackupMethod.iCloud);
_log('Wallet backed up to iCloud successfully!');
} on PortalException catch (e) {
_log('iCloud backup failed: [${e.code}] ${e.message}');
} catch (e) {
_log('iCloud backup failed: $e');
}
}
Future<void> _backupWithPasskey() async {
if (!_isInitialized || !_hasWallet) {
_log('Please create a wallet first');
return;
}
_log('Backing up wallet with Passkey...');
try {
// Configure passkey storage first
await _portal.configurePasskeyStorage(
relyingPartyId: AppSettings.passkeyRelyingPartyId,
relyingPartyOrigins: [AppSettings.passkeyWebAuthnHost],
webAuthnHost: AppSettings.passkeyWebAuthnHost,
);
final backupResponse =
await _portal.backupWallet(method: PortalBackupMethod.passkey);
// If not using backup with Portal, store cipherText with custodian
if (!AppSettings.shouldBackupWithPortal) {
_log('Storing cipher text with custodian...');
await _apiService.storeCipherText(
exchangeUserId: UserSession().user!.exchangeUserId,
cipherText: backupResponse.cipherText,
backupMethod: 'PASSKEY',
);
_lastBackupCipherText = backupResponse.cipherText;
}
_log('Wallet backed up with Passkey successfully!');
} on PortalException catch (e) {
_log('Passkey backup failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Passkey backup failed: $e');
}
}
Future<void> _recoverWallet() async {
final password = _passwordController.text.trim();
if (password.isEmpty) {
_log('Please enter a password');
return;
}
if (!_isInitialized) {
_log('Please initialize Portal first');
return;
}
_log('Recovering wallet...');
try {
String? cipherText;
// If not using backup with Portal, retrieve cipherText from custodian
if (!AppSettings.shouldBackupWithPortal) {
_log('Retrieving cipher text from custodian...');
cipherText = await _apiService.getCipherText(
exchangeUserId: UserSession().user!.exchangeUserId,
backupMethod: 'PASSWORD',
);
if (cipherText == null) {
_log('No backup found. Please backup your wallet first.');
return;
}
}
final addresses = await _portal.recoverWallet(
method: PortalBackupMethod.password,
password: password,
cipherText: cipherText,
);
_ethereumAddress = addresses.ethereum;
_solanaAddress = addresses.solana;
_hasWallet = true;
_log(
'Wallet recovered!\nETH: ${_truncate(_ethereumAddress!)}\nSOL: ${_truncate(_solanaAddress!)}');
} on PortalException catch (e) {
_log('Recovery failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Recovery failed: $e');
}
}
Future<void> _recoverWithICloud() async {
if (!_isInitialized) {
_log('Please initialize Portal first');
return;
}
_log('Recovering wallet from iCloud...');
try {
await _portal.configureICloudStorage();
final addresses = await _portal.recoverWallet(
method: PortalBackupMethod.iCloud,
);
_ethereumAddress = addresses.ethereum;
_solanaAddress = addresses.solana;
_hasWallet = true;
_log(
'Wallet recovered from iCloud!\nETH: ${_truncate(_ethereumAddress!)}\nSOL: ${_truncate(_solanaAddress!)}');
} on PortalException catch (e) {
_log('iCloud recovery failed: [${e.code}] ${e.message}');
} catch (e) {
_log('iCloud recovery failed: $e');
}
}
Future<void> _recoverWithPasskey() async {
if (!_isInitialized) {
_log('Please initialize Portal first');
return;
}
_log('Recovering wallet with Passkey...');
try {
await _portal.configurePasskeyStorage(
relyingPartyId: AppSettings.passkeyRelyingPartyId,
relyingPartyOrigins: [AppSettings.passkeyWebAuthnHost],
webAuthnHost: AppSettings.passkeyWebAuthnHost,
);
// If not using backup with Portal, retrieve cipherText from custodian
String? cipherText;
if (!AppSettings.shouldBackupWithPortal) {
_log('Retrieving cipher text from custodian...');
cipherText = await _apiService.getCipherText(
exchangeUserId: UserSession().user!.exchangeUserId,
backupMethod: 'PASSKEY',
);
if (cipherText == null) {
_log('No cipher text found. Please backup first.');
return;
}
}
final addresses = await _portal.recoverWallet(
method: PortalBackupMethod.passkey,
cipherText: cipherText,
);
_ethereumAddress = addresses.ethereum;
_solanaAddress = addresses.solana;
_hasWallet = true;
_log(
'Wallet recovered with Passkey!\nETH: ${_truncate(_ethereumAddress!)}\nSOL: ${_truncate(_solanaAddress!)}');
} on PortalException catch (e) {
_log('Passkey recovery failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Passkey recovery failed: $e');
}
}
Future<void> _ejectPrivateKeys() async {
final password = _passwordController.text.trim();
if (password.isEmpty) {
_log('Please enter a password');
return;
}
if (!_isInitialized || !_hasWallet) {
_log('Please create a wallet first');
return;
}
_log('Ejecting private keys...');
try {
String? cipherText;
if (AppSettings.shouldBackupWithPortal) {
// Backup-with-Portal mode: Portal stores the cipherText
// We need to enable eject first via custodian
_log('Enabling eject via custodian...');
// Get client info to find wallet IDs
final clientInfo = await _portal.getClient();
// Enable eject for each wallet
for (final wallet in clientInfo.wallets) {
if (wallet != null) {
final ejectableUntil = await _apiService.enableEject(
exchangeUserId: UserSession().user!.exchangeUserId,
walletId: wallet.id,
);
_log('Wallet ${wallet.id} ejectable until: $ejectableUntil');
}
}
// cipherText stays null - Portal has it stored
} else {
// Non-backup-with-Portal mode: Custodian stores the cipherText
// Retrieve it from custodian
_log('Retrieving cipher text from custodian...');
cipherText = await _apiService.getCipherText(
exchangeUserId: UserSession().user!.exchangeUserId,
backupMethod: 'PASSWORD',
);
if (cipherText == null) {
_log('No backup found. Please backup your wallet first.');
return;
}
}
final keys = await _portal.ejectPrivateKeys(
method: PortalBackupMethod.password,
password: password,
cipherText: cipherText,
);
final keyCount = keys.length;
_log('Private keys ejected!\n$keyCount keys retrieved.');
// Show dialog with copyable keys
if (mounted) {
_showEjectedKeysDialog(keys);
}
} on PortalException catch (e) {
_log('Eject failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Eject failed: $e');
}
}
void _showEjectedKeysDialog(Map<String, String> keys) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning, color: Colors.red),
SizedBox(width: 8),
Text('Private Keys'),
],
),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'WARNING: Store these securely! Anyone with these keys has full control of your wallet.',
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
...keys.entries.map((entry) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.key.toUpperCase(),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
Expanded(
child: SelectableText(
entry.value,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
IconButton(
icon: const Icon(Icons.copy, size: 20),
onPressed: () {
Clipboard.setData(ClipboardData(text: entry.value));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${entry.key} key copied!'),
duration: const Duration(seconds: 2),
),
);
},
),
],
),
),
],
),
)),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
),
);
}
Future<void> _deleteKeychain() async {
if (!_isInitialized) {
_log('Please initialize Portal first');
return;
}
_log('Deleting keychain data...');
try {
await _portal.deleteKeychain();
_hasWallet = false;
_ethereumAddress = null;
_solanaAddress = null;
_log('Keychain deleted successfully!\nWallet data removed from device.');
} on PortalException catch (e) {
_log('Delete keychain failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Delete keychain failed: $e');
}
}
Future<void> _checkWalletStatus() async {
if (!_isInitialized) {
_log('Please initialize Portal first');
return;
}
_log('Checking wallet status...');
try {
final exists = await _portal.doesWalletExist();
final onDevice = await _portal.isWalletOnDevice();
final backedUp = await _portal.isWalletBackedUp();
final recoverable = await _portal.isWalletRecoverable();
_log('''Wallet Status:
- Exists: $exists
- On Device: $onDevice
- Backed Up: $backedUp
- Recoverable: $recoverable''');
} on PortalException catch (e) {
_log('Status check failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Status check failed: $e');
}
}
Future<void> _getAssets() async {
if (!_hasWallet) {
_log('Please create a wallet first');
return;
}
_log('Getting assets on Sepolia...');
try {
final assets = await _portal.getAssets(_sepoliaChainId);
final nativeBalances = assets.nativeBalance.where((a) => a != null).toList();
final tokenBalances = assets.tokenBalances.where((a) => a != null).toList();
var result = 'Assets on Sepolia:\n';
result += 'Native Balance:\n';
for (final asset in nativeBalances) {
result += ' ${asset!.symbol}: ${asset.balance}\n';
}
if (tokenBalances.isNotEmpty) {
result += 'Token Balances:\n';
for (final asset in tokenBalances) {
result += ' ${asset!.symbol}: ${asset.balance}\n';
}
} else {
result += 'No token balances';
}
_log(result);
} on PortalException catch (e) {
_log('Get assets failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Get assets failed: $e');
}
}
Future<void> _getAvailableRecoveryMethods() async {
if (!_isInitialized) {
_log('Please initialize Portal first');
return;
}
_log('Getting available recovery methods...');
try {
final methods = await _portal.availableRecoveryMethods();
if (methods.isEmpty) {
_log('No recovery methods available');
} else {
_log('Available recovery methods:\n${methods.map((m) => ' - $m').join('\n')}');
}
} on PortalException catch (e) {
_log('Get recovery methods failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Get recovery methods failed: $e');
}
}
// ==================== Signing ====================
Future<void> _signMessage() async {
if (!_hasWallet) {
_log('Please create a wallet first');
return;
}
_log('Signing message...');
try {
const message = 'Hello from Portal Flutter SDK!';
final signature = await _portal.signMessage(
chainId: _sepoliaChainId,
message: message,
);
_log('Message signed!\nSignature: ${_truncate(signature)}');
} on PortalException catch (e) {
_log('Sign message failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Sign message failed: $e');
}
}
Future<void> _signTypedData() async {
if (!_hasWallet) {
_log('Please create a wallet first');
return;
}
_log('Signing typed data (EIP-712)...');
try {
const typedData = '''
{
"types": {
"EIP712Domain": [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"}
],
"Message": [
{"name": "content", "type": "string"}
]
},
"primaryType": "Message",
"domain": {
"name": "Portal Example",
"version": "1",
"chainId": 11155111
},
"message": {
"content": "Hello from Portal!"
}
}
''';
final signature = await _portal.signTypedData(
chainId: _sepoliaChainId,
typedData: typedData,
);
_log('Typed data signed!\nSignature: ${_truncate(signature)}');
} on PortalException catch (e) {
_log('Sign typed data failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Sign typed data failed: $e');
}
}
Future<void> _ethSign() async {
if (!_hasWallet) {
_log('Please create a wallet first');
return;
}
_log('Signing with eth_sign...');
try {
// eth_sign takes address and data hash
final messageHash = '0x${List.generate(64, (_) => 'a').join()}'; // 32 bytes hex
final result = await _portal.request(
chainId: _sepoliaChainId,
method: 'eth_sign',
params: [_ethereumAddress!, messageHash],
);
_log('eth_sign result:\n${_truncate(result.result ?? 'null')}');
} on PortalException catch (e) {
_log('eth_sign failed: [${e.code}] ${e.message}');
} catch (e) {
_log('eth_sign failed: $e');
}
}
Future<void> _getBalance() async {
if (!_hasWallet) {
_log('Please create a wallet first');
return;
}
_log('Getting ETH balance...');
try {
final result = await _portal.request(
chainId: _sepoliaChainId,
method: 'eth_getBalance',
params: [_ethereumAddress!, 'latest'],
);
var balanceHex = result.result ?? '0x0';
// Handle case where result might be a JSON string or wrapped response
if (balanceHex.contains('"result"')) {
// Try to extract the result from JSON
final match = RegExp(r'"result":\s*"?(0x[0-9a-fA-F]+)"?').firstMatch(balanceHex);
if (match != null) {
balanceHex = match.group(1) ?? '0x0';
}
}
// Ensure we have a valid hex string
if (!balanceHex.startsWith('0x')) {
_log('Balance response: $balanceHex');
return;
}
final balanceWei = int.parse(balanceHex.substring(2), radix: 16);
final balanceEth = balanceWei / 1e18;
_log('Balance: ${balanceEth.toStringAsFixed(6)} ETH\n($balanceWei wei)');
} on PortalException catch (e) {
_log('Get balance failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Get balance failed: $e');
}
}
Future<void> _signSolanaMessage() async {
if (!_hasWallet || _solanaAddress == null) {
_log('Please create a wallet first');
return;
}
_log('Signing Solana message...');
try {
const message = 'Hello from Portal Flutter SDK on Solana!';
final result = await _portal.request(
chainId: _solanaDevnetChainId,
method: 'sol_signMessage',
params: [message],
);
// Check for errors in the response
if (result.error != null) {
_log('Solana sign failed: ${result.error}');
return;
}
_log('Solana message signed!\nSignature: ${_truncate(result.result ?? 'null')}');
} on PortalException catch (e) {
_log('Solana sign failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Solana sign failed: $e');
}
}
Future<void> _sendSol() async {
if (!_hasWallet || _solanaAddress == null) {
_log('Please create a wallet first');
return;
}
_log('Sending 0.01 SOL on Devnet...');
try {
final txHash = await _portal.sendAsset(
chainId: _solanaDevnetChainId,
to: _testSolanaRecipient,
token: 'SOL',
amount: '0.01',
);
_log('SOL sent!\nTransaction: ${_truncate(txHash)}');
} on PortalException catch (e) {
_log('Send SOL failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Send SOL failed: $e');
}
}
Future<void> _rawSign() async {
if (!_hasWallet) {
_log('Please create a wallet first');
return;
}
_log('Signing raw message...');
try {
// Raw sign takes a hex-encoded message (without personal_sign prefix)
final messageHash = '0x${List.generate(64, (_) => 'b').join()}'; // 32 bytes hex
final signature = await _portal.rawSign(
chainId: _sepoliaChainId,
message: messageHash,
signatureApprovalMemo: 'Raw sign test',
);
_log('Raw message signed!\nSignature: ${_truncate(signature)}');
} on PortalException catch (e) {
_log('Raw sign failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Raw sign failed: $e');
}
}
// ==================== Transactions ====================
Future<void> _sendTransaction() async {
if (!_hasWallet) {
_log('Please create a wallet first');
return;
}
_log('Sending transaction (1 wei)...');
try {
final txHash = await _portal.sendTransaction(
chainId: _sepoliaChainId,
to: _testRecipient,
value: '0x1', // 1 wei
);
_log('Transaction sent!\nHash: ${_truncate(txHash)}');
} on PortalException catch (e) {
_log('Send transaction failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Send transaction failed: $e');
}
}
Future<void> _fundTestnet() async {
if (!_hasWallet) {
_log('Please create a wallet first');
return;
}
_log('Requesting testnet ETH on Sepolia...');
try {
final result = await _portal.receiveTestnetAsset(
chainId: _sepoliaChainId,
amount: '0.01',
token: 'ETH',
);
if (result.success) {
_log('Testnet funds received!\nTx: ${_truncate(result.transactionHash ?? '')}');
} else {
_log('Fund request failed: ${result.error ?? 'Unknown error'}');
}
} on PortalException catch (e) {
_log('Fund testnet failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Fund testnet failed: $e');
}
}
Future<void> _sendAssetWithGasSponsorship() async {
if (!_hasWallet) {
_log('Please create a wallet first');
return;
}
_log('Sending asset with gas sponsorship...');
try {
final txHash = await _portal.sendAsset(
chainId: _sepoliaChainId,
to: _testRecipient,
token: 'ETH',
amount: '0.0001',
sponsorGas: true,
);
_log('Asset sent with gas sponsorship!\nHash: ${_truncate(txHash)}');
} on PortalException catch (e) {
_log('Send asset failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Send asset failed: $e');
}
}
Future<void> _getTransactions() async {
if (!_hasWallet) {
_log('Please create a wallet first');
return;
}
_log('Getting transactions on Sepolia...');
try {
final transactions = await _portal.getTransactions(
_sepoliaChainId,
limit: 5,
order: PortalTransactionOrder.desc,
);
if (transactions.isEmpty) {
_log('No transactions found');
return;
}
var result = 'Recent Transactions (${transactions.length}):\n';
for (final tx in transactions) {
result += ' ${_truncate(tx.hash ?? 'unknown', 8)}: ${tx.value ?? '0'} wei\n';
result += ' From: ${_truncate(tx.from ?? 'unknown', 6)}\n';
result += ' To: ${_truncate(tx.to ?? 'unknown', 6)}\n';
}
_log(result);
} on PortalException catch (e) {
_log('Get transactions failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Get transactions failed: $e');
}
}
Future<void> _getNftAssets() async {
if (!_hasWallet) {
_log('Please create a wallet first');
return;
}
_log('Getting NFT assets on Sepolia...');
try {
final nfts = await _portal.getNftAssets(_sepoliaChainId);
if (nfts.isEmpty) {
_log('No NFTs found');
return;
}
var result = 'NFT Assets (${nfts.length}):\n';
for (final nft in nfts) {
result += ' ${nft.name ?? 'Unnamed'} (#${nft.tokenId ?? 'unknown'})\n';
result += ' Contract: ${_truncate(nft.contractAddress ?? 'unknown', 8)}\n';
result += ' Type: ${nft.tokenType ?? 'unknown'}\n';
}
_log(result);
} on PortalException catch (e) {
_log('Get NFTs failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Get NFTs failed: $e');
}
}
Future<void> _evaluateTransaction() async {
if (!_hasWallet) {
_log('Please create a wallet first');
return;
}
_log('Evaluating transaction security...');
try {
final result = await _portal.evaluateTransaction(
chainId: _sepoliaChainId,
to: _testRecipient,
value: '0x1',
operationType: PortalEvaluateTransactionOperationType.all,
);
_log('''Transaction Evaluation:
- Result: ${result.result ?? 'unknown'}
- Status: ${result.status ?? 'unknown'}
- Classification: ${result.classification ?? 'unknown'}
- Reason: ${result.reason ?? 'none'}
- Description: ${result.description ?? 'none'}''');
} on PortalException catch (e) {
_log('Evaluate transaction failed: [${e.code}] ${e.message}');
} catch (e) {
_log('Evaluate transaction failed: $e');
}
}
// ==================== Helpers ====================
String _truncate(String s, [int length = 16]) {
if (s.length <= length * 2) return s;
return '${s.substring(0, length)}...${s.substring(s.length - length)}';
}
void _copyAddress() {
if (_ethereumAddress != null) {
Clipboard.setData(ClipboardData(text: _ethereumAddress!));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Address copied to clipboard')),
);
}
}
void _signOut() {
UserSession().user = null;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const AuthScreen()),
);
}
@override
void dispose() {
_passwordController.dispose();
super.dispose();
}
// ==================== UI ====================
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 4,
child: Scaffold(
appBar: AppBar(
title: Text('Welcome, ${UserSession().username}'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: _signOut,
tooltip: 'Sign Out',
),
],
bottom: const TabBar(
isScrollable: true,
tabs: [
Tab(text: 'Setup'),
Tab(text: 'Backup'),
Tab(text: 'Signing'),
Tab(text: 'Transactions'),
],
),
),
body: Column(
children: [
// Status bar
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: Colors.grey[200],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Status: $_status',
style: const TextStyle(fontSize: 12),
),
if (_ethereumAddress != null) ...[
const SizedBox(height: 4),
GestureDetector(
onTap: _copyAddress,
child: Text(
'ETH: ${_truncate(_ethereumAddress!, 10)}',
style: const TextStyle(
fontSize: 11,
color: Colors.blue,
),
),
),
],
if (_solanaAddress != null) ...[
const SizedBox(height: 2),
Text(
'SOL: ${_truncate(_solanaAddress!, 10)}',
style: const TextStyle(
fontSize: 11,
color: Colors.purple,
),
),
],
],
),
),
// Tab content
Expanded(
child: TabBarView(
children: [
_buildSetupTab(),
_buildBackupTab(),
_buildSigningTab(),
_buildTransactionsTab(),
],
),
),
],
),
),
);
}
Widget _buildSetupTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// User Info Card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Session Info',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text('Username: ${UserSession().username}'),
Text('Environment: ${AppSettings.envName}'),
Text('API Key: ${_truncate(UserSession().clientApiKey, 8)}'),
],
),
),
),
const SizedBox(height: 16),
const Text(
'Initialization',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _initializePortal,
child: const Text('Initialize Portal'),
),
const SizedBox(height: 24),
const Text(
'Wallet',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _isInitialized ? _createWallet : null,
child: const Text('Create Wallet'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _isInitialized ? _checkWalletStatus : null,
child: const Text('Check Wallet Status'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _getBalance : null,
child: const Text('Get ETH Balance'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _getAssets : null,
child: const Text('Get Assets'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _isInitialized ? _getAvailableRecoveryMethods : null,
child: const Text('Available Recovery Methods'),
),
],
),
);
}
Widget _buildBackupTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Password field for password-based operations
TextField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
hintText: 'For password backup/recovery/eject',
border: OutlineInputBorder(),
),
obscureText: true,
),
const SizedBox(height: 24),
const Text(
'Password Backup',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _backupWallet : null,
child: const Text('Backup with Password'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _isInitialized ? _recoverWallet : null,
child: const Text('Recover with Password'),
),
const SizedBox(height: 24),
const Text(
'iCloud Backup (iOS)',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _backupWithICloud : null,
child: const Text('Backup to iCloud'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _isInitialized ? _recoverWithICloud : null,
child: const Text('Recover from iCloud'),
),
const SizedBox(height: 24),
const Text(
'Passkey Backup',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Uses WebAuthn for secure backup (iOS 16+)',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _backupWithPasskey : null,
child: const Text('Backup with Passkey'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _isInitialized ? _recoverWithPasskey : null,
child: const Text('Recover with Passkey'),
),
const SizedBox(height: 24),
const Text(
'Private Key Export',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'⚠️ WARNING: Exports full private keys. Use with caution!',
style: TextStyle(fontSize: 12, color: Colors.red),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _ejectPrivateKeys : null,
style: ElevatedButton.styleFrom(backgroundColor: Colors.red[100]),
child: const Text('Eject Private Keys'),
),
const SizedBox(height: 24),
const Text(
'Delete Keychain',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Removes all local wallet data from device',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _isInitialized ? _deleteKeychain : null,
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange[100]),
child: const Text('Delete Keychain'),
),
],
),
);
}
Widget _buildSigningTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Ethereum Signing',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
const Text(
'personal_sign',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
const Text(
'Sign a personal message with prefix',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _signMessage : null,
child: const Text('Personal Sign'),
),
const SizedBox(height: 16),
const Text(
'eth_sign',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
const Text(
'Sign raw data hash (32 bytes)',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _ethSign : null,
child: const Text('Eth Sign'),
),
const SizedBox(height: 16),
const Text(
'eth_signTypedData_v4',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
const Text(
'Sign EIP-712 structured data',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _signTypedData : null,
child: const Text('Sign Typed Data (v4)'),
),
const SizedBox(height: 16),
const Text(
'raw_sign',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
const Text(
'Sign raw data without personal_sign prefix',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _rawSign : null,
child: const Text('Raw Sign'),
),
const SizedBox(height: 24),
const Text(
'Solana Signing',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
const Text(
'sol_signMessage',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
const Text(
'Sign a message on Solana Devnet',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _signSolanaMessage : null,
child: const Text('Sign Solana Message'),
),
const SizedBox(height: 16),
const Text(
'sol_signAndSendTransaction',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
const Text(
'Send 0.01 SOL on Devnet',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _sendSol : null,
child: const Text('Send 0.01 SOL'),
),
const SizedBox(height: 24),
const Text(
'Supported Methods',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Ethereum:',
style: TextStyle(fontWeight: FontWeight.bold)),
Text(' - personal_sign'),
Text(' - eth_sign'),
Text(' - eth_signTransaction'),
Text(' - eth_signTypedData_v3'),
Text(' - eth_signTypedData_v4'),
SizedBox(height: 8),
Text('Solana:', style: TextStyle(fontWeight: FontWeight.bold)),
Text(' - sol_signMessage'),
Text(' - sol_signTransaction'),
Text(' - sol_signAndSendTransaction'),
],
),
),
],
),
);
}
Widget _buildTransactionsTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Fund Testnet Wallet',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Request 0.01 ETH on Sepolia testnet',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _fundTestnet : null,
child: const Text('Fund Testnet'),
),
const SizedBox(height: 24),
const Text(
'Send Transaction',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Send 1 wei to test recipient on Sepolia',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _sendTransaction : null,
child: const Text('Send 1 Wei'),
),
const SizedBox(height: 24),
const Text(
'Send Asset (Gas Sponsorship)',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Send 0.0001 ETH with gas sponsorship${AppSettings.isAccountAbstraction ? " (AA enabled)" : ""}',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _sendAssetWithGasSponsorship : null,
style: ElevatedButton.styleFrom(
backgroundColor: AppSettings.isAccountAbstraction ? Colors.green[100] : null,
),
child: const Text('Send with Gas Sponsorship'),
),
const SizedBox(height: 24),
const Text(
'Transaction History',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Get recent transactions on Sepolia',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _getTransactions : null,
child: const Text('Get Transactions'),
),
const SizedBox(height: 24),
const Text(
'NFT Assets',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Get NFTs owned by wallet on Sepolia',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _getNftAssets : null,
child: const Text('Get NFT Assets'),
),
const SizedBox(height: 24),
const Text(
'Transaction Security',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Evaluate transaction using Blockaid security scan',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _hasWallet ? _evaluateTransaction : null,
child: const Text('Evaluate Transaction'),
),
const SizedBox(height: 24),
const Text(
'Chain Info',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Ethereum Sepolia Testnet',
style: TextStyle(fontWeight: FontWeight.bold)),
Text(' Chain ID: eip155:11155111'),
SizedBox(height: 8),
Text('Solana Devnet',
style: TextStyle(fontWeight: FontWeight.bold)),
Text(' Chain ID: solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'),
],
),
),
],
),
);
}
}