diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 2703b8f..201a9bf 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -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" } \ No newline at end of file diff --git a/lib/bloc/balance/balance_bloc.dart b/lib/bloc/balance/balance_bloc.dart index 8174c56..9a7572d 100644 --- a/lib/bloc/balance/balance_bloc.dart +++ b/lib/bloc/balance/balance_bloc.dart @@ -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'; @@ -13,13 +15,14 @@ part 'balance_state.dart'; class BalanceBloc extends Bloc { final BalanceRepository balanceRepository; final GlobalErrorsSink globalErrorsSink; + final GlobalEventsSink globalEventsSink; BalanceBloc({ required this.balanceRepository, required this.globalErrorsSink, + required this.globalEventsSink, required Stream authStateChanges, }) : super(const LoadingBalanceState()) { - // TODO: Add a listener for events from the smart contract, propagated through the repository on(_onLoadBalanceEvent); on<_UpdateBalanceEvent>(_onUpdateBalanceEvent); on(_onBuyTokensEvent); @@ -35,12 +38,19 @@ class BalanceBloc extends Bloc { add(const _UpdateBalanceEvent(balance: null)); } }); + + // Listen to coin transfers + globalEventsSink.coinTransfers.listen((event) { + debugPrint('BalanceBloc: globalEventsSink.coinTransfers.listen: $event'); + add(const LoadBalanceEvent()); + }); } FutureOr _onLoadBalanceEvent( LoadBalanceEvent event, Emitter emit, ) async { + debugPrint('BalanceBloc: _onLoadBalanceEvent'); emit(const LoadingBalanceState()); try { final balance = await balanceRepository.getBalance(); @@ -54,7 +64,8 @@ class BalanceBloc extends Bloc { /// So, the data it gives can be trusted (not coming from outside classes). FutureOr _onUpdateBalanceEvent( _UpdateBalanceEvent event, - Emitter emit,) async { + Emitter emit, + ) async { if (event.balance == null) { emit(const UnavailableBalanceState()); } else { @@ -64,31 +75,40 @@ class BalanceBloc extends Bloc { void buyTokens(BigInt amount) => add(BuyTokensEvent(amount)); - FutureOr _wrapSmartContractInvocation(Future Function() invocation, - Emitter emit,) async { + FutureOr _wrapSmartContractInvocation( + Future Function() invocation, + Emitter 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 _onBuyTokensEvent(BuyTokensEvent event, - Emitter emit,) => - _wrapSmartContractInvocation(() { - return balanceRepository.purchaseTokens(event.amount); + FutureOr _onBuyTokensEvent( + BuyTokensEvent event, + Emitter emit, + ) => + _wrapSmartContractInvocation(() async { + final txHash = await balanceRepository.purchaseTokens(event.amount); + globalEventsSink.addWeb3Event(Web3TransactionSent(txHash)); }, emit); void sellTokens(BigInt amount) => add(SellTokensEvent(amount)); - FutureOr _onSellTokensEvent(SellTokensEvent event, - Emitter emit,) => - _wrapSmartContractInvocation(() { - return balanceRepository.sellTokens(event.amount); + FutureOr _onSellTokensEvent( + SellTokensEvent event, + Emitter emit, + ) => + _wrapSmartContractInvocation(() async { + final txHash = await balanceRepository.sellTokens(event.amount); + globalEventsSink.addWeb3Event(Web3TransactionSent(txHash)); }, emit); } diff --git a/lib/bloc/global_errors/banner.dart b/lib/bloc/global_errors/banner.dart index e83fc81..48e5122 100644 --- a/lib/bloc/global_errors/banner.dart +++ b/lib/bloc/global_errors/banner.dart @@ -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}); @@ -15,53 +19,119 @@ class GlobalErrorsBanner extends StatefulWidget { } class _GlobalErrorsBannerState extends State { - 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? _errorsSubscription; + StreamSubscription? _eventsSubscription; + + Web3Notice? _error; Timer? _timer; @override void initState() { super.initState(); final globalErrorsSink = context.read(); - globalErrorsSink.web3ErrorsStream.listen((error) { - // An error came, so cancel the timer (don't hide the banner) - _timer?.cancel(); + final globalEventsSink = context.read(); - 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; }); } } diff --git a/lib/bloc/global_errors/global_errors.dart b/lib/bloc/global_errors/global_errors.dart index 7009415..b7e2e4c 100644 --- a/lib/bloc/global_errors/global_errors.dart +++ b/lib/bloc/global_errors/global_errors.dart @@ -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 _web3ErrorsStreamController = StreamController.broadcast(); - Stream 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 get web3ErrorsStream => _web3ErrorsStreamController.stream; void addWeb3Error(Web3Exception error) { - debugPrint('[Web3Error] $error'); + debugPrint('[Web3Exception] $error'); _web3ErrorsStreamController.add(error); } } diff --git a/lib/bloc/global_errors/global_events.dart b/lib/bloc/global_errors/global_events.dart new file mode 100644 index 0000000..a5bc568 --- /dev/null +++ b/lib/bloc/global_errors/global_events.dart @@ -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 _web3ErrorsStreamController = + StreamController.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 get web3ErrorsStream => _web3ErrorsStreamController.stream; + + Stream _getWeb3Events() { + return web3ErrorsStream.where((event) => event is T).cast(); + } + + Stream get coinTransfers => + _getWeb3Events(); + + void addWeb3Event(Web3Event event) { + debugPrint('[Web3Event] $event'); + _web3ErrorsStreamController.add(event); + } +} diff --git a/lib/contracts/events.dart b/lib/contracts/events.dart new file mode 100644 index 0000000..afc24d2 --- /dev/null +++ b/lib/contracts/events.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +abstract class Web3Notice { + const Web3Notice(); + + String getDisplayMessage(); + + @override + String toString() => '$runtimeType{message: "${getDisplayMessage()}"}'; +} + +sealed class Web3Event extends Web3Notice { + final String message; + + const Web3Event(this.message); + + factory Web3Event.fromJsTag(String tag, String message) { + return switch (tag) { + 'transactionSent' => Web3TransactionSent(message), + 'coinTransfer' => Web3CoinTransfer.fromJson(message), + _ => throw Exception('Unknown Web3Event tag: $tag'), + }; + } + + @override + String getDisplayMessage() => message; +} + +class Web3TransactionSent extends Web3Event { + final String transactionHash; + + const Web3TransactionSent(this.transactionHash) + : super('Transaction sent: $transactionHash'); +} + +class Web3CoinTransfer extends Web3Event { + final String from; + final String to; + final BigInt value; + + const Web3CoinTransfer({ + required this.from, + required this.to, + required this.value, + }) : super('Transfer $value from $from to $to'); + + factory Web3CoinTransfer.fromJson(String json) { + final data = jsonDecode(json); + return Web3CoinTransfer( + from: data['from'], + to: data['to'], + value: BigInt.parse(data['value']), + ); + } + + bool fromNoOne() => _addressIsNoOne(from); + + bool toNoOne() => _addressIsNoOne(to); + + // First 2 chars are 0x, and the rest are all zeros + static bool _addressIsNoOne(String address) => + address.startsWith('0x') && + address.substring(2).split('').every((c) => c == '0'); +} diff --git a/lib/contracts/exceptions.dart b/lib/contracts/exceptions.dart index dbc24cd..915babd 100644 --- a/lib/contracts/exceptions.dart +++ b/lib/contracts/exceptions.dart @@ -1,6 +1,7 @@ +import 'package:gethdomains/contracts/events.dart'; import 'package:gethdomains/contracts/js_error_info.dart'; -class Web3Exception implements Exception { +class Web3Exception extends Web3Notice implements Exception { final int code; const Web3Exception(this.code); @@ -18,6 +19,7 @@ class Web3Exception implements Exception { return '$runtimeType{code: $code}'; } + @override String getDisplayMessage() => 'Unknown error with code $code'; } diff --git a/lib/contracts/geth_contract.dart b/lib/contracts/geth_contract.dart index 0e9186a..6208a0b 100644 --- a/lib/contracts/geth_contract.dart +++ b/lib/contracts/geth_contract.dart @@ -33,7 +33,7 @@ class GethContract { _purchaseTokensFees(amount.toString()), ).then((value) => BigInt.parse(value)); - Future purchaseTokens(BigInt amount) => metamaskPromise( + Future purchaseTokens(BigInt amount) => metamaskPromise( _purchaseTokens(amount.toString()), ); @@ -41,7 +41,7 @@ class GethContract { _withdrawEthFees(amount.toString()), ).then((value) => BigInt.parse(value)); - Future sellTokens(BigInt amount) => metamaskPromise( + Future sellTokens(BigInt amount) => metamaskPromise( _withdrawEth(amount.toString()), ); } diff --git a/lib/di/base.dart b/lib/di/base.dart index 71370ed..5abab37 100644 --- a/lib/di/base.dart +++ b/lib/di/base.dart @@ -11,6 +11,7 @@ class _BaseDependencies extends StatelessWidget { providers: [ RepositoryProvider(create: (_) => const ThemeUpdater()), RepositoryProvider(create: (_) => GlobalErrorsSink()), + RepositoryProvider(create: (_) => GlobalEventsSink()), RepositoryProvider(create: (_) => SepoliaNetworkDetector()), ], child: Builder(builder: builder), diff --git a/lib/di/bloc.dart b/lib/di/bloc.dart index 4dc2664..0437a0b 100644 --- a/lib/di/bloc.dart +++ b/lib/di/bloc.dart @@ -28,6 +28,7 @@ class _BlocDependencies extends StatelessWidget { create: (context) => BalanceBloc( balanceRepository: context.read(), globalErrorsSink: context.read(), + globalEventsSink: context.read(), authStateChanges: authBloc.stream, ), lazy: false, diff --git a/lib/di/injector.dart b/lib/di/injector.dart index 68f6554..54b84ec 100644 --- a/lib/di/injector.dart +++ b/lib/di/injector.dart @@ -4,6 +4,7 @@ import 'package:gethdomains/bloc/auth/auth_bloc.dart'; import 'package:gethdomains/bloc/balance/balance_bloc.dart'; import 'package:gethdomains/bloc/domains/domains_bloc.dart'; import 'package:gethdomains/bloc/global_errors/global_errors.dart'; +import 'package:gethdomains/bloc/global_errors/global_events.dart'; import 'package:gethdomains/bloc/sepolia/sepolia_bloc.dart'; import 'package:gethdomains/bloc/settings/settings.dart'; import 'package:gethdomains/bloc/theme/theme.dart'; diff --git a/lib/repository/balance_repository.dart b/lib/repository/balance_repository.dart index 4af6f82..deeddad 100644 --- a/lib/repository/balance_repository.dart +++ b/lib/repository/balance_repository.dart @@ -14,14 +14,14 @@ class BalanceRepository { gethContract.purchaseTokensFees(BigInt.one); /// Purchase tokens - Future purchaseTokens(BigInt amount) => + Future purchaseTokens(BigInt amount) => gethContract.purchaseTokens(amount); /// Get the fees for selling tokens Future getSellTokensFees() => gethContract.sellTokensFees(BigInt.one); /// Sell tokens - Future sellTokens(BigInt amount) => gethContract.sellTokens(amount); + Future sellTokens(BigInt amount) => gethContract.sellTokens(amount); /// Handle balance changes // TODO: Stream get balanceChanges => gethContract.balanceChanges; diff --git a/lib/widget/banner_card.dart b/lib/widget/banner_card.dart index c86a43e..7661707 100644 --- a/lib/widget/banner_card.dart +++ b/lib/widget/banner_card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:gethdomains/contracts/exceptions.dart'; class BannerCard extends StatelessWidget { final Color color; @@ -54,3 +55,21 @@ class BannerCard extends StatelessWidget { ); } } + +class ErrorBannerCard extends BannerCard { + const ErrorBannerCard({ + Key? key, + required Widget content, + Widget? action, + }) : super( + key: key, + color: Colors.red, + icon: const Icon(Icons.error_outline), + content: content, + action: action, + ); + + factory ErrorBannerCard.fromWeb3Error(Web3Exception error) { + return ErrorBannerCard(content: Text(error.getDisplayMessage())); + } +} diff --git a/pubspec.lock b/pubspec.lock index 14d46e2..9c3bc23 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -424,7 +424,7 @@ packages: source: hosted version: "1.0.4" js: - dependency: transitive + dependency: "direct main" description: name: js sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 diff --git a/pubspec.yaml b/pubspec.yaml index b043fa1..fd84e5c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,6 +52,7 @@ dependencies: url_launcher: ^6.2.1 local_hero: ^0.3.0 decimal: ^2.3.3 + js: ^0.6.7 data_encoder: path: ./data_encoder diff --git a/web/big_icon.png b/web/big_icon.png new file mode 100644 index 0000000..e5405c2 Binary files /dev/null and b/web/big_icon.png differ diff --git a/web/favicon.png b/web/favicon.png index 8aaa46a..70db0bd 100644 Binary files a/web/favicon.png and b/web/favicon.png differ diff --git a/web/js/geth_contract.js b/web/js/geth_contract.js index 68e567e..6767c12 100644 --- a/web/js/geth_contract.js +++ b/web/js/geth_contract.js @@ -457,7 +457,32 @@ async function geth_purchaseTokens(amount) { const [contract, user] = await _initializeGethContract(); const weiAmount = web3.utils.toWei(amount, "ether").slice(0, -3); // Equivalent as /1000 const gas = await contract.methods.purchaseTokens().estimateGas({from: user, value: weiAmount}); - await contract.methods.purchaseTokens().send({from: user, value: weiAmount, gas: gas}); + + const txHash = await wrapContractSend(contract.methods.purchaseTokens() + .send({from: user, value: weiAmount, gas: gas})); + + // Add token to Metamask if not already present + const alreadyAdded = localStorage.getItem('geth_token_added'); + if (ethereum && ethereum.request && !alreadyAdded) { + ethereum.request({ + method: 'wallet_watchAsset', + params: { + type: 'ERC20', + options: { + address: geth_contract_address, // The address that the token is at. + symbol: 'GETH', // A ticker symbol or shorthand, up to 5 chars. + decimals: 0, // The number of decimals in the token + image: window.location.origin + '/big_icon.png', // A string url of the token logo + }, + }, + }).then(() => { + // Successfully added token to Metamask + // Save not to ask again + localStorage.setItem('geth_token_added', 'true'); + }); + } + + return txHash; } async function geth_withdrawEther_fees(amount) { @@ -469,5 +494,25 @@ async function geth_withdrawEther_fees(amount) { async function geth_withdrawEther(amount) { const [contract, user] = await _initializeGethContract(); const gas = await contract.methods.purchaseWei(amount).estimateGas({from: user}); - await contract.methods.purchaseWei(amount).send({from: user, gas: gas}); -} \ No newline at end of file + return wrapContractSend(contract.methods.purchaseWei(amount).send({from: user, gas: gas})); +} + +(async function() { + const [contract, user] = await _initializeGethContract(); + // Subscribe to Transfer events from or to me + const _onEvent = function(error, event) { + if (error) { + console.error(error); + web3ErrorsSink(error.code, error.data.reason); + } else { + console.log(event); + web3EventsSink('coinTransfer', JSON.stringify({ + from: event.returnValues.from, + to: event.returnValues.to, + value: event.returnValues.value, + })); + } + }; + contract.events.Transfer({filter: {from: user}}, _onEvent); + contract.events.Transfer({filter: {to: user}}, _onEvent); +})().catch(console.error); \ No newline at end of file diff --git a/web/js/web3.js b/web/js/web3.js index 4a80c00..c0d31f3 100644 --- a/web/js/web3.js +++ b/web/js/web3.js @@ -66,4 +66,31 @@ async function login() { } return getCurrentUser(); +} + +async function wrapContractSend(originalSend) { + // Return as soon as the transactionHash is ready + return new Promise((resolve, reject) => { + let txHash = null; + + originalSend.once('transactionHash', function(hash) { + console.log('Transaction Hash:', hash); + txHash = hash; + resolve(hash); + }) + .on('error', function(error) { + console.log('Error:', error); + if (txHash === null) { + reject(error); + } else { + // Use events + const reason = error.data && error.data.reason ? error.data.reason : error.message; + web3ErrorsSink(error.code, reason); + } + }) + .then(function(receipt) { + // will be fired once the receipt is mined + console.log('Receipt mined!', receipt); + }); + }); } \ No newline at end of file