fix(shopinbit): fix ShopInbit USDT payments
What changed, and why it matters
This commit fixes the ShopInBit payment feature so it can correctly pay with USDT (an Ethereum token) instead of only supporting Bitcoin and Monero. It also adds a 'check for payment' button when the user has no compatible wallet, and makes several UI labels and navigation fixes. There is no clear security vulnerability in the diff; it is a feature/bug-fix patch.
Review the hardcoded USDT contract address and ensure it matches the intended network (Ethereum mainnet). Verify that Wallet.loadTokenWallet and prepareSend/confirmSend handle ERC-20 token transfers and gas/fee accounting correctly. Test the new payment-status polling and navigation flows on both mobile and desktop.
Security signals we found
Hardcoded USDT contract address (0xdac17f958d2ee523a2206206994597c13d831ec7) used for token detection
Address type fallback to AddressType.ethereum when token wallet cannot classify the recipient address
Manual payment-status polling with user-facing flushbar messages
Navigation/pop fixes that could affect UI state consistency
Evidence from the diff
The patch extends the ShopInBit checkout flow to support ERC-20 token payments (specifically USDT on Ethereum). It introduces an optional EthContract tokenContract parameter through ShopInBitSendFromView, ShopInBitSendFromCard, and ShopInBitConfirmSendView, uses Wallet.loadTokenWallet to create a token-aware wallet for prepareSend/confirmSend, and updates amount formatting, fee display, and balance lookups to use token decimals/symbol. It also adds a manual payment-status polling helper (_checkForPayment) and changes the primary button label/action when no compatible wallet exists. Several navigator pop calls are corrected to use rootNavigator: true consistently.
Changed components
lib/pages/shopinbit/shopinbit_confirm_send_view.dartlib/pages/shopinbit/shopinbit_payment_view.dartlib/pages/shopinbit/shopinbit_send_from_view.dartInspect captured patch +396 / −133
diff --git a/lib/pages/shopinbit/shopinbit_confirm_send_view.dart b/lib/pages/shopinbit/shopinbit_confirm_send_view.dart
index 47b13d3..6a512fd 100644
--- a/lib/pages/shopinbit/shopinbit_confirm_send_view.dart
+++ b/lib/pages/shopinbit/shopinbit_confirm_send_view.dart
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../db/isar/main_db.dart';
import '../../models/isar/models/isar_models.dart';
import '../../models/shopinbit/shopinbit_order_model.dart';
import '../../notifications/show_flush_bar.dart';
@@ -18,6 +19,8 @@ import '../../utilities/text_styles.dart';
import '../../utilities/util.dart';
import '../../wallets/isar/providers/wallet_info_provider.dart';
import '../../wallets/models/tx_data.dart';
+import '../../wallets/wallet/impl/ethereum_wallet.dart';
+import '../../wallets/wallet/wallet.dart';
import '../../widgets/background.dart';
import '../../widgets/conditional_parent.dart';
import '../../widgets/custom_buttons/app_bar_icon_button.dart';
@@ -39,6 +42,7 @@ class ShopInBitConfirmSendView extends ConsumerStatefulWidget {
required this.walletId,
this.routeOnSuccessName = WalletView.routeName,
required this.model,
+ this.tokenContract,
});
static const String routeName = "/shopInBitConfirmSend";
@@ -47,6 +51,7 @@ class ShopInBitConfirmSendView extends ConsumerStatefulWidget {
final String walletId;
final String routeOnSuccessName;
final ShopInBitOrderModel model;
+ final EthContract? tokenContract;
@override
ConsumerState<ShopInBitConfirmSendView> createState() =>
@@ -62,8 +67,8 @@ class _ShopInBitConfirmSendViewState
final isDesktop = Util.isDesktop;
Future<void> _attemptSend(BuildContext context) async {
- final wallet = ref.read(pWallets).getWallet(walletId);
- final coin = wallet.info.coin;
+ final parentWallet = ref.read(pWallets).getWallet(walletId);
+ final coin = parentWallet.info.coin;
final sendProgressController = ProgressAndSuccessController();
@@ -89,6 +94,13 @@ class _ShopInBitConfirmSendViewState
final String note = widget.txData.note ?? "";
try {
+ final wallet = widget.tokenContract != null
+ ? Wallet.loadTokenWallet(
+ ethWallet: parentWallet as EthereumWallet,
+ contract: widget.tokenContract!,
+ )
+ : parentWallet;
+
txidFuture = wallet.confirmSend(txData: widget.txData);
unawaited(wallet.refresh());
@@ -109,15 +121,19 @@ class _ShopInBitConfirmSendViewState
// Update model status after successful broadcast
model.status = ShopInBitOrderStatus.paymentPending;
- model.paymentMethod = coin.ticker.toUpperCase();
+ model.paymentMethod = widget.tokenContract != null
+ ? widget.tokenContract!.symbol.toUpperCase()
+ : coin.ticker.toUpperCase();
+
+ unawaited(MainDB.instance.putShopInBitTicket(model.toIsarTicket()));
// pop back to wallet
if (context.mounted) {
- if (Util.isDesktop) {
- // pop sending dialog
- Navigator.of(context, rootNavigator: true).pop();
+ // pop sending dialog (pushed via showDialog which uses root navigator)
+ Navigator.of(context, rootNavigator: true).pop();
- // one day we'll do routing right
+ if (Util.isDesktop) {
+ // pop the confirm send desktop dialog
Navigator.of(context, rootNavigator: true).pop();
}
@@ -130,8 +146,8 @@ class _ShopInBitConfirmSendViewState
stackTrace: s,
);
- // pop sending dialog
- Navigator.of(context).pop();
+ // pop sending dialog (pushed via showDialog which uses root navigator)
+ Navigator.of(context, rootNavigator: true).pop();
await showDialog<dynamic>(
context: context,
@@ -297,7 +313,7 @@ class _ShopInBitConfirmSendViewState
const AppBarBackButton(isCompact: true, iconSize: 23),
const SizedBox(width: 12),
Text(
- "Confirm ${ref.watch(pWalletCoin(walletId)).ticker} transaction",
+ "Confirm ${widget.tokenContract?.symbol ?? ref.watch(pWalletCoin(walletId)).ticker} transaction",
style: STextStyles.desktopH3(context),
),
],
@@ -373,8 +389,26 @@ class _ShopInBitConfirmSendViewState
final coin = ref.read(pWalletCoin(walletId));
final fee = widget.txData.fee!;
final amount = widget.txData.amountWithoutChange!;
- final total = amount + fee;
+ if (widget.tokenContract != null) {
+ final amountStr =
+ "${amount.decimal.toStringAsFixed(widget.tokenContract!.decimals)} ${widget.tokenContract!.symbol}";
+ final feeStr = ref
+ .watch(pAmountFormatter(coin))
+ .format(fee);
+ return Text(
+ "$amountStr + $feeStr",
+ style: STextStyles.itemSubtitle12(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textConfirmTotalAmount,
+ ),
+ textAlign: TextAlign.right,
+ );
+ }
+
+ final total = amount + fee;
return Text(
ref.watch(pAmountFormatter(coin)).format(total),
style: STextStyles.itemSubtitle12(context)
@@ -434,7 +468,7 @@ class _ShopInBitConfirmSendViewState
),
),
child: Text(
- "Send ${ref.watch(pWalletCoin(walletId)).ticker}",
+ "Send ${widget.tokenContract?.symbol ?? ref.watch(pWalletCoin(walletId)).ticker}",
style: isDesktop
? STextStyles.desktopTextMedium(context)
: STextStyles.pageTitleH1(context),
@@ -455,7 +489,9 @@ class _ShopInBitConfirmSendViewState
Text("Send from", style: STextStyles.smallMed12(context)),
const SizedBox(height: 4),
Text(
- ref.watch(pWalletName(walletId)),
+ widget.tokenContract != null
+ ? "${ref.watch(pWalletName(walletId))} (${widget.tokenContract!.symbol})"
+ : ref.watch(pWalletName(walletId)),
style: STextStyles.itemSubtitle12(context),
),
],
@@ -503,59 +539,64 @@ class _ShopInBitConfirmSendViewState
builder: (child) => Row(
children: [
child,
- Builder(
- builder: (context) {
- final coin = ref.watch(pWalletCoin(walletId));
- final price = ref.watch(
- priceAnd24hChangeNotifierProvider.select(
- (value) => value.getPrice(coin),
- ),
- );
- final String extra;
- if (price == null) {
- extra = "";
- } else {
- final amountWithoutChange =
- widget.txData.amountWithoutChange!;
- final value =
- (price.value * amountWithoutChange.decimal)
- .toAmount(fractionDigits: 2);
- final currency = ref.watch(
- prefsChangeNotifierProvider.select(
- (value) => value.currency,
- ),
- );
- final locale = ref.watch(
- localeServiceChangeNotifierProvider.select(
- (value) => value.locale,
+ if (widget.tokenContract == null)
+ Builder(
+ builder: (context) {
+ final coin = ref.watch(pWalletCoin(walletId));
+ final price = ref.watch(
+ priceAnd24hChangeNotifierProvider.select(
+ (value) => value.getPrice(coin),
),
);
+ final String extra;
+ if (price == null) {
+ extra = "";
+ } else {
+ final amountWithoutChange =
+ widget.txData.amountWithoutChange!;
+ final value =
+ (price.value * amountWithoutChange.decimal)
+ .toAmount(fractionDigits: 2);
+ final currency = ref.watch(
+ prefsChangeNotifierProvider.select(
+ (value) => value.currency,
+ ),
+ );
+ final locale = ref.watch(
+ localeServiceChangeNotifierProvider.select(
+ (value) => value.locale,
+ ),
+ );
- extra =
- " | ${value.fiatString(locale: locale)} $currency";
- }
+ extra =
+ " | ${value.fiatString(locale: locale)} $currency";
+ }
- return Text(
- extra,
- style:
- STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color: Theme.of(
+ return Text(
+ extra,
+ style:
+ STextStyles.desktopTextExtraExtraSmall(
context,
- ).extension<StackColors>()!.textSubtitle2,
- ),
- );
- },
- ),
+ ).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textSubtitle2,
+ ),
+ );
+ },
+ ),
],
),
child: Text(
- ref
- .watch(
- pAmountFormatter(ref.watch(pWalletCoin(walletId))),
- )
- .format(widget.txData.amountWithoutChange!),
+ widget.tokenContract != null
+ ? "${widget.txData.amountWithoutChange!.decimal.toStringAsFixed(widget.tokenContract!.decimals)} ${widget.tokenContract!.symbol}"
+ : ref
+ .watch(
+ pAmountFormatter(
+ ref.watch(pWalletCoin(walletId)),
+ ),
+ )
+ .format(widget.txData.amountWithoutChange!),
style: STextStyles.itemSubtitle12(context),
textAlign: TextAlign.right,
),
@@ -655,8 +696,25 @@ class _ShopInBitConfirmSendViewState
final coin = ref.watch(pWalletCoin(walletId));
final fee = widget.txData.fee!;
final amount = widget.txData.amountWithoutChange!;
- final total = amount + fee;
+ if (widget.tokenContract != null) {
+ final amountStr =
+ "${amount.decimal.toStringAsFixed(widget.tokenContract!.decimals)} ${widget.tokenContract!.symbol}";
+ final feeStr = ref
+ .watch(pAmountFormatter(coin))
+ .format(fee);
+ return Text(
+ "$amountStr + $feeStr",
+ style: STextStyles.itemSubtitle12(context).copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textConfirmTotalAmount,
+ ),
+ textAlign: TextAlign.right,
+ );
+ }
+
+ final total = amount + fee;
return Text(
ref.watch(pAmountFormatter(coin)).format(total),
style: STextStyles.itemSubtitle12(context).copyWith(
diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart
index f8db2a0..4c91dbf 100644
--- a/lib/pages/shopinbit/shopinbit_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_view.dart
@@ -9,8 +9,10 @@ import 'package:flutter_svg/svg.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../app_config.dart';
+import '../../models/isar/models/ethereum/eth_contract.dart';
import '../../models/shopinbit/shopinbit_order_model.dart';
import '../../notifications/show_flush_bar.dart';
+import '../../providers/providers.dart';
import '../../route_generator.dart';
import '../../services/shopinbit/shopinbit_service.dart';
import '../../services/shopinbit/src/models/payment.dart';
@@ -20,6 +22,7 @@ import '../../utilities/amount/amount.dart';
import '../../utilities/assets.dart';
import '../../utilities/text_styles.dart';
import '../../utilities/util.dart';
+import '../../wallets/crypto_currency/crypto_currency.dart';
import '../../widgets/background.dart';
import '../../widgets/custom_buttons/app_bar_icon_button.dart';
import '../../widgets/desktop/desktop_dialog.dart';
@@ -160,26 +163,98 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
+ Future<void> _checkForPayment() async {
+ _pollTimer?.cancel();
+ setState(() => _loading = true);
+ try {
+ final resp = await ShopInBitService.instance.client.getPayment(
+ widget.model.apiTicketId,
+ );
+ if (!resp.hasError && resp.value != null && mounted) {
+ setState(() => _applyPaymentInfo(resp.value!));
+ final status = resp.value!.status;
+ if (const {
+ 'paid',
+ 'paid_over',
+ 'paid_late',
+ 'payment_processing',
+ }.contains(status)) {
+ if (mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.success,
+ message: "Payment received!",
+ context: context,
+ ),
+ );
+ }
+ } else if (status == 'underpaid') {
+ if (mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.warning,
+ message: "Underpaid. Remaining: ${resp.value!.due ?? '?'} EUR.",
+ context: context,
+ ),
+ );
+ }
+ } else {
+ if (mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.info,
+ message: "No payment detected yet.",
+ context: context,
+ ),
+ );
+ }
+ }
+ } else if (mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.warning,
+ message: resp.exception?.message ?? "Failed to check payment.",
+ context: context,
+ ),
+ );
+ }
+ } catch (e) {
+ if (mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.warning,
+ message: e.toString(),
+ context: context,
+ ),
+ );
+ }
+ } finally {
+ if (mounted) {
+ setState(() => _loading = false);
+ if (!_isTerminal) {
+ _startPolling();
+ }
+ }
+ }
+ }
+
void _confirmPayment() {
_pollTimer?.cancel();
final method = _methods[_selectedMethod];
final ticker = method.toUpperCase();
- // Only BTC and XMR have Stack Wallet coin classes for sending
- final coin = (ticker == "BTC" || ticker == "XMR")
- ? AppConfig.getCryptoCurrencyForTicker(ticker)
- : null;
+ final coin = AppConfig.getCryptoCurrencyForTicker(ticker);
+
+ String address = "";
+ Amount? amount;
+ EthContract? tokenContract;
- if (coin != null && _currentAddress.isNotEmpty) {
- // Try to parse BIP21/Monero URI for address + amount
+ if (_currentAddress.isNotEmpty) {
final parsed = AddressUtils.parsePaymentUri(_currentAddress);
- // Extract address: from parsed URI, or strip scheme manually
- String address;
if (parsed?.address != null && parsed!.address.isNotEmpty) {
address = parsed.address;
} else {
- // Fallback: strip URI scheme prefix if present
final raw = _currentAddress;
final colonIdx = raw.indexOf(':');
if (colonIdx != -1) {
@@ -191,10 +266,8 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
}
}
- // Try amount from: 1) parsed URI, 2) raw query param, 3) due
String? amountStr = parsed?.amount;
if (amountStr == null || amountStr.isEmpty) {
- // Try extracting from raw URI query string
final uri = Uri.tryParse(_currentAddress);
if (uri != null) {
amountStr = uri.queryParameters['amount'];
@@ -204,52 +277,47 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
amountStr = _paymentInfo?.due;
}
- Amount? amount;
+ final int fractionDigits;
+ if (coin != null) {
+ fractionDigits = coin.fractionDigits;
+ } else if (ticker == "USDT") {
+ fractionDigits = 6;
+ } else {
+ fractionDigits = 8;
+ }
+
if (amountStr != null && amountStr.isNotEmpty) {
try {
amount = Amount.fromDecimal(
Decimal.parse(amountStr),
- fractionDigits: coin.fractionDigits,
+ fractionDigits: fractionDigits,
);
- } catch (_) {
- // amount stays null
- }
+ } catch (_) {}
}
+ }
- if (Util.isDesktop) {
- Navigator.of(context, rootNavigator: true).pop();
- unawaited(
- showDialog<void>(
- context: context,
- builder: (_) => ShopInBitSendFromView(
- coin: coin,
- amount: amount,
- address: address,
- model: widget.model,
- shouldPopRoot: true,
- ),
- ),
- );
- } else {
- Navigator.of(context).push(
- RouteGenerator.getRoute<dynamic>(
- shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute,
- builder: (_) => ShopInBitSendFromView(
- coin: coin,
- amount: amount,
- address: address,
- model: widget.model,
- ),
- settings: const RouteSettings(
- name: ShopInBitSendFromView.routeName,
- ),
- ),
- );
- }
+ if (coin != null && address.isNotEmpty) {
+ _navigateToSendFrom(coin: coin, amount: amount, address: address);
return;
}
- // USDT or other unsupported coins: keep existing behavior
+ if (ticker == "USDT" && address.isNotEmpty) {
+ const usdtAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7";
+ tokenContract = ref.read(mainDBProvider).getEthContractSync(usdtAddress);
+ if (tokenContract != null) {
+ final ethCoin = AppConfig.getCryptoCurrencyForTicker("ETH");
+ if (ethCoin != null) {
+ _navigateToSendFrom(
+ coin: ethCoin,
+ amount: amount,
+ address: address,
+ tokenContract: tokenContract,
+ );
+ return;
+ }
+ }
+ }
+
widget.model.status = ShopInBitOrderStatus.paymentPending;
widget.model.paymentMethod = method;
@@ -260,6 +328,44 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
}
}
+ void _navigateToSendFrom({
+ required CryptoCurrency coin,
+ required Amount? amount,
+ required String address,
+ EthContract? tokenContract,
+ }) {
+ if (Util.isDesktop) {
+ Navigator.of(context, rootNavigator: true).pop();
+ unawaited(
+ showDialog<void>(
+ context: context,
+ builder: (_) => ShopInBitSendFromView(
+ coin: coin,
+ amount: amount,
+ address: address,
+ model: widget.model,
+ shouldPopRoot: true,
+ tokenContract: tokenContract,
+ ),
+ ),
+ );
+ } else {
+ Navigator.of(context).push(
+ RouteGenerator.getRoute<dynamic>(
+ shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute,
+ builder: (_) => ShopInBitSendFromView(
+ coin: coin,
+ amount: amount,
+ address: address,
+ model: widget.model,
+ tokenContract: tokenContract,
+ ),
+ settings: const RouteSettings(name: ShopInBitSendFromView.routeName),
+ ),
+ );
+ }
+ }
+
void _copyAddress(BuildContext context) {
Clipboard.setData(ClipboardData(text: _currentAddress));
showFloatingFlushBar(
@@ -273,6 +379,30 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
@override
Widget build(BuildContext context) {
final isDesktop = Util.isDesktop;
+ final ticker = _selectedMethod < _methods.length
+ ? _methods[_selectedMethod].toUpperCase()
+ : "";
+
+ bool hasWallets = false;
+ if (ticker == "USDT") {
+ const usdtAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7";
+ hasWallets = ref
+ .watch(pWallets)
+ .wallets
+ .any(
+ (w) =>
+ w.info.coin is Ethereum &&
+ w.info.tokenContractAddresses.contains(usdtAddress),
+ );
+ } else {
+ final coin = AppConfig.getCryptoCurrencyForTicker(ticker);
+ if (coin != null) {
+ hasWallets = ref
+ .watch(pWallets)
+ .wallets
+ .any((e) => e.info.coin == coin);
+ }
+ }
const loadingOverlay = Center(
child: SizedBox(
@@ -585,9 +715,11 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
),
SizedBox(height: isDesktop ? 16 : 12),
PrimaryButton(
- label: "PAY NOW",
+ label: hasWallets ? "PAY NOW" : "CHECK FOR PAYMENT",
enabled: _payNowEnabled,
- onPressed: _payNowEnabled ? _confirmPayment : null,
+ onPressed: _payNowEnabled
+ ? (hasWallets ? _confirmPayment : _checkForPayment)
+ : null,
),
],
);
diff --git a/lib/pages/shopinbit/shopinbit_send_from_view.dart b/lib/pages/shopinbit/shopinbit_send_from_view.dart
index 091dc6a..6ed9bdf 100644
--- a/lib/pages/shopinbit/shopinbit_send_from_view.dart
+++ b/lib/pages/shopinbit/shopinbit_send_from_view.dart
@@ -6,6 +6,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/svg.dart';
import '../../app_config.dart';
+import '../../models/isar/models/blockchain_data/address.dart';
+import '../../models/isar/models/ethereum/eth_contract.dart';
import '../../models/shopinbit/shopinbit_order_model.dart';
import '../../providers/providers.dart';
import '../../route_generator.dart';
@@ -20,10 +22,13 @@ import '../../utilities/logger.dart';
import '../../utilities/text_styles.dart';
import '../../utilities/util.dart';
import '../../wallets/crypto_currency/crypto_currency.dart';
+import '../../wallets/isar/providers/eth/token_balance_provider.dart';
import '../../wallets/isar/providers/wallet_info_provider.dart';
import '../../wallets/models/tx_data.dart';
import '../../services/shopinbit/shopinbit_service.dart';
+import '../../wallets/wallet/impl/ethereum_wallet.dart';
import '../../wallets/wallet/intermediate/external_wallet.dart';
+import '../../wallets/wallet/wallet.dart';
import '../../widgets/background.dart';
import '../../widgets/conditional_parent.dart';
import '../../widgets/custom_buttons/app_bar_icon_button.dart';
@@ -31,6 +36,7 @@ import '../../widgets/desktop/desktop_dialog.dart';
import '../../widgets/desktop/desktop_dialog_close_button.dart';
import '../../widgets/rounded_white_container.dart';
import '../../widgets/stack_dialog.dart';
+import '../../pages_desktop_specific/desktop_home_view.dart';
import '../home_view/home_view.dart';
import '../send_view/sub_widgets/building_transaction_dialog.dart';
import 'shopinbit_confirm_send_view.dart';
@@ -43,6 +49,7 @@ class ShopInBitSendFromView extends ConsumerStatefulWidget {
this.amount,
required this.address,
this.shouldPopRoot = false,
+ this.tokenContract,
});
static const String routeName = "/shopInBitSendFrom";
@@ -52,6 +59,7 @@ class ShopInBitSendFromView extends ConsumerStatefulWidget {
final String address;
final ShopInBitOrderModel model;
final bool shouldPopRoot;
+ final EthContract? tokenContract;
@override
ConsumerState<ShopInBitSendFromView> createState() =>
@@ -63,6 +71,7 @@ class _ShopInBitSendFromViewState extends ConsumerState<ShopInBitSendFromView> {
late final Amount? amount;
late final String address;
late final ShopInBitOrderModel model;
+ late final EthContract? tokenContract;
@override
void initState() {
@@ -70,17 +79,32 @@ class _ShopInBitSendFromViewState extends ConsumerState<ShopInBitSendFromView> {
address = widget.address;
amount = widget.amount;
model = widget.model;
+ tokenContract = widget.tokenContract;
super.initState();
}
@override
Widget build(BuildContext context) {
- final walletIds = ref
- .watch(pWallets)
- .wallets
- .where((e) => e.info.coin == coin)
- .map((e) => e.walletId)
- .toList();
+ final List<String> walletIds;
+ if (tokenContract != null) {
+ walletIds = ref
+ .watch(pWallets)
+ .wallets
+ .where(
+ (w) =>
+ w.info.coin == coin &&
+ w.info.tokenContractAddresses.contains(tokenContract!.address),
+ )
+ .map((e) => e.walletId)
+ .toList();
+ } else {
+ walletIds = ref
+ .watch(pWallets)
+ .wallets
+ .where((e) => e.info.coin == coin)
+ .map((e) => e.walletId)
+ .toList();
+ }
final isDesktop = Util.isDesktop;
@@ -144,7 +168,9 @@ class _ShopInBitSendFromViewState extends ConsumerState<ShopInBitSendFromView> {
children: [
Text(
amount != null
- ? "You need to send ${ref.watch(pAmountFormatter(coin)).format(amount!)}"
+ ? tokenContract != null
+ ? "You need to send ${amount!.decimal.toStringAsFixed(tokenContract!.decimals)} ${tokenContract!.symbol}"
+ : "You need to send ${ref.watch(pAmountFormatter(coin)).format(amount!)}"
: "Select a wallet to pay",
style: isDesktop
? STextStyles.desktopTextExtraExtraSmall(context)
@@ -168,6 +194,7 @@ class _ShopInBitSendFromViewState extends ConsumerState<ShopInBitSendFromView> {
amount: amount,
address: address,
model: model,
+ tokenContract: tokenContract,
),
);
},
@@ -187,12 +214,14 @@ class ShopInBitSendFromCard extends ConsumerStatefulWidget {
this.amount,
required this.address,
required this.model,
+ this.tokenContract,
});
final String walletId;
final Amount? amount;
final String address;
final ShopInBitOrderModel model;
+ final EthContract? tokenContract;
@override
ConsumerState<ShopInBitSendFromCard> createState() =>
@@ -204,18 +233,21 @@ class _ShopInBitSendFromCardState extends ConsumerState<ShopInBitSendFromCard> {
late final Amount? amount;
late final String address;
late final ShopInBitOrderModel model;
+ late final EthContract? tokenContract;
Future<void> _send() async {
final coin = ref.read(pWalletCoin(walletId));
+ final int fractionDigits = tokenContract != null
+ ? tokenContract!.decimals
+ : coin.fractionDigits;
+
Amount? sendAmount = amount;
if (sendAmount == null) {
if (ShopInBitService.instance.client.sandbox) {
- // Sandbox URIs omit ?amount=, use a small fallback so prepareSend
- // can build a real transaction for UI testing.
sendAmount = Amount(
rawValue: BigInt.from(10000),
- fractionDigits: coin.fractionDigits,
+ fractionDigits: fractionDigits,
);
} else {
await showDialog<dynamic>(
@@ -250,7 +282,7 @@ class _ShopInBitSendFromCardState extends ConsumerState<ShopInBitSendFromCard> {
try {
bool wasCancelled = false;
- final wallet = ref.read(pWallets).getWallet(walletId);
+ final parentWallet = ref.read(pWallets).getWallet(walletId);
unawaited(
showDialog<dynamic>(
@@ -279,20 +311,37 @@ class _ShopInBitSendFromCardState extends ConsumerState<ShopInBitSendFromCard> {
),
);
- if (wallet is ExternalWallet) {
- await wallet.init();
- await wallet.open();
+ if (parentWallet is ExternalWallet) {
+ await parentWallet.init();
+ await parentWallet.open();
}
final time = Future<dynamic>.delayed(const Duration(milliseconds: 2500));
TxData txData;
+ // Use token wallet for ERC-20 tokens, parent wallet otherwise
+ final wallet = tokenContract != null
+ ? Wallet.loadTokenWallet(
+ ethWallet: parentWallet as EthereumWallet,
+ contract: tokenContract!,
+ )
+ : parentWallet;
+
+ if (tokenContract != null) {
+ await wallet.init();
+ }
+
+ final addressType =
+ wallet.cryptoCurrency.getAddressType(address) ??
+ parentWallet.cryptoCurrency.getAddressType(address) ??
+ AddressType.ethereum;
+
final recipient = TxRecipient(
address: address,
amount: sendAmount,
isChange: false,
- addressType: wallet.cryptoCurrency.getAddressType(address)!,
+ addressType: addressType,
);
final txDataFuture = wallet.prepareSend(
@@ -308,7 +357,7 @@ class _ShopInBitSendFromCardState extends ConsumerState<ShopInBitSendFromCard> {
if (!wasCancelled) {
if (mounted) {
- Navigator.of(context, rootNavigator: Util.isDesktop).pop();
+ Navigator.of(context, rootNavigator: true).pop();
}
txData = txData.copyWith(note: "ShopInBit payment");
@@ -320,8 +369,11 @@ class _ShopInBitSendFromCardState extends ConsumerState<ShopInBitSendFromCard> {
builder: (_) => ShopInBitConfirmSendView(
txData: txData,
walletId: walletId,
- routeOnSuccessName: HomeView.routeName,
+ routeOnSuccessName: Util.isDesktop
+ ? DesktopHomeView.routeName
+ : HomeView.routeName,
model: model,
+ tokenContract: tokenContract,
),
settings: const RouteSettings(
name: ShopInBitConfirmSendView.routeName,
@@ -333,7 +385,7 @@ class _ShopInBitSendFromCardState extends ConsumerState<ShopInBitSendFromCard> {
} catch (e, s) {
Logging.instance.e("$e\n$s", error: e, stackTrace: s);
if (mounted) {
- Navigator.of(context, rootNavigator: Util.isDesktop).pop();
+ Navigator.of(context, rootNavigator: true).pop();
await showDialog<dynamic>(
context: context,
@@ -372,6 +424,7 @@ class _ShopInBitSendFromCardState extends ConsumerState<ShopInBitSendFromCard> {
amount = widget.amount;
address = widget.address;
model = widget.model;
+ tokenContract = widget.tokenContract;
super.initState();
}
@@ -421,16 +474,36 @@ class _ShopInBitSendFromCardState extends ConsumerState<ShopInBitSendFromCard> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
- ref.watch(pWalletName(walletId)),
+ tokenContract != null
+ ? "${ref.watch(pWalletName(walletId))} (${tokenContract!.symbol})"
+ : ref.watch(pWalletName(walletId)),
style: STextStyles.titleBold12(context),
),
const SizedBox(height: 2),
- Text(
- ref
- .watch(pAmountFormatter(coin))
- .format(ref.watch(pWalletBalance(walletId)).spendable),
- style: STextStyles.itemSubtitle(context),
- ),
+ if (tokenContract != null)
+ Builder(
+ builder: (context) {
+ final balance = ref.watch(
+ pTokenBalance((
+ walletId: walletId,
+ contractAddress: tokenContract!.address,
+ )),
+ );
+ return Text(
+ "${balance.spendable.decimal.toStringAsFixed(tokenContract!.decimals)} ${tokenContract!.symbol}",
+ style: STextStyles.itemSubtitle(context),
+ );
+ },
+ )
+ else
+ Text(
+ ref
+ .watch(pAmountFormatter(coin))
+ .format(
+ ref.watch(pWalletBalance(walletId)).spendable,
+ ),
+ style: STextStyles.itemSubtitle(context),
+ ),
],
),
),
Why this scored 24/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.