fix: guard against non-ETH TRON addresses
What changed, and why it matters
This commit fixes a bug in Stack Wallet's ShopInBit payment feature. Previously, when a user chose to pay with USDT, the app assumed every USDT payment address was on the Ethereum network. If the merchant actually provided a Tron (TRC-20) USDT address, the app could still try to route the payment through an Ethereum wallet, which would send funds to the wrong kind of address and likely cause the user to lose money. The fix checks whether the payment URI is genuinely Ethereum-based before allowing an in-app Ethereum/USDT wallet payment; otherwise it forces the user to pay externally.
Users should update to the version containing this commit before paying ShopInBit invoices with USDT. Developers should extend chain detection to explicitly identify and handle other supported USDT chains (e.g., Tron, BSC) rather than falling back to external-only payment, and add tests covering multi-chain URI parsing.
Security signals we found
Loss-of-funds bug: app could construct an Ethereum transaction to a non-Ethereum USDT address
Incorrect chain assumption for multi-chain stablecoin (USDT)
UI now hides in-app wallet option for unsupported chain variants
No input validation beyond Ethereum prefix/hex regex; other non-ETH chains still not explicitly supported
Evidence from the diff
The patch adds a paymentUri parameter to hasShopInBitWalletForTicker() and tryNavigateToShopInBitWalletSend() and introduces _isEthereumUsdtUri(). That helper returns true only if the URI starts with ethereum: or is a bare 0x hex address. For USDT, both wallet-discovery and send-navigation now return false early when the URI is not Ethereum, preventing the app from treating a non-ETH USDT URI (e.g., a Tron T... address or tron: URI) as payable via an Ethereum ERC-20 wallet. The UI call sites pass the current payment URI/address into these helpers.
Changed components
lib/pages/shopinbit/shopinbit_payment_shared.dartlib/pages/shopinbit/shopinbit_payment_view.dartlib/pages/shopinbit/shopinbit_car_research_payment_view.dartInspect captured patch +36 / −5
diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
index a7f43dc..484fed7 100644
--- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
@@ -104,6 +104,7 @@ class _ShopInBitCarResearchPaymentViewState
ref: ref,
context: context,
ticker: ticker,
+ paymentUri: _currentAddress,
address: target.address,
amount: target.amount,
model: widget.model,
@@ -630,7 +631,11 @@ class _ShopInBitCarResearchPaymentViewState
? _methods[_selectedMethod].toUpperCase()
: "";
- final hasWallets = hasShopInBitWalletForTicker(ref.watch(pWallets), ticker);
+ final hasWallets = hasShopInBitWalletForTicker(
+ wallets: ref.watch(pWallets),
+ ticker: ticker,
+ paymentUri: _currentAddress,
+ );
final methodSelector = _methods.length <= 1
? Padding(
diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart
index d15e22e..fab7f89 100644
--- a/lib/pages/shopinbit/shopinbit_payment_shared.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart
@@ -93,10 +93,29 @@ ShopInBitPaymentTarget parseShopInBitPaymentTarget({
return ShopInBitPaymentTarget(address: address, amount: amount);
}
-// True if any wallet in [wallets] can send the given upper-cased [ticker].
-// USDT is special-cased to look at Ethereum wallets' token contracts.
-bool hasShopInBitWalletForTicker(Wallets wallets, String ticker) {
+// USDT exists on multiple chains (ERC-20, TRC-20, BEP-20, ...) and the
+// ShopInBit API just keys the payment link as "USDT". Only treat it as
+// ETH-USDT when the URI scheme is `ethereum:` or the address looks like a
+// bare Ethereum hex address. Anything else (Tron, etc.) we don't support
+// in-app and the user has to pay externally.
+final RegExp _kEthAddressRegExp = RegExp(r'^0x[0-9a-fA-F]{40}$');
+
+bool _isEthereumUsdtUri(String paymentUri) {
+ final trimmed = paymentUri.trim();
+ if (trimmed.toLowerCase().startsWith('ethereum:')) return true;
+ return _kEthAddressRegExp.hasMatch(trimmed);
+}
+
+// True if any wallet in [wallets] can send the given upper-cased [ticker]
+// for the given [paymentUri]. USDT is special-cased to look at Ethereum
+// wallets' token contracts, gated on the URI actually being ETH-chain.
+bool hasShopInBitWalletForTicker({
+ required Wallets wallets,
+ required String ticker,
+ required String paymentUri,
+}) {
if (ticker == "USDT") {
+ if (!_isEthereumUsdtUri(paymentUri)) return false;
return wallets.wallets.any(
(w) =>
w.info.coin is Ethereum &&
@@ -161,6 +180,7 @@ bool tryNavigateToShopInBitWalletSend({
required WidgetRef ref,
required BuildContext context,
required String ticker,
+ required String paymentUri,
required String address,
required Amount? amount,
required ShopInBitOrderModel model,
@@ -184,6 +204,7 @@ bool tryNavigateToShopInBitWalletSend({
}
if (ticker == "USDT") {
+ if (!_isEthereumUsdtUri(paymentUri)) return false;
final tokenContract = ref
.read(mainDBProvider)
.getEthContractSync(kShopInBitUsdtContractAddress);
diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart
index f9216cf..2ade2f5 100644
--- a/lib/pages/shopinbit/shopinbit_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_view.dart
@@ -259,6 +259,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
ref: ref,
context: context,
ticker: ticker,
+ paymentUri: _currentAddress,
address: target.address,
amount: target.amount,
model: widget.model,
@@ -376,7 +377,11 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
for (int i = 0; i < _methods.length; i++) {
final ticker = _methods[i].toUpperCase();
final coin = AppConfig.getCryptoCurrencyForTicker(ticker);
- final hasWallet = hasShopInBitWalletForTicker(wallets, ticker);
+ final hasWallet = hasShopInBitWalletForTicker(
+ wallets: wallets,
+ ticker: ticker,
+ paymentUri: _addresses[i],
+ );
final amountStr = _addresses[i].isNotEmpty
? _parseBip21Amount(_addresses[i])
: null;
Why this scored 44/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.