Skip to content

Commit

Permalink
Integrate GETH coin events in MetaMask
Browse files Browse the repository at this point in the history
  • Loading branch information
simonesestito committed Jan 10, 2024
1 parent bf6fc5b commit 8c6d5df
Show file tree
Hide file tree
Showing 19 changed files with 395 additions and 52 deletions.
41 changes: 40 additions & 1 deletion assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -138,5 +138,44 @@
"sellTokensButtonLabel": "Sell GETH",
"sellTokensAmountHint": "Amount of GETH to sell",
"sepoliaWarningBanner": "You are not using Sepolia test network, where the smart contracts are!",
"sepoliaWarningBannerButton": "Switch to Sepolia"
"sepoliaWarningBannerButton": "Switch to Sepolia",
"transactionSentEventLabel": "Transaction sent (hash: {hash})",
"@transactionSentEventLabel": {
"placeholders": {
"hash": {
"type": "String"
}
}
},
"coinTransferSent": "You successfully bought {amount} GETH",
"@coinTransferSent": {
"placeholders": {
"amount": {
"type": "String"
}
}
},
"coinTransferReceived": "You successfully sold {amount} GETH",
"@coinTransferReceived": {
"placeholders": {
"amount": {
"type": "String"
}
}
},
"coinTransferExchanged": "Successful transaction of {amount} GETH from {from} to {to}",
"@coinTransferExchanged": {
"placeholders": {
"amount": {
"type": "String"
},
"from": {
"type": "String"
},
"to": {
"type": "String"
}
}
},
"etherscanLinkLabel": "View on Etherscan"
}
46 changes: 33 additions & 13 deletions lib/bloc/balance/balance_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gethdomains/bloc/auth/auth_bloc.dart';
import 'package:gethdomains/bloc/global_errors/global_errors.dart';
import 'package:gethdomains/bloc/global_errors/global_events.dart';
import 'package:gethdomains/contracts/events.dart';
import 'package:gethdomains/contracts/exceptions.dart';
import 'package:gethdomains/repository/balance_repository.dart';

Expand All @@ -13,13 +15,14 @@ part 'balance_state.dart';
class BalanceBloc extends Bloc<BalanceEvent, BalanceState> {
final BalanceRepository balanceRepository;
final GlobalErrorsSink globalErrorsSink;
final GlobalEventsSink globalEventsSink;

BalanceBloc({
required this.balanceRepository,
required this.globalErrorsSink,
required this.globalEventsSink,
required Stream<AuthState> authStateChanges,
}) : super(const LoadingBalanceState()) {
// TODO: Add a listener for events from the smart contract, propagated through the repository
on<LoadBalanceEvent>(_onLoadBalanceEvent);
on<_UpdateBalanceEvent>(_onUpdateBalanceEvent);
on<BuyTokensEvent>(_onBuyTokensEvent);
Expand All @@ -35,12 +38,19 @@ class BalanceBloc extends Bloc<BalanceEvent, BalanceState> {
add(const _UpdateBalanceEvent(balance: null));
}
});

// Listen to coin transfers
globalEventsSink.coinTransfers.listen((event) {
debugPrint('BalanceBloc: globalEventsSink.coinTransfers.listen: $event');
add(const LoadBalanceEvent());
});
}

FutureOr<void> _onLoadBalanceEvent(
LoadBalanceEvent event,
Emitter<BalanceState> emit,
) async {
debugPrint('BalanceBloc: _onLoadBalanceEvent');
emit(const LoadingBalanceState());
try {
final balance = await balanceRepository.getBalance();
Expand All @@ -54,7 +64,8 @@ class BalanceBloc extends Bloc<BalanceEvent, BalanceState> {
/// So, the data it gives can be trusted (not coming from outside classes).
FutureOr<void> _onUpdateBalanceEvent(
_UpdateBalanceEvent event,
Emitter<BalanceState> emit,) async {
Emitter<BalanceState> emit,
) async {
if (event.balance == null) {
emit(const UnavailableBalanceState());
} else {
Expand All @@ -64,31 +75,40 @@ class BalanceBloc extends Bloc<BalanceEvent, BalanceState> {

void buyTokens(BigInt amount) => add(BuyTokensEvent(amount));

FutureOr<T?> _wrapSmartContractInvocation<T>(Future<T> Function() invocation,
Emitter<BalanceState> emit,) async {
FutureOr<T?> _wrapSmartContractInvocation<T>(
Future<T> Function() invocation,
Emitter<BalanceState> emit,
) async {
emit(const LoadingBalanceState());
try {
return await invocation();
} on Web3Exception catch (e) {
globalErrorsSink.addWeb3Error(e);
} finally {
debugPrint('BalanceBloc: _wrapSmartContractInvocation: $e');
// Refresh only on error, otherwise an update will be triggered by events
final balance = await balanceRepository.getBalance();
add(_UpdateBalanceEvent(balance: balance));
}
return null;
}

FutureOr<void> _onBuyTokensEvent(BuyTokensEvent event,
Emitter<BalanceState> emit,) =>
_wrapSmartContractInvocation(() {
return balanceRepository.purchaseTokens(event.amount);
FutureOr<void> _onBuyTokensEvent(
BuyTokensEvent event,
Emitter<BalanceState> emit,
) =>
_wrapSmartContractInvocation(() async {
final txHash = await balanceRepository.purchaseTokens(event.amount);
globalEventsSink.addWeb3Event(Web3TransactionSent(txHash));
}, emit);

void sellTokens(BigInt amount) => add(SellTokensEvent(amount));

FutureOr<void> _onSellTokensEvent(SellTokensEvent event,
Emitter<BalanceState> emit,) =>
_wrapSmartContractInvocation(() {
return balanceRepository.sellTokens(event.amount);
FutureOr<void> _onSellTokensEvent(
SellTokensEvent event,
Emitter<BalanceState> emit,
) =>
_wrapSmartContractInvocation(() async {
final txHash = await balanceRepository.sellTokens(event.amount);
globalEventsSink.addWeb3Event(Web3TransactionSent(txHash));
}, emit);
}
122 changes: 96 additions & 26 deletions lib/bloc/global_errors/banner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:gethdomains/contracts/events.dart';
import 'package:gethdomains/contracts/exceptions.dart';
import 'package:gethdomains/widget/banner_card.dart';
import 'package:url_launcher/url_launcher.dart';

import 'global_errors.dart';
import 'global_events.dart';

class GlobalErrorsBanner extends StatefulWidget {
const GlobalErrorsBanner({super.key});
Expand All @@ -15,53 +19,119 @@ class GlobalErrorsBanner extends StatefulWidget {
}

class _GlobalErrorsBannerState extends State<GlobalErrorsBanner> {
static const _errorHideDuration = Duration(seconds: 5);
static const _bannerAnimationDuration = Duration(milliseconds: 500);
static const _errorHideDuration = Duration(seconds: 8);

Web3Exception _error = const Web3Exception(0); // Initial error placeholder
bool _isVisible = false;
StreamSubscription<Web3Notice>? _errorsSubscription;
StreamSubscription<Web3Notice>? _eventsSubscription;

Web3Notice? _error;
Timer? _timer;

@override
void initState() {
super.initState();
final globalErrorsSink = context.read<GlobalErrorsSink>();
globalErrorsSink.web3ErrorsStream.listen((error) {
// An error came, so cancel the timer (don't hide the banner)
_timer?.cancel();
final globalEventsSink = context.read<GlobalEventsSink>();

setState(() {
_error = error;
_isVisible = true;
_errorsSubscription = globalErrorsSink.web3ErrorsStream.listen(_onEvent);
_eventsSubscription = globalEventsSink.web3ErrorsStream.listen(_onEvent);
}

// Hide the banner after 5 seconds, if no other error comes
_timer = Timer(_errorHideDuration, _hideError);
});
});
@override
void dispose() {
super.dispose();
_errorsSubscription?.cancel();
_eventsSubscription?.cancel();
}

@override
Widget build(BuildContext context) {
return IgnorePointer(
child: AnimatedOpacity(
duration: _bannerAnimationDuration,
opacity: _isVisible ? 1 : 0,
child: Padding(
padding: const EdgeInsets.only(top: 56),
child: BannerCard(
color: Colors.red,
icon: const Icon(Icons.error_outline),
content: Text(_error.getDisplayMessage()),
return Padding(
padding: const EdgeInsets.only(top: 56),
child: _buildCardForNotice(_error),
);
}

Widget _buildCardForNotice(Web3Notice? notice) {
if (notice == null) {
return const SizedBox.shrink();
}

if (notice is Web3Exception) {
return ErrorBannerCard.fromWeb3Error(notice);
}

const icon = Icon(Icons.check);
const color = Colors.green;

if (notice is Web3TransactionSent) {
// Also show a link to Etherscan (for Sepolia)
final url = Uri.parse(
'https://sepolia.etherscan.io/tx/${notice.transactionHash}');
final link = TextButton.icon(
onPressed: () => launchUrl(url, webOnlyWindowName: '_blank'),
icon: const Icon(Icons.open_in_new, color: Colors.white),
label: Text(
AppLocalizations.of(context)!.etherscanLinkLabel,
style: const TextStyle(color: Colors.white),
),
);
return BannerCard(
color: color,
icon: icon,
content: Text(
AppLocalizations.of(context)!.transactionSentEventLabel(
notice.transactionHash,
),
),
),
action: link,
);
}

if (notice is Web3CoinTransfer) {
final String label;
if (notice.fromNoOne()) {
label = AppLocalizations.of(context)!.coinTransferSent(
notice.value.toString(),
);
} else if (notice.toNoOne()) {
label = AppLocalizations.of(context)!.coinTransferReceived(
notice.value.toString(),
);
} else {
label = AppLocalizations.of(context)!.coinTransferExchanged(
notice.value.toString(),
notice.from,
notice.to,
);
}

return BannerCard(color: color, icon: icon, content: Text(label));
}

return BannerCard(
color: color,
icon: icon,
content: Text(notice.getDisplayMessage()),
);
}

void _onEvent(Web3Notice event) {
// An error came, so cancel the timer (don't hide the banner)
_timer?.cancel();

setState(() {
_error = event;

// Hide the banner after 5 seconds, if no other error comes
_timer = Timer(_errorHideDuration, _hideError);
});
}

void _hideError() {
_timer = null;
setState(() {
_isVisible = false;
_error = null;
});
}
}
22 changes: 19 additions & 3 deletions lib/bloc/global_errors/global_errors.dart
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
import 'dart:async';
import 'dart:html';

import 'package:flutter/cupertino.dart';
import 'package:gethdomains/contracts/events.dart';
import 'package:gethdomains/contracts/exceptions.dart';
import 'package:gethdomains/contracts/js_error_info.dart';
import 'package:js/js_util.dart';

class GlobalErrorsSink {
final StreamController<Web3Exception> _web3ErrorsStreamController =
StreamController<Web3Exception>.broadcast();

Stream<Web3Exception> get web3ErrorsStream =>
_web3ErrorsStreamController.stream;
// Singleton!
static final GlobalErrorsSink _instance = GlobalErrorsSink._internal();

GlobalErrorsSink._internal() {
setProperty(window, 'web3ErrorsSink',
allowInterop((int code, String reason) {
debugPrint('GlobalErrorsSink: web3ErrorsSink: $code, $reason');
addWeb3Error(Web3Exception.fromErrorInfo(JsErrorInfo(code, reason)));
}));
}

factory GlobalErrorsSink() => _instance;

Stream<Web3Notice> get web3ErrorsStream => _web3ErrorsStreamController.stream;

void addWeb3Error(Web3Exception error) {
debugPrint('[Web3Error] $error');
debugPrint('[Web3Exception] $error');
_web3ErrorsStreamController.add(error);
}
}
37 changes: 37 additions & 0 deletions lib/bloc/global_errors/global_events.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import 'dart:async';
import 'dart:html';

import 'package:flutter/cupertino.dart';
import 'package:gethdomains/contracts/events.dart';
import 'package:js/js_util.dart';

class GlobalEventsSink {
final StreamController<Web3Event> _web3ErrorsStreamController =
StreamController<Web3Event>.broadcast();

// Singleton!
static final GlobalEventsSink _instance = GlobalEventsSink._internal();

GlobalEventsSink._internal() {
setProperty(window, 'web3EventsSink',
allowInterop((String tag, String content) {
addWeb3Event(Web3Event.fromJsTag(tag, content));
}));
}

factory GlobalEventsSink() => _instance;

Stream<Web3Event> get web3ErrorsStream => _web3ErrorsStreamController.stream;

Stream<T> _getWeb3Events<T extends Web3Event>() {
return web3ErrorsStream.where((event) => event is T).cast<T>();
}

Stream<Web3CoinTransfer> get coinTransfers =>
_getWeb3Events<Web3CoinTransfer>();

void addWeb3Event(Web3Event event) {
debugPrint('[Web3Event] $event');
_web3ErrorsStreamController.add(event);
}
}
Loading

0 comments on commit 8c6d5df

Please sign in to comment.