diff --git a/docs/rosen_bridge.md b/docs/rosen_bridge.md new file mode 100644 index 0000000000..e56f0eaf07 --- /dev/null +++ b/docs/rosen_bridge.md @@ -0,0 +1,61 @@ +# Rosen Bridge swaps + +The Swap tab offers **Rosen Bridge** for native FIRO ↔ rsFIRO on Ethereum mainnet. Both assets use 8 decimals. Only estimated quotes based on the send amount are supported. Bridge fees are deducted from the deposit; FIRO mining fees or ETH gas are additional. + +rsFIRO uses the existing app-specific token defaults and migration from #1448. The bridge enables that catalog token on the selected Ethereum wallet without replacing its existing metadata. + +FIRO deposits use the wallet's transparent balance. The transaction contains a zero-value OP_RETURN output with Rosen metadata; coin selection includes that output's serialized size. Immediately before broadcast, the signed raw transaction is checked against every prepared input and output, the exact mining fee, and exactly one zero-value OP_RETURN containing the derived metadata. Missing, changed, duplicate, or extra transaction data is rejected. Spark sends reject OP_RETURN data. A FIRO payout must also use a transparent mainnet address. + +rsFIRO deposits call the token's ERC20 `transfer(lockAddress, amount)` function with Rosen metadata appended to the calldata and zero ETH value. The sender needs ETH for gas. Funding checks the mainnet chain ID, token decimals, pending transfer result, EIP-1559 envelope, gas balance, destination, amount, metadata, nonce, and current source-chain height. The signed transaction, its locally derived txid, and the funding wallet are saved before submission; a mined failed token transaction marks the swap failed. Selecting an Ethereum receiving wallet registers rsFIRO in its token list. Ethereum funding retains the wallet's existing restriction on Tor. + +Bridge records use the existing swap persistence and polling. The exact signed transaction is journaled as submitting before broadcast. After restart, polling looks up its locally derived txid and, when absent and the saved fees are still current, resubmits only the same signed bytes before marking it broadcast. A rejected or ambiguous submission remains `Verifying`, is rechecked without rebuilding, and blocks another Rosen deposit from that wallet. It becomes `Failed` only when a sufficiently confirmed conflicting spend proves the saved transaction can no longer confirm. Polling matches the exact source transaction, source token, chains, destination, amount and encoded fees. `COMPLETED` requires a payout transaction ID before the swap becomes `Finished`; an unobserved transaction remains pending. + +The confirmation screen distinguishes a saved-but-unverified deposit from a successful broadcast. An accepted deposit that later disappears is rechecked and may only be resubmitted using its original signed bytes and still-valid fees. A rejected, malformed or unexpected-response deposit is checked for later observation but is not automatically resubmitted. A fee-mismatch deposit can be resubmitted only if its saved fees become valid again. + +For a permanently stuck deposit, do not delete its swap record or send another bridge deposit. Deleting a record cannot revoke signed bytes that might still reach the network. Contact support before creating a normal replacement transaction: FIRO must spend at least one of the **same transparent inputs**, and Ethereum must use the **same sender and nonce**. Once that conflicting transaction has 10 FIRO or 50 Ethereum confirmations, recovery can mark the saved deposit failed and release its wallet guard. A different input or nonce does not cancel the deposit. If the original deposit is found instead, it remains the active swap. + +## Protocol and production configuration + +Verified on 2026-10-08 against the Rosen production app, configuration version **7.1.1**: + +| Value | Mainnet configuration | +| --- | --- | +| rsFIRO Ethereum contract | `0x2744ea5ac9b11cb5e3cd63d3a88e858336aeddc2` | +| Ethereum lock address | `0x451698faa07fc68301af622a3ad42205f13c6e4b` | +| FIRO lock address | `aEF6fyd5jjCPcbiEBZJ2g8583caUme8T7Y` | +| FIRO Ergo token | `581d7df25808881b2b8b9b4e03e2f637c46a94f74a69a5da36434125bacb4e08` | +| Minimum-fee configuration token | `e2ed4d64393222db666f20e67803e9e6fbe6d64531e14ff52ddd95615b0cbf17` | + +These identifiers are pinned in `rosen_api.dart`. Reverify them against Rosen's deployed configuration when updating the integration. The [production configuration bundle](https://app.rosen.tech/_next/static/immutable/chunks/3uty44qqykf4p.js) supplied these values; the repository's generated configuration file intentionally contains empty defaults. + +Metadata is `destinationChain:uint8 || bridgeFee:uint64BE || networkFee:uint64BE || addressLength:uint8 || addressBytes`. Ethereum's chain code is `3`, and its address is 20 raw bytes. FIRO's chain code is `7`, and its address is encoded as the P2PKH or P2SH output script. + +Fee configuration comes from the unspent Ergo box containing both the pinned minimum-fee token and FIRO Ergo token. R4–R7 and R9 are decoded directly from serialized Sigma values. The applicable schedule activates strictly after its source-chain height. The bridge fee is `max(baseFee, amount * feeRatio ~/ 10000)`; the network fee is selected for the destination chain. All calculations use `BigInt`. Discovery uses Rosen scanner heights; funding rechecks with the wallet's source-chain tip. A scheduled fee change within 10 FIRO or 50 Ethereum blocks prevents funding until a stable quote is available. + +Recovery waits 10 FIRO or 50 Ethereum confirmations before treating a conflicting spend as final. The FIRO threshold is deliberately more conservative than the [guard's published default of 8](https://github.com/rosen-bridge/guard-service/blob/d138ca7581f2e50f7d7797a39d3f147f35e83358/services/guard-service/config/default.yaml); guard operators can override their defaults. + +Status requests use `https://app.rosen.tech/api/v1/events` with the `sourceTxId*` filter and exact matching after the response. Missing or unrecognized status data never marks a swap complete. + +## Quote validity and refresh + +Rosen FIRO/Ethereum requests have no fixed expiry timestamp or guaranteed duration in minutes. The encoded fees must satisfy the fee schedule applicable at the source transaction's mining height. Rosen's UI caches fee calculations for 10 minutes; that cache duration is not a request validity period. Its FIRO/Ethereum fee lookahead is 10/50 blocks, respectively, and its QR dialog warns that delayed submission can fail when fees change. + +Stack re-fetches Rosen fees when creating a swap, preparing funding and confirming the send. A change to either fee component requires refreshing the quote, including decreases or changes that leave the total fee unchanged. Initial confirmation refreshes through the existing estimate API and shows the updated amount for review. An unfunded saved swap refreshes its fees, payout amount and bridge metadata together, rebuilds the transaction and returns to confirmation. It never automatically broadcasts the refreshed transaction. A funded swap cannot be refreshed and remains an ongoing swap while the bridge processes it. + +Pinned upstream implementation references: + +- [FIRO metadata and payment URI](https://github.com/rosen-bridge/ui/blob/8d8183c61cc1ab588a3cce08e74fbf2e9bc2e1c1/networks/firo/src/utils.ts) +- [Ethereum transfer calldata](https://github.com/rosen-bridge/ui/blob/8d8183c61cc1ab588a3cce08e74fbf2e9bc2e1c1/networks/evm/src/generateTxParameters.ts) +- [Fee schedule selection](https://github.com/rosen-bridge/utils/blob/7379a610271f34e54cedad68b6c2bb89b792b44d/packages/minimum-fee/lib/minimumFeeBox.ts) and [register layout](https://github.com/rosen-bridge/utils/blob/7379a610271f34e54cedad68b6c2bb89b792b44d/packages/minimum-fee/lib/utils.ts) +- [FIRO address codec](https://github.com/rosen-bridge/utils/blob/7379a610271f34e54cedad68b6c2bb89b792b44d/packages/address-codec-chains/firo/lib/firo.ts) +- [Event response and status mapping](https://github.com/rosen-bridge/ui/blob/8d8183c61cc1ab588a3cce08e74fbf2e9bc2e1c1/apps/rosen/src/backend/events/repository.ts) +- [Time-sensitive request warning](https://github.com/rosen-bridge/ui/blob/8d8183c61cc1ab588a3cce08e74fbf2e9bc2e1c1/apps/rosen/src/app/%28main%29/%28bridge%29/SubmitButton.tsx#L154-L156) and [fee cache lifetime](https://github.com/rosen-bridge/ui/blob/8d8183c61cc1ab588a3cce08e74fbf2e9bc2e1c1/apps/rosen/src/networks/firo/server.ts#L25-L28) + +## Verification + +After completing the repository's [build setup](building.md), including generated configuration, dependencies and required native libraries: + +```sh +bash scripts/ensure_test_app_config.sh +flutter test test/services/exchange/rosen/ test/services/exchange/rosen_registration_test.dart test/services/exchange/rosen_token_registration_test.dart test/wallets/firo_op_return_test.dart test/wallets/broadcast_callback_test.dart +``` diff --git a/lib/exceptions/exchange/exchange_exception.dart b/lib/exceptions/exchange/exchange_exception.dart index 952170d721..cc4450b375 100644 --- a/lib/exceptions/exchange/exchange_exception.dart +++ b/lib/exceptions/exchange/exchange_exception.dart @@ -10,7 +10,12 @@ import '../sw_exception.dart'; -enum ExchangeExceptionType { generic, serializeResponseError, orderNotFound } +enum ExchangeExceptionType { + generic, + serializeResponseError, + orderNotFound, + quoteChanged, +} class ExchangeException extends SWException { ExchangeExceptionType type; diff --git a/lib/models/isar/exchange_cache/currency.dart b/lib/models/isar/exchange_cache/currency.dart index 414deff9e3..d5e9cb2239 100644 --- a/lib/models/isar/exchange_cache/currency.dart +++ b/lib/models/isar/exchange_cache/currency.dart @@ -17,6 +17,7 @@ import '../../../services/exchange/exchange.dart'; import '../../../services/exchange/exolix/exolix_exchange.dart'; import '../../../services/exchange/lets_exchange/lets_exchange_exchange.dart'; import '../../../services/exchange/nanswap/nanswap_exchange.dart'; +import '../../../services/exchange/rosen/rosen_exchange.dart'; import '../../../services/exchange/trocador/trocador_exchange.dart'; import '../../../services/exchange/wizard_swap/wizard_swap_exchange.dart'; import 'pair.dart'; @@ -80,13 +81,13 @@ class Currency { return ticker.toLowerCase(); } - return _fuzzyCache ??= switch (Exchange.fromName( - exchangeName, - ).runtimeType) { + return _fuzzyCache ??= switch (Exchange.fromName(exchangeName) + .runtimeType) { // already lower case ticker basically const (ChangeNowExchange) => network, const (ExolixExchange) => network.toLowerCase(), + const (RosenExchange) => network.toLowerCase(), // not used at the time being // case const (SimpleSwapExchange): diff --git a/lib/pages/exchange_view/choose_address_from_stack_view.dart b/lib/pages/exchange_view/choose_address_from_stack_view.dart index c17c58b1a3..430978693a 100644 --- a/lib/pages/exchange_view/choose_address_from_stack_view.dart +++ b/lib/pages/exchange_view/choose_address_from_stack_view.dart @@ -30,9 +30,14 @@ import '../../widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart'; import '../../widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart'; class ChooseAddressFromStackView extends ConsumerStatefulWidget { - const ChooseAddressFromStackView({super.key, required this.coin}); + const ChooseAddressFromStackView({ + super.key, + required this.coin, + this.transparentOnly = false, + }); final CryptoCurrency coin; + final bool transparentOnly; static const String routeName = "/chooseFromStack"; @@ -92,6 +97,7 @@ class _ChooseFromStackViewState padding: const EdgeInsets.symmetric(vertical: 5.0), child: _WalletAddressSelectCard( walletId: walletIds[index], + transparentOnly: widget.transparentOnly, ), ), ), @@ -103,9 +109,13 @@ class _ChooseFromStackViewState } class _WalletAddressSelectCard extends ConsumerStatefulWidget { - const _WalletAddressSelectCard({required this.walletId}); + const _WalletAddressSelectCard({ + required this.walletId, + required this.transparentOnly, + }); final String walletId; + final bool transparentOnly; @override ConsumerState<_WalletAddressSelectCard> createState() => @@ -189,94 +199,99 @@ class _WalletAddressSelectCardState ], ), const SizedBox(height: 10), - RawMaterialButton( - splashColor: Theme.of(context).extension()!.highlight, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, + if (!widget.transparentOnly) + RawMaterialButton( + splashColor: Theme.of(context) + .extension()! + .highlight, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), ), - ), - padding: const EdgeInsets.all(0), - elevation: 0, - onPressed: () async { - Future _future() async { - final wallet = - ref.read(pWallets).getWallet(widget.walletId) - as SparkInterface; - final sparkAddress = await wallet - .getCurrentReceivingSparkAddress(); - if (sparkAddress != null) { - return sparkAddress.value; + padding: const EdgeInsets.all(0), + elevation: 0, + onPressed: () async { + Future _future() async { + final wallet = + ref.read(pWallets).getWallet(widget.walletId) + as SparkInterface; + final sparkAddress = await wallet + .getCurrentReceivingSparkAddress(); + if (sparkAddress != null) { + return sparkAddress.value; + } + + return (await wallet.generateNextSparkAddress(saveToDB: true)) + .value; } - return (await wallet.generateNextSparkAddress( - saveToDB: true, - )).value; - } + Exception? ex; + final sparkAddress = await showLoading( + context: context, + message: "Fetching Spark address", + rootNavigator: Util.isDesktop, + delay: const Duration(milliseconds: 1200), + whileFutureAlt: _future, + onException: (e) => ex = e, + ); - Exception? ex; - final sparkAddress = await showLoading( - context: context, - message: "Fetching Spark address", - rootNavigator: Util.isDesktop, - delay: const Duration(milliseconds: 1200), - whileFutureAlt: _future, - onException: (e) => ex = e, - ); - - if (context.mounted) { - if (ex != null) { - await showDialog( - context: context, - builder: (context) => StackOkDialog( - title: "Error", - message: ex - .toString() - .replaceFirst("Exception:", "") - .trim(), - ), - ); - } else { - Navigator.of(context).pop(( - walletId: widget.walletId, - address: sparkAddress, - walletName: - "${ref.read(pWalletName(widget.walletId))} (Spark)", - )); - } - } - }, - child: Row( - crossAxisAlignment: .center, - mainAxisAlignment: .spaceBetween, - children: [ - Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - Text("Spark address", style: STextStyles.w500_12(context)), - const SizedBox(height: 2), - WalletInfoRowBalance( + if (context.mounted) { + if (ex != null) { + await showDialog( + context: context, + builder: (context) => StackOkDialog( + title: "Error", + message: ex + .toString() + .replaceFirst("Exception:", "") + .trim(), + ), + ); + } else { + Navigator.of(context).pop(( walletId: widget.walletId, - balanceType: .private, - ), - ], - ), - SizedBox( - width: 25, - height: 25, - child: SvgPicture.asset( - Assets.svg.chevronRight, - colorFilter: ColorFilter.mode( - Theme.of(context).extension()!.textDark, - BlendMode.srcIn, + address: sparkAddress, + walletName: + "${ref.read(pWalletName(widget.walletId))} (Spark)", + )); + } + } + }, + child: Row( + crossAxisAlignment: .center, + mainAxisAlignment: .spaceBetween, + children: [ + Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text( + "Spark address", + style: STextStyles.w500_12(context), + ), + const SizedBox(height: 2), + WalletInfoRowBalance( + walletId: widget.walletId, + balanceType: .private, + ), + ], + ), + SizedBox( + width: 25, + height: 25, + child: SvgPicture.asset( + Assets.svg.chevronRight, + colorFilter: ColorFilter.mode( + Theme.of(context).extension()!.textDark, + BlendMode.srcIn, + ), ), ), - ), - ], + ], + ), ), - ), const SizedBox(height: 8), RawMaterialButton( splashColor: Theme.of(context).extension()!.highlight, diff --git a/lib/pages/exchange_view/confirm_change_now_send.dart b/lib/pages/exchange_view/confirm_change_now_send.dart index a84dff319d..c31ad9547f 100644 --- a/lib/pages/exchange_view/confirm_change_now_send.dart +++ b/lib/pages/exchange_view/confirm_change_now_send.dart @@ -14,13 +14,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; +import '../../exceptions/exchange/exchange_exception.dart'; import '../../models/exchange/response_objects/trade.dart'; import '../../models/isar/models/isar_models.dart'; import '../../models/trade_wallet_lookup.dart'; import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; +import '../../providers/global/trades_service_provider.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; +import '../../services/exchange/rosen/rosen_exchange.dart'; +import '../../services/exchange/rosen/rosen_funding.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; @@ -45,6 +49,7 @@ import '../pinpad_views/lock_screen_view.dart'; import '../send_view/sub_widgets/sending_transaction_dialog.dart'; import '../send_view/sub_widgets/split_transaction_warning.dart'; import '../wallet_view/wallet_view.dart'; +import 'rosen_quote_dialog.dart'; class ConfirmChangeNowSendView extends ConsumerStatefulWidget { const ConfirmChangeNowSendView({ @@ -78,8 +83,38 @@ class _ConfirmChangeNowSendViewState late final Trade trade; final isDesktop = Util.isDesktop; + bool _sending = false; + bool _quoteChanged = false; + bool get _isRosen => trade.exchangeName == RosenExchange.exchangeName; + bool get _isRosenToken => + _isRosen && trade.payInCurrency.toLowerCase() == "rsfiro"; + + String _totalAmount() { + final formatter = ref.watch( + pAmountFormatter(ref.watch(pWalletCoin(walletId))), + ); + final fee = widget.txData.fee!; + if (_isRosenToken) { + return "${trade.payInAmount} ${trade.payInCurrency} + ${formatter.format(fee)}"; + } + final amount = + widget.txData.amountWithoutChange ?? + widget.txData.amountSparkWithoutChange!; + return formatter.format(amount + fee); + } Future _attemptSend(BuildContext context) async { + if (_sending || _quoteChanged) return; + if (_isRosen && + (ref + .read(tradesServiceProvider) + .get(trade.tradeId) + ?.payInTxid + .isNotEmpty ?? + false)) { + return; + } + _sending = true; final wallet = ref.read(pWallets).getWallet(walletId); final coin = wallet.info.coin; @@ -115,12 +150,20 @@ class _ConfirmChangeNowSendViewState final time = Future.delayed(const Duration(milliseconds: 2500)); + String? broadcastTxid; Future txidFuture; final String note = widget.txData.note ?? ""; try { - if (wallet is FiroWallet && widget.shouldSendPublicFiroFunds == false) { + if (_isRosen) { + txidFuture = RosenFunding.confirmSend( + wallet: wallet, + trade: trade, + txData: widget.txData, + ); + } else if (wallet is FiroWallet && + widget.shouldSendPublicFiroFunds == false) { txidFuture = wallet.confirmSendSpark(txData: widget.txData); } else { txidFuture = wallet.confirmSend(txData: widget.txData); @@ -128,13 +171,27 @@ class _ConfirmChangeNowSendViewState unawaited(wallet.refresh()); - final results = await Future.wait([txidFuture, time]); + final sent = await txidFuture; + broadcastTxid = sent.txids.join(", "); + if (_isRosen) { + final service = ref.read(tradesServiceProvider); + service.refresh(); + final saved = service.get(trade.tradeId); + if (saved == null || RosenFunding.fundingWalletId(saved) != null) { + closeSendingDialog(); + if (mounted && context.mounted) { + setState(() {}); + await _showRosenDepositStatus(saved ?? trade); + } + return; + } + } + await time; sendProgressController.triggerSuccess?.call(); await Future.delayed(const Duration(seconds: 5)); - final confirmedTx = results.first as TxData; - for (final txid in confirmedTx.txids) { + for (final txid in sent.txids) { await ref .read(mainDBProvider) .putTransactionNote( @@ -142,17 +199,19 @@ class _ConfirmChangeNowSendViewState ); } - for (final txid in confirmedTx.txids) { - await ref - .read(tradeSentFromStackLookupProvider) - .save( - tradeWalletLookup: TradeWalletLookup( - uuid: const Uuid().v1(), - txid: txid, - tradeId: trade.tradeId, - walletIds: [walletId], - ), - ); + if (!_isRosen) { + for (final txid in sent.txids) { + await ref + .read(tradeSentFromStackLookupProvider) + .save( + tradeWalletLookup: TradeWalletLookup( + uuid: const Uuid().v1(), + txid: txid, + tradeId: trade.tradeId, + walletIds: [walletId], + ), + ); + } } // pop back to wallet @@ -177,38 +236,94 @@ class _ConfirmChangeNowSendViewState // pop sending dialog closeSendingDialog(); + if (!mounted || !context.mounted) return; + + if (_isRosen) { + final service = ref.read(tradesServiceProvider); + final saved = service.get(trade.tradeId); + if (saved?.payInTxid.isNotEmpty == true || broadcastTxid != null) { + service.refresh(); + setState(() {}); + await _showRosenDepositStatus(saved ?? trade); + return; + } + } + if (broadcastTxid == null) _sending = false; + + if (broadcastTxid == null && + e is ExchangeException && + e.type == ExchangeExceptionType.quoteChanged) { + setState(() => _quoteChanged = true); + await _refreshQuote(); + return; + } await showDialog( context: context, useSafeArea: false, barrierDismissible: true, builder: (context) { - return StackDialog( - title: "Broadcast transaction failed", - message: e.toString(), - rightButton: TextButton( - style: Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context), - child: Text( - "Ok", - style: STextStyles.button(context).copyWith( - color: Theme.of(context) - .extension()! - .buttonTextSecondary, - ), - ), - onPressed: () { - Navigator.of(context).pop(); - }, - ), + return StackOkDialog( + title: broadcastTxid == null + ? "Broadcast transaction failed" + : "Transaction sent", + message: broadcastTxid == null + ? e.toString() + : "Transaction(s) $broadcastTxid were sent, but saving their details failed: $e. Do not send again.", + desktopPopRootNavigator: true, ); }, ); } } + Future _showRosenDepositStatus(Trade saved) { + final failed = saved.status.toLowerCase() == "failed"; + final needsAttention = RosenFunding.needsAttention(saved); + final submitted = + saved.payInTxid.isNotEmpty && + RosenFunding.fundingWalletId(saved) == null; + return showDialog( + context: context, + useSafeArea: false, + builder: (context) => StackOkDialog( + title: needsAttention + ? "Bridge deposit needs attention" + : failed + ? "Bridge deposit failed" + : submitted + ? "Bridge deposit submitted" + : "Bridge deposit pending verification", + message: needsAttention + ? "Your signed deposit was saved, but needs attention. " + "Do not send again. Check this swap for recovery guidance." + : failed + ? "The saved deposit failed. Do not send this prepared transaction again. " + "Check this swap for recovery guidance." + : submitted + ? "Your deposit was submitted, but saving local details failed. " + "Do not send again. Check this swap in your swaps." + : "Your signed deposit was saved. Network acceptance is not confirmed. " + "Stack Wallet will verify it and retry safely. " + "Do not send again. Check this swap in your swaps.", + desktopPopRootNavigator: true, + ), + ); + } + + Future _refreshQuote() async { + if (await showRosenQuoteChangedDialog(context) && mounted) { + // Discard the prepared transaction; SendFrom rebuilds it for fresh review. + Navigator.of(context).pop(true); + } + } + Future _confirmSend() async { + if (_isRosen && _sending) return; + if (_quoteChanged) { + await _refreshQuote(); + return; + } final dynamic unlocked; final coin = ref.read(pWalletCoin(walletId)); @@ -346,7 +461,7 @@ class _ConfirmChangeNowSendViewState const AppBarBackButton(isCompact: true, iconSize: 23), const SizedBox(width: 12), Text( - "Confirm ${ref.watch(pWalletCoin(walletId)).ticker} transaction", + "Confirm ${_isRosen ? trade.payInCurrency : ref.watch(pWalletCoin(walletId)).ticker} transaction", style: STextStyles.desktopH3(context), ), ], @@ -416,24 +531,14 @@ class _ConfirmChangeNowSendViewState .textConfirmTotalAmount, ), ), - Builder( - builder: (context) { - final coin = ref.read(pWalletCoin(walletId)); - final fee = widget.txData.fee!; - final amount = widget.txData.amountWithoutChange!; - final total = amount + fee; - - return Text( - ref.watch(pAmountFormatter(coin)).format(total), - style: STextStyles.itemSubtitle12(context) - .copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ), - textAlign: TextAlign.right, - ); - }, + Text( + _totalAmount(), + style: STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), + textAlign: TextAlign.right, ), ], ), @@ -458,7 +563,8 @@ class _ConfirmChangeNowSendViewState const SizedBox(width: 16), Expanded( child: PrimaryButton( - label: "Send", + label: _quoteChanged ? "Refresh quote" : "Send", + enabled: !_isRosen || !_sending, buttonHeight: isDesktop ? ButtonHeight.l : null, onPressed: _confirmSend, ), @@ -489,7 +595,7 @@ class _ConfirmChangeNowSendViewState ), ), child: Text( - "Send ${ref.watch(pWalletCoin(walletId)).ticker}", + "Send ${_isRosen ? trade.payInCurrency : ref.watch(pWalletCoin(walletId)).ticker}", style: isDesktop ? STextStyles.desktopTextMedium(context) : STextStyles.pageTitleH1(context), @@ -503,6 +609,37 @@ class _ConfirmChangeNowSendViewState height: 1, ) : const SizedBox(height: 12), + if (_isRosen) ...[ + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Estimated receive", + style: STextStyles.smallMed12(context), + ), + const SizedBox(height: 4), + Text( + "${trade.payOutAmount} ${trade.payOutCurrency}", + style: STextStyles.itemSubtitle12(context), + ), + const SizedBox(height: 8), + Text( + trade.payOutAddress, + style: STextStyles.itemSubtitle12(context), + ), + const SizedBox(height: 8), + Text( + _quoteChanged + ? "This quote has changed. Refresh it before sending." + : "Bridge fees are checked again when you send. Any change requires a new quote and your confirmation.", + style: STextStyles.smallMed12(context), + ), + ], + ), + ), + const SizedBox(height: 12), + ], RoundedWhiteContainer( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -568,7 +705,7 @@ class _ConfirmChangeNowSendViewState ), ); final String extra; - if (price == null) { + if (price == null || _isRosenToken) { extra = ""; } else { final amountWithoutChange = @@ -607,14 +744,18 @@ class _ConfirmChangeNowSendViewState ], ), child: Text( - ref - .watch( - pAmountFormatter(ref.watch(pWalletCoin(walletId))), - ) - .format( - (widget.txData.amountWithoutChange ?? - widget.txData.amountSparkWithoutChange!), - ), + _isRosen + ? "${trade.payInAmount} ${trade.payInCurrency}" + : ref + .watch( + pAmountFormatter( + ref.watch(pWalletCoin(walletId)), + ), + ) + .format( + widget.txData.amountWithoutChange ?? + widget.txData.amountSparkWithoutChange!, + ), style: STextStyles.itemSubtitle12(context), textAlign: TextAlign.right, ), @@ -711,15 +852,8 @@ class _ConfirmChangeNowSendViewState ), Builder( builder: (context) { - final coin = ref.watch(pWalletCoin(walletId)); - final fee = widget.txData.fee!; - final amount = - widget.txData.amountWithoutChange ?? - widget.txData.amountSparkWithoutChange!; - final total = amount + fee; - return Text( - ref.watch(pAmountFormatter(coin)).format(total), + _totalAmount(), style: STextStyles.itemSubtitle12(context).copyWith( color: Theme.of(context) .extension()! @@ -743,7 +877,8 @@ class _ConfirmChangeNowSendViewState ), if (!isDesktop) PrimaryButton( - label: "Send", + label: _quoteChanged ? "Refresh quote" : "Send", + enabled: !_isRosen || !_sending, buttonHeight: isDesktop ? ButtonHeight.l : null, onPressed: _confirmSend, ), diff --git a/lib/pages/exchange_view/exchange_form.dart b/lib/pages/exchange_view/exchange_form.dart index c5488964a4..d79966d41a 100644 --- a/lib/pages/exchange_view/exchange_form.dart +++ b/lib/pages/exchange_view/exchange_form.dart @@ -34,6 +34,7 @@ import '../../services/exchange/exchange_response.dart'; import '../../services/exchange/exolix/exolix_exchange.dart'; import '../../services/exchange/lets_exchange/lets_exchange_exchange.dart'; import '../../services/exchange/nanswap/nanswap_exchange.dart'; +import '../../services/exchange/rosen/rosen_exchange.dart'; import '../../services/exchange/trocador/trocador_exchange.dart'; import '../../services/exchange/wizard_swap/wizard_swap_exchange.dart'; import '../../themes/stack_colors.dart'; @@ -91,6 +92,7 @@ class _ExchangeFormState extends ConsumerState { NanswapExchange.instance, WizardSwapExchange.instance, CypherGoatExchange.instance, + RosenExchange.instance, ]; } } diff --git a/lib/pages/exchange_view/exchange_step_views/step_2_view.dart b/lib/pages/exchange_view/exchange_step_views/step_2_view.dart index 8e91edb1d5..cbb5cb9207 100644 --- a/lib/pages/exchange_view/exchange_step_views/step_2_view.dart +++ b/lib/pages/exchange_view/exchange_step_views/step_2_view.dart @@ -15,6 +15,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app_config.dart'; import '../../../models/exchange/incomplete_exchange.dart'; import '../../../providers/providers.dart'; +import '../../../route_generator.dart'; +import '../../../services/exchange/rosen/rosen_exchange.dart'; +import '../../../services/exchange/rosen/rosen_funding.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/address_utils.dart'; import '../../../utilities/barcode_scanner_interface.dart'; @@ -23,6 +26,7 @@ import '../../../utilities/constants.dart'; import '../../../utilities/extra_id_currency_support.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/text_styles.dart'; +import '../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../widgets/background.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../widgets/custom_buttons/blue_text_button.dart'; @@ -32,6 +36,7 @@ import '../../../widgets/icon_widgets/clipboard_icon.dart'; import '../../../widgets/icon_widgets/qrcode_icon.dart'; import '../../../widgets/icon_widgets/x_icon.dart'; import '../../../widgets/rounded_white_container.dart'; +import '../../../widgets/stack_dialog.dart'; import '../../../widgets/stack_text_field.dart'; import '../../../widgets/textfield_icon_button.dart'; import '../../address_book_views/address_book_view.dart'; @@ -236,8 +241,15 @@ class _Step2ViewState extends ConsumerState { .getWallet(tuple.item1) .getCurrentReceivingAddress() .then((value) { - _toController.text = value!.value; + if (!mounted || value == null) return; + _toController.text = value.value; model.recipientAddress = _toController.text; + setState(() { + enableNext = + _toController.text.isNotEmpty && + (_refundController.text.isNotEmpty || + !ref.read(efExchangeProvider).supportsRefundAddress); + }); }); } else { if (model.sendTicker.toUpperCase() == @@ -247,8 +259,15 @@ class _Step2ViewState extends ConsumerState { .getWallet(tuple.item1) .getCurrentReceivingAddress() .then((value) { - _refundController.text = value!.value; + if (!mounted || value == null) return; + _refundController.text = value.value; model.refundAddress = _refundController.text; + setState(() { + enableNext = + _toController.text.isNotEmpty && + (_refundController.text.isNotEmpty || + !ref.read(efExchangeProvider).supportsRefundAddress); + }); }); } } @@ -274,7 +293,9 @@ class _Step2ViewState extends ConsumerState { @override Widget build(BuildContext context) { - final supportsRefund = ref.watch(efExchangeProvider).supportsRefundAddress; + final exchange = ref.watch(efExchangeProvider); + final supportsRefund = exchange.supportsRefundAddress; + final isRosen = exchange.name == RosenExchange.exchangeName; return Background( child: Scaffold( @@ -318,7 +339,9 @@ class _Step2ViewState extends ConsumerState { ), const SizedBox(height: 8), Text( - "Enter your recipient and refund addresses", + supportsRefund + ? "Enter your recipient and refund addresses" + : "Enter your recipient address", style: STextStyles.itemSubtitle(context), ), const SizedBox(height: 24), @@ -326,56 +349,104 @@ class _Step2ViewState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "Recipient Wallet", + isRosen && + model.receiveTicker.toLowerCase() == + "firo" + ? "Recipient Wallet (transparent FIRO)" + : "Recipient Wallet", style: STextStyles.smallMed12(context), ), - if (AppConfig.isStackCoin(model.receiveTicker)) + if (isRosen || + AppConfig.isStackCoin(model.receiveTicker)) CustomTextButton( text: "Choose from ${AppConfig.prefix}", - onTap: () { + onTap: () async { try { - final coin = AppConfig.coins.firstWhere( - (e) => - e.ticker.toLowerCase() == - model.receiveTicker.toLowerCase(), - ); + final coin = + isRosen && + model.receiveTicker + .toLowerCase() == + "rsfiro" + ? Ethereum( + CryptoCurrencyNetwork.main, + ) + : AppConfig.coins.firstWhere( + (e) => + e.ticker.toLowerCase() == + model.receiveTicker + .toLowerCase(), + ); - Navigator.of(context) - .pushNamed( - ChooseAddressFromStackView - .routeName, - arguments: coin, - ) - .then((value) async { - if (value - is ({ - String walletId, - String address, - String walletName, - })) { - _toController.text = - value.walletName; - model.recipientAddress = - value.address; + final value = + await Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: + RouteGenerator + .useMaterialPageRoute, + settings: const RouteSettings( + name: + ChooseAddressFromStackView + .routeName, + ), + builder: (_) => + ChooseAddressFromStackView( + coin: coin, + transparentOnly: isRosen, + ), + ), + ); + if (!context.mounted) return; + if (value + is ({ + String walletId, + String address, + String walletName, + })) { + if (isRosen && + model.receiveTicker + .toLowerCase() == + "rsfiro") { + await RosenFunding.registerToken( + ref + .read(pWallets) + .getWallet(value.walletId), + ); + if (!context.mounted) return; + } + _toController.text = value.walletName; + model.recipientAddress = + value.address; - setState(() { - enableNext = - _toController - .text - .isNotEmpty && - (_refundController - .text - .isNotEmpty || - !supportsRefund); - }); - } - }); + setState(() { + enableNext = + _toController.text.isNotEmpty && + (_refundController + .text + .isNotEmpty || + !supportsRefund); + }); + } } catch (e, s) { Logging.instance.e( - "", + "Failed to select recipient wallet", error: e, stackTrace: s, ); + if (context.mounted) { + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Unable to select wallet", + message: e + .toString() + .replaceFirst( + "Exception:", + "", + ) + .trim(), + ), + ); + } } }, ), diff --git a/lib/pages/exchange_view/exchange_step_views/step_3_view.dart b/lib/pages/exchange_view/exchange_step_views/step_3_view.dart index b649d56121..96ab16af4c 100644 --- a/lib/pages/exchange_view/exchange_step_views/step_3_view.dart +++ b/lib/pages/exchange_view/exchange_step_views/step_3_view.dart @@ -13,6 +13,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../exceptions/exchange/exchange_exception.dart'; import '../../../models/exchange/incomplete_exchange.dart'; import '../../../models/exchange/response_objects/trade.dart'; import '../../../providers/global/trades_service_provider.dart'; @@ -23,12 +24,14 @@ import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; import '../../../utilities/clipboard_interface.dart'; import '../../../utilities/enums/exchange_rate_type_enum.dart'; +import '../../../utilities/show_loading.dart'; import '../../../utilities/text_styles.dart'; import '../../../widgets/background.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../widgets/custom_loading_overlay.dart'; import '../../../widgets/rounded_white_container.dart'; import '../../../widgets/stack_dialog.dart'; +import '../rosen_quote_dialog.dart'; import '../sub_widgets/step_row.dart'; import 'step_4_view.dart'; @@ -49,8 +52,9 @@ class Step3View extends ConsumerStatefulWidget { } class _Step3ViewState extends ConsumerState { - late final IncompleteExchangeModel model; + late IncompleteExchangeModel model; late final ClipboardInterface clipboard; + bool _creating = false; @override void initState() { @@ -261,130 +265,194 @@ class _Step3ViewState extends ConsumerState { const SizedBox(width: 16), Expanded( child: TextButton( - onPressed: () async { - unawaited( - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => WillPopScope( - onWillPop: () async => false, - child: Container( - color: Theme.of(context) - .extension()! - .overlay - .withOpacity(0.6), - child: const CustomLoadingOverlay( - message: "Creating a trade", - eventBus: null, - ), - ), - ), - ), - ); + onPressed: _creating + ? null + : () async { + if (_creating) return; + setState(() => _creating = true); + try { + unawaited( + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => WillPopScope( + onWillPop: () async => + false, + child: Container( + color: Theme.of(context) + .extension< + StackColors + >()! + .overlay + .withOpacity(0.6), + child: + const CustomLoadingOverlay( + message: "Creating a trade", + eventBus: null, + ), + ), + ), + ), + ); - final ExchangeResponse - response = await ref - .read(efExchangeProvider) - .createTrade( - from: model.sendTicker, - fromNetwork: - model.sendCurrency.network, - to: model.receiveTicker, - toNetwork: - model.receiveCurrency.network, - fixedRate: - model.rateType != - ExchangeRateType.estimated, - amount: model.reversed - ? model.receiveAmount - : model.sendAmount, - addressTo: model.recipientAddress!, - extraId: model.extraId, - addressRefund: supportsRefund - ? model.refundAddress! - : "", - refundExtraId: - model.refundExtraId ?? "", - estimate: model.estimate, - reversed: model.reversed, - ); + final ExchangeResponse + response = await ref + .read(efExchangeProvider) + .createTrade( + from: model.sendTicker, + fromNetwork: model + .sendCurrency + .network, + to: model.receiveTicker, + toNetwork: model + .receiveCurrency + .network, + fixedRate: + model.rateType != + ExchangeRateType + .estimated, + amount: model.reversed + ? model.receiveAmount + : model.sendAmount, + addressTo: + model.recipientAddress!, + extraId: model.extraId, + addressRefund: + supportsRefund + ? model.refundAddress! + : "", + refundExtraId: + model.refundExtraId ?? + "", + estimate: model.estimate, + reversed: model.reversed, + ); - if (response.value == null) { - if (context.mounted) { - Navigator.of(context).pop(); + if (response.value == null) { + if (context.mounted) { + Navigator.of(context).pop(); - // TODO: better errors - String? message; - if (response.exception != null) { - message = response.exception! - .toString(); - if (message.startsWith( - "FormatException:", - ) && - message.contains("")) { - message = - "${ref.read(efExchangeProvider).name} server error"; - } - } + // TODO: better errors + String? message; + if (response + .exception + ?.type == + ExchangeExceptionType + .quoteChanged) { + final refresh = + await showRosenQuoteChangedDialog( + context, + ); + if (!refresh || !mounted) + return; + final refreshed = + await showLoading( + whileFuture: + refreshRosenEstimate( + model, + ), + context: context, + message: 'Updating exchange rate', + onException: (error) => + message = error + .toString(), + ); + if (!mounted) return; + if (refreshed != null) { + setState( + () => model = refreshed, + ); + return; + } + } + if (response.exception != + null) { + final detail = + message ?? + response.exception! + .toString(); + message = detail; + if (detail.startsWith( + "FormatException:", + ) && + detail.contains( + "", + )) { + message = + "${ref.read(efExchangeProvider).name} server error"; + } + } - unawaited( - showDialog( - context: context, - barrierDismissible: true, - builder: (_) => StackDialog( - title: "Failed to create trade", - message: message ?? "", - ), - ), - ); - } - return; - } + unawaited( + showDialog( + context: context, + barrierDismissible: true, + builder: (_) => + StackDialog( + title: "Failed to create trade", + message: + message ?? "", + ), + ), + ); + } + return; + } - // save trade to hive - await ref - .read(tradesServiceProvider) - .add( - trade: response.value!, - shouldNotifyListeners: true, - ); + // save trade to hive + await ref + .read(tradesServiceProvider) + .add( + trade: response.value!, + shouldNotifyListeners: true, + ); - String status = response.value!.status; + String status = + response.value!.status; - model.trade = response.value!; + model.trade = response.value!; - // extra info if status is waiting - if (status == "Waiting") { - status += " for deposit"; - } + // extra info if status is waiting + if (status == "Waiting") { + status += " for deposit"; + } - if (mounted) { - Navigator.of(context).pop(); - } + if (mounted) { + Navigator.of(context).pop(); + } - unawaited( - NotificationApi.showNotification( - changeNowId: model.trade!.tradeId, - title: status, - body: - "Trade ID ${model.trade!.tradeId}", - walletId: "", - iconAssetName: Assets.svg.arrowRotate, - date: model.trade!.timestamp, - shouldWatchForUpdates: true, - coinName: "coinName", - ), - ); + unawaited( + NotificationApi.showNotification( + changeNowId: + model.trade!.tradeId, + title: status, + body: + "Trade ID ${model.trade!.tradeId}", + walletId: "", + iconAssetName: + Assets.svg.arrowRotate, + date: model.trade!.timestamp, + shouldWatchForUpdates: true, + coinName: "coinName", + ), + ); - if (context.mounted) { - unawaited( - Navigator.of(context).pushNamed( - Step4View.routeName, - arguments: model, - ), - ); - } - }, + if (context.mounted) { + unawaited( + Navigator.of(context) + .pushNamed( + Step4View.routeName, + arguments: model, + ), + ); + } + } finally { + if (mounted) + setState( + () => _creating = false, + ); + } + }, style: Theme.of(context) .extension()! .getPrimaryEnabledButtonStyle(context), diff --git a/lib/pages/exchange_view/exchange_step_views/step_4_view.dart b/lib/pages/exchange_view/exchange_step_views/step_4_view.dart index c23cd61178..5a22c556d1 100644 --- a/lib/pages/exchange_view/exchange_step_views/step_4_view.dart +++ b/lib/pages/exchange_view/exchange_step_views/step_4_view.dart @@ -19,12 +19,15 @@ import '../../../app_config.dart'; import '../../../models/exchange/incomplete_exchange.dart'; import '../../../providers/providers.dart'; import '../../../route_generator.dart'; +import '../../../services/exchange/rosen/rosen_exchange.dart'; +import '../../../services/exchange/rosen/rosen_funding.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/amount/amount_formatter.dart'; import '../../../utilities/assets.dart'; import '../../../utilities/clipboard_interface.dart'; import '../../../utilities/constants.dart'; +import '../../../utilities/default_eth_tokens.dart'; import '../../../utilities/enums/fee_rate_type_enum.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/text_styles.dart'; @@ -72,6 +75,7 @@ class _Step4ViewState extends ConsumerState { late final ClipboardInterface clipboard; String _statusString = "New"; + bool get _isRosen => model.trade!.exchangeName == RosenExchange.exchangeName; Timer? _statusTimer; @@ -153,12 +157,21 @@ class _Step4ViewState extends ConsumerState { void initState() { model = widget.model; clipboard = widget.clipboard; + if (_isRosen) { + _statusString = model.trade!.status == "Waiting" + ? "Waiting for deposit" + : model.trade!.status; + } - isWalletCoinAndCanSend = - Util.isWalletCoinAndCanSendWithoutWalletOpenedIgnoringXMR( - model.trade!.payInCurrency, - ref.read(pWallets).wallets, - ); + isWalletCoinAndCanSend = _isRosen + ? ref + .read(pWallets) + .wallets + .any((wallet) => RosenFunding.canFund(wallet, model.trade!)) + : Util.isWalletCoinAndCanSendWithoutWalletOpenedIgnoringXMR( + model.trade!.payInCurrency, + ref.read(pWallets).wallets, + ); _statusTimer = Timer.periodic(const Duration(seconds: 60), (_) { _updateStatus(); @@ -457,19 +470,23 @@ class _Step4ViewState extends ConsumerState { StepRow(count: 4, current: 3, width: width), const SizedBox(height: 14), Text( - "Send ${model.sendTicker.toUpperCase()} " - "to the address below", + _isRosen + ? "Send with Rosen Bridge" + : "Send ${model.sendTicker.toUpperCase()} " + "to the address below", style: STextStyles.pageTitleH1(context), ), const SizedBox(height: 8), Text( - "Send ${model.sendTicker.toUpperCase()} " - "to the address below. Once it is received, " - "${model.trade!.exchangeName} will send the " - "${model.receiveTicker.toUpperCase()} to the " - "recipient address you provided. You can find" - " this trade details and check its status in " - "the list of trades.", + _isRosen + ? "Send from ${AppConfig.appName} to include the required bridge data. Your pending bridge appears in your swaps." + : "Send ${model.sendTicker.toUpperCase()} " + "to the address below. Once it is received, " + "${model.trade!.exchangeName} will send the " + "${model.receiveTicker.toUpperCase()} to the " + "recipient address you provided. You can find" + " this trade details and check its status in " + "the list of trades.", style: STextStyles.itemSubtitle(context), ), const SizedBox(height: 12), @@ -485,16 +502,17 @@ class _Step4ViewState extends ConsumerState { ), ), const SizedBox(height: 8), - DetailItem( - title: - "Send " - "${model.sendTicker.toUpperCase()}" - " to this address", - detail: model.trade!.payInAddress, - button: SimpleCopyButton( - data: model.trade!.payInAddress, + if (!_isRosen) + DetailItem( + title: + "Send " + "${model.sendTicker.toUpperCase()}" + " to this address", + detail: model.trade!.payInAddress, + button: SimpleCopyButton( + data: model.trade!.payInAddress, + ), ), - ), const SizedBox(height: 6), if (model.trade!.payInExtraId.isNotEmpty) DetailItem( @@ -537,10 +555,16 @@ class _Step4ViewState extends ConsumerState { ), const Spacer(), const SizedBox(height: 12), - PrimaryButton( - label: "Show QR Code", - onPressed: _showQr, - ), + if (!_isRosen) + PrimaryButton( + label: "Show QR Code", + onPressed: _showQr, + ), + if (_isRosen && !isWalletCoinAndCanSend) + Text( + "Add a ${model.sendTicker.toLowerCase() == "firo" ? "FIRO" : "Ethereum"} wallet to fund this swap.", + style: STextStyles.itemSubtitle(context), + ), if (isWalletCoinAndCanSend) const SizedBox(height: 12), if (isWalletCoinAndCanSend) @@ -570,6 +594,17 @@ class _WarningInfo extends StatelessWidget { @override Widget build(BuildContext context) { + if (model.trade!.exchangeName == RosenExchange.exchangeName) { + return RoundedContainer( + color: Theme.of(context).extension()!.warningBackground, + child: Text( + model.sendTicker.toLowerCase() == "firo" + ? "Use your transparent FIRO balance. Stack Wallet adds the required Rosen Bridge data automatically." + : "Use an Ethereum wallet holding rsFIRO and enough ETH for network fees.", + style: STextStyles.label(context), + ), + ); + } return RoundedContainer( color: Theme.of(context).extension()!.warningBackground, child: RichText( @@ -616,7 +651,10 @@ class _SendFromButton extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { String buttonTitle = "Send from ${AppConfig.appName}"; - final tuple = ref.read(exchangeSendFromWalletIdStateProvider.state).state; + final isRosen = model.trade!.exchangeName == RosenExchange.exchangeName; + final tuple = isRosen + ? null + : ref.read(exchangeSendFromWalletIdStateProvider.state).state; if (tuple != null && model.sendTicker.toLowerCase() == tuple.item2.ticker.toLowerCase()) { final walletName = ref.read(pWallets).getWallet(tuple.item1).info.name; @@ -649,16 +687,20 @@ class _SendFromButton extends ConsumerWidget { RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, builder: (BuildContext context) { - final coin = AppConfig.coins.firstWhere( - (e) => - e.ticker.toLowerCase() == - model.trade!.payInCurrency.toLowerCase(), - ); + final coin = isRosen + ? RosenFunding.sourceCoin(model.trade!) + : AppConfig.coins.firstWhere( + (e) => + e.ticker.toLowerCase() == + model.trade!.payInCurrency.toLowerCase(), + ); return SendFromView( coin: coin, amount: payInDecimal.toAmount( - fractionDigits: coin.fractionDigits, + fractionDigits: isRosen + ? DefaultTokens.rsFiro.decimals + : coin.fractionDigits, ), address: model.trade!.payInAddress, trade: model.trade!, diff --git a/lib/pages/exchange_view/rosen_quote_dialog.dart b/lib/pages/exchange_view/rosen_quote_dialog.dart new file mode 100644 index 0000000000..eb4ef9ebc6 --- /dev/null +++ b/lib/pages/exchange_view/rosen_quote_dialog.dart @@ -0,0 +1,70 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; + +import '../../models/exchange/incomplete_exchange.dart'; +import '../../services/exchange/rosen/rosen_exchange.dart'; +import '../../utilities/enums/exchange_rate_type_enum.dart'; +import '../../utilities/util.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/dialogs/basic_dialog.dart'; + +Future showRosenQuoteChangedDialog(BuildContext context) async => + await showDialog( + context: context, + builder: (context) => BasicDialog( + title: 'Bridge fees changed', + message: + 'Rosen Bridge fees changed. No funds were sent. Refresh the quote, ' + 'then review the updated amount before confirming again.', + desktopHeight: 340, + canPopWithBackButton: true, + flex: true, + leftButton: SecondaryButton( + label: 'Cancel', + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: () => Navigator.of(context).pop(false), + ), + rightButton: PrimaryButton( + label: 'Refresh quote', + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: () => Navigator.of(context).pop(true), + ), + ), + ) ?? + false; + +Future refreshRosenEstimate( + IncompleteExchangeModel model, +) async { + final response = await RosenExchange.instance.getEstimates( + model.sendTicker, + model.sendCurrency.network, + model.receiveTicker, + model.receiveCurrency.network, + model.sendAmount, + model.rateType == ExchangeRateType.fixed, + model.reversed, + ); + if (response.value == null || response.value!.isEmpty) { + throw response.exception ?? + StateError('Unable to refresh the bridge quote.'); + } + final estimate = response.value!.single; + return IncompleteExchangeModel( + sendCurrency: model.sendCurrency, + receiveCurrency: model.receiveCurrency, + rateInfo: + '1 ${model.sendTicker.toUpperCase()} ' + '~${(estimate.estimatedAmount / model.sendAmount).toDecimal(scaleOnInfinitePrecision: 8).toStringAsFixed(8)} ' + '${model.receiveTicker.toUpperCase()}', + sendAmount: model.sendAmount, + receiveAmount: estimate.estimatedAmount, + rateType: model.rateType, + reversed: model.reversed, + walletInitiated: model.walletInitiated, + estimate: estimate, + ) + ..recipientAddress = model.recipientAddress + ..refundAddress = model.refundAddress; +} diff --git a/lib/pages/exchange_view/send_from_view.dart b/lib/pages/exchange_view/send_from_view.dart index 7c0fd140cd..c20a09010a 100644 --- a/lib/pages/exchange_view/send_from_view.dart +++ b/lib/pages/exchange_view/send_from_view.dart @@ -16,10 +16,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../../app_config.dart'; +import '../../exceptions/exchange/exchange_exception.dart'; import '../../models/exchange/response_objects/trade.dart'; import '../../pages_desktop_specific/desktop_exchange/desktop_exchange_view.dart'; +import '../../providers/global/trades_service_provider.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; +import '../../services/exchange/rosen/rosen_exchange.dart'; +import '../../services/exchange/rosen/rosen_funding.dart'; import '../../services/exchange/trocador/trocador_exchange.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; @@ -48,6 +52,7 @@ import '../../widgets/stack_dialog.dart'; import '../home_view/home_view.dart'; import '../send_view/sub_widgets/building_transaction_dialog.dart'; import 'confirm_change_now_send.dart'; +import 'rosen_quote_dialog.dart'; class SendFromView extends ConsumerStatefulWidget { const SendFromView({ @@ -95,7 +100,11 @@ class _SendFromViewState extends ConsumerState { final walletIds = ref .watch(pWallets) .wallets - .where((e) => e.info.coin == coin) + .where( + (e) => trade.exchangeName == RosenExchange.exchangeName + ? RosenFunding.canFund(e, trade) + : e.info.coin == coin, + ) .map((e) => e.walletId) .toList(); @@ -160,7 +169,9 @@ class _SendFromViewState extends ConsumerState { Row( children: [ Text( - "You need to send ${ref.watch(pAmountFormatter(coin)).format(amount)}", + trade.exchangeName == RosenExchange.exchangeName + ? "You need to send ${trade.payInAmount} ${trade.payInCurrency}" + : "You need to send ${ref.watch(pAmountFormatter(coin)).format(amount)}", style: isDesktop ? STextStyles.desktopTextExtraExtraSmall(context) : STextStyles.itemSubtitle(context), @@ -168,6 +179,21 @@ class _SendFromViewState extends ConsumerState { ], ), const SizedBox(height: 16), + if (trade.exchangeName == RosenExchange.exchangeName) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + coin is Firo + ? "Rosen Bridge uses your transparent FIRO balance and includes the required bridge data." + : "Select the Ethereum wallet holding rsFIRO. ETH is required for network fees; the rsFIRO balance is checked before confirmation.", + style: STextStyles.itemSubtitle(context), + ), + ), + if (walletIds.isEmpty) + Text( + "No compatible wallets available", + style: STextStyles.itemSubtitle(context), + ), ConditionalParent( condition: !isDesktop, builder: (child) => Expanded(child: child), @@ -220,15 +246,33 @@ class _SendFromCardState extends ConsumerState { late final String walletId; late final Amount amount; late final String address; - late final Trade trade; - - Future _send({bool? shouldSendPublicFiroFunds}) async { + late Trade trade; + Object? _preparation; + + Future _send({ + bool? shouldSendPublicFiroFunds, + bool refreshQuote = false, + }) async { + if (_preparation != null) return; + final preparation = Object(); + _preparation = preparation; final coin = ref.read(pWalletCoin(walletId)); bool wasCancelled = false; + bool isBuildingDialogOpen = false; + bool refreshRequested = false; + + void closeBuildingDialog() { + if (!mounted || !isBuildingDialogOpen) return; + Navigator.of(context, rootNavigator: true).pop(); + isBuildingDialogOpen = false; + } + try { final wallet = ref.read(pWallets).getWallet(walletId); + isBuildingDialogOpen = true; + unawaited( showDialog( context: context, @@ -245,18 +289,33 @@ class _SendFromCardState extends ConsumerState { child: BuildingTransactionDialog( coin: coin, isSpark: - wallet is FiroWallet && shouldSendPublicFiroFunds != true, + trade.exchangeName != RosenExchange.exchangeName && + wallet is FiroWallet && + shouldSendPublicFiroFunds != true, onCancel: () { wasCancelled = true; - - Navigator.of(context).pop(); + closeBuildingDialog(); + if (identical(_preparation, preparation)) { + _preparation = null; + } }, ), ); }, - ), + ).whenComplete(() => isBuildingDialogOpen = false), ); + if (refreshQuote) { + final refreshed = await RosenFunding.refreshTrade( + wallet: wallet, + trade: trade, + ); + if (wasCancelled || !mounted) return; + trade = refreshed; + ref.read(tradesServiceProvider).refresh(); + } + if (wasCancelled || !mounted) return; + // Currently most external wallets need to fully sync before they can // which will cause errors and things and stuff // TODO come back to this some day @@ -264,6 +323,7 @@ class _SendFromCardState extends ConsumerState { await wallet.init(); await wallet.open(); } + if (wasCancelled || !mounted) return; final time = Future.delayed(const Duration(milliseconds: 2500)); @@ -278,7 +338,9 @@ class _SendFromCardState extends ConsumerState { ); // if not firo then do normal send - if (shouldSendPublicFiroFunds == null) { + if (trade.exchangeName == RosenExchange.exchangeName) { + txDataFuture = RosenFunding.prepareSend(wallet: wallet, trade: trade); + } else if (shouldSendPublicFiroFunds == null) { final memo = coin is Stellar || coin is Solana ? trade.payInExtraId.isNotEmpty ? trade.payInExtraId @@ -328,10 +390,7 @@ class _SendFromCardState extends ConsumerState { if (!wasCancelled) { // pop building dialog - if (mounted) { - Navigator.of(context, rootNavigator: Util.isDesktop).pop(); - } - + closeBuildingDialog(); txData = txData.copyWith( note: "${trade.payInCurrency.toUpperCase()}/" @@ -339,7 +398,7 @@ class _SendFromCardState extends ConsumerState { ); if (mounted) { - await Navigator.of(context).push( + final result = await Navigator.of(context).push( RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, builder: (_) => ConfirmChangeNowSendView( @@ -357,43 +416,45 @@ class _SendFromCardState extends ConsumerState { ), ), ); + refreshRequested = result == true; } } } catch (e, s) { Logging.instance.e("$e\n$s", error: e, stackTrace: s); if (mounted && !wasCancelled) { - // pop building dialog - Navigator.of(context, rootNavigator: Util.isDesktop).pop(); - - await showDialog( - context: context, - useSafeArea: false, - barrierDismissible: true, - builder: (context) { - return StackDialog( - title: "Transaction failed", - message: e.toString(), - rightButton: TextButton( - style: Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context), - child: Text( - "Ok", - style: STextStyles.button(context).copyWith( - color: Theme.of(context) - .extension()! - .buttonTextSecondary, - ), - ), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ); - }, - ); + closeBuildingDialog(); + if (e is ExchangeException && + e.type == ExchangeExceptionType.quoteChanged) { + refreshRequested = await showRosenQuoteChangedDialog(context); + } else { + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackOkDialog( + title: "Transaction failed", + message: e.toString(), + desktopPopRootNavigator: true, + ); + }, + ); + } + } + } finally { + closeBuildingDialog(); + if (identical(_preparation, preparation)) { + _preparation = null; } } + if (mounted && refreshRequested) { + unawaited( + _send( + shouldSendPublicFiroFunds: shouldSendPublicFiroFunds, + refreshQuote: true, + ), + ); + } } @override @@ -424,7 +485,10 @@ class _SendFromCardState extends ConsumerState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (!trade.exchangeName.startsWith(TrocadorExchange.exchangeName)) + if (!trade.exchangeName.startsWith( + TrocadorExchange.exchangeName, + ) && + trade.exchangeName != RosenExchange.exchangeName) MaterialButton( splashColor: Theme.of(context) .extension()! @@ -523,7 +587,9 @@ class _SendFromCardState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "Use public balance", + trade.exchangeName == RosenExchange.exchangeName + ? "Use transparent balance" + : "Use public balance", style: STextStyles.itemSubtitle(context), ), Text( @@ -605,11 +671,7 @@ class _SendFromCardState extends ConsumerState { if (!isFiro) const SizedBox(height: 2), if (!isFiro) Text( - ref - .watch(pAmountFormatter(coin)) - .format( - ref.watch(pWalletBalance(walletId)).spendable, - ), + "${ref.watch(pAmountFormatter(coin)).format(ref.watch(pWalletBalance(walletId)).spendable)}${trade.exchangeName == RosenExchange.exchangeName ? " (network fees)" : ""}", style: STextStyles.itemSubtitle(context), ), ], diff --git a/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart b/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart index dda1cd4c53..91a5fdcbd3 100644 --- a/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart +++ b/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart @@ -19,6 +19,7 @@ import '../../../services/exchange/exchange.dart'; import '../../../services/exchange/exolix/exolix_exchange.dart'; import '../../../services/exchange/lets_exchange/lets_exchange_exchange.dart'; import '../../../services/exchange/nanswap/nanswap_exchange.dart'; +import '../../../services/exchange/rosen/rosen_exchange.dart'; import '../../../services/exchange/trocador/trocador_exchange.dart'; import '../../../services/exchange/wizard_swap/wizard_swap_exchange.dart'; import '../../../themes/stack_colors.dart'; @@ -80,6 +81,11 @@ class _ExchangeProviderOptionsState efCurrencyPairProvider.select((value) => value.receive), ); + final showRosen = exchangeSupported( + exchangeName: RosenExchange.exchangeName, + sendCurrency: sendCurrency, + receiveCurrency: receivingCurrency, + ); final showChangeNow = exchangeSupported( exchangeName: ChangeNowExchange.exchangeName, sendCurrency: sendCurrency, @@ -123,6 +129,7 @@ class _ExchangeProviderOptionsState : null, child: SortedExchangeProviders( exchangees: [ + if (showRosen) RosenExchange.instance, if (showChangeNow) ChangeNowExchange.instance, if (showExolix) ExolixExchange.instance, if (showLetsExchange) LetsExchangeExchange.instance, diff --git a/lib/pages/exchange_view/trade_details_view.dart b/lib/pages/exchange_view/trade_details_view.dart index b5799e039a..bd0e8739d5 100644 --- a/lib/pages/exchange_view/trade_details_view.dart +++ b/lib/pages/exchange_view/trade_details_view.dart @@ -33,6 +33,8 @@ import '../../services/exchange/exchange.dart'; import '../../services/exchange/exolix/exolix_exchange.dart'; import '../../services/exchange/lets_exchange/lets_exchange_exchange.dart'; import '../../services/exchange/nanswap/nanswap_exchange.dart'; +import '../../services/exchange/rosen/rosen_exchange.dart'; +import '../../services/exchange/rosen/rosen_funding.dart'; import '../../services/exchange/simpleswap/simpleswap_exchange.dart'; import '../../services/exchange/trocador/trocador_exchange.dart'; import '../../services/exchange/wizard_swap/wizard_swap_exchange.dart'; @@ -43,10 +45,12 @@ import '../../utilities/amount/amount_formatter.dart'; import '../../utilities/assets.dart'; import '../../utilities/clipboard_interface.dart'; import '../../utilities/constants.dart'; +import '../../utilities/default_eth_tokens.dart'; import '../../utilities/format.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/isar/providers/all_wallets_info_provider.dart'; import '../../widgets/background.dart'; import '../../widgets/conditional_parent.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -171,8 +175,14 @@ class _TradeDetailsViewState extends ConsumerState { ), ); + final isRosen = trade.exchangeName == RosenExchange.exchangeName; + final fundingNeedsAttention = isRosen && RosenFunding.needsAttention(trade); + final replacement = RosenExchange.isFiro(trade) + ? "transparent FIRO inputs and waiting 10 confirmations" + : "Ethereum nonce and waiting 50 confirmations"; final bool hasTx = sentFromStack || + (isRosen && trade.payInTxid.isNotEmpty) || !(trade.status == "New" || trade.status == "new" || trade.status == "wait" || @@ -201,30 +211,78 @@ class _TradeDetailsViewState extends ConsumerState { final isDesktop = Util.isDesktop; + final walletIds = ref + .watch(pAllWalletsInfo) + .map((wallet) => wallet.walletId) + .toSet(); + final wallets = ref + .read(pWallets) + .wallets + .where((wallet) => walletIds.contains(wallet.walletId)) + .toList(); + final canSendFromStack = isRosen + ? wallets.any((wallet) => RosenFunding.canFund(wallet, trade)) + : AppConfig.isStackCoin(trade.payInCurrency) && + Util.isWalletCoinAndCanSendWithoutWalletOpenedIgnoringXMR( + trade.payInCurrency, + wallets, + ); + final awaitingDeposit = const { + "new", + "wait", + "waiting", + }.contains(trade.status.toLowerCase()); final showSendFromStackButton = - !hasTx && - AppConfig.isStackCoin(trade.payInCurrency) && - Util.isWalletCoinAndCanSendWithoutWalletOpenedIgnoringXMR( - trade.payInCurrency, - ref.read(pWallets).wallets, - ) && - (trade.status == "New" || - trade.status == "new" || - trade.status == "waiting" || - trade.status == "wait" || - trade.status == "Waiting"); + !hasTx && canSendFromStack && awaitingDeposit; + final String rosenFundingMessage; + if (canSendFromStack) { + rosenFundingMessage = RosenExchange.isFiro(trade) + ? "Send from your transparent FIRO balance using the button below. " + "Stack Wallet includes the required Rosen Bridge data." + : "Send from an Ethereum wallet holding rsFIRO using the button " + "below. ETH is required for network fees."; + } else { + rosenFundingMessage = RosenExchange.isFiro(trade) + ? "No spendable transparent FIRO wallet is available. Add or " + "restore one to fund this swap from Stack Wallet." + : "No spendable Ethereum mainnet wallet is available. Add or " + "restore one holding rsFIRO and ETH to fund this swap from " + "Stack Wallet."; + } + + void sendFromStack() { + CryptoCurrency coin; + try { + coin = isRosen + ? RosenFunding.sourceCoin(trade) + : AppConfig.getCryptoCurrencyForTicker(trade.payInCurrency)!; + } catch (_) { + coin = AppConfig.getCryptoCurrencyByPrettyName(trade.payInCurrency); + } + final amount = Amount.fromDecimal( + sendAmount, + fractionDigits: isRosen + ? DefaultTokens.rsFiro.decimals + : coin.fractionDigits, + ); + + Navigator.of(context).pushNamed( + SendFromView.routeName, + arguments: Tuple4(coin, amount, trade.payInAddress, trade), + ); + } return ConditionalParent( condition: !isDesktop, builder: (child) => Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, leading: AppBarBackButton( onPressed: () async { Navigator.of(context).pop(); @@ -259,9 +317,9 @@ class _TradeDetailsViewState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ RoundedWhiteContainer( - borderColor: Theme.of( - context, - ).extension()!.backgroundAppBar, + borderColor: Theme.of(context) + .extension()! + .backgroundAppBar, padding: const EdgeInsets.all(0), child: ListView( primary: false, @@ -274,28 +332,7 @@ class _TradeDetailsViewState extends ConsumerState { SecondaryButton( label: "Send from ${AppConfig.prefix}", buttonHeight: ButtonHeight.l, - onPressed: () { - CryptoCurrency coin; - try { - coin = AppConfig.getCryptoCurrencyForTicker( - trade.payInCurrency, - )!; - } catch (_) { - coin = AppConfig.getCryptoCurrencyByPrettyName( - trade.payInCurrency, - ); - } - final amount = Amount.fromDecimal( - sendAmount, - fractionDigits: coin.fractionDigits, - ); - final address = trade.payInAddress; - - Navigator.of(context).pushNamed( - SendFromView.routeName, - arguments: Tuple4(coin, amount, address, trade), - ); - }, + onPressed: sendFromStack, ), const SizedBox(height: 32), ], @@ -315,9 +352,9 @@ class _TradeDetailsViewState extends ConsumerState { child: Container( decoration: isDesktop ? BoxDecoration( - color: Theme.of( - context, - ).extension()!.backgroundAppBar, + color: Theme.of(context) + .extension()! + .backgroundAppBar, borderRadius: BorderRadius.vertical( top: Radius.circular( Constants.size.circularBorderRadius, @@ -371,7 +408,9 @@ class _TradeDetailsViewState extends ConsumerState { trade.payInCurrency, )!; final amount = sendAmount.toAmount( - fractionDigits: coin.fractionDigits, + fractionDigits: isRosen + ? DefaultTokens.rsFiro.decimals + : coin.fractionDigits, ); text = ref .watch(pAmountFormatter(coin)) @@ -436,26 +475,42 @@ class _TradeDetailsViewState extends ConsumerState { SelectableText( trade.status, style: STextStyles.itemSubtitle(context).copyWith( - color: Theme.of( - context, - ).extension()!.colorForStatus(trade.status), + color: Theme.of(context) + .extension()! + .colorForStatus(trade.status), ), ), ], ), ), - if (!sentFromStack && !hasTx) + if (fundingNeedsAttention) isDesktop ? const _Divider() : const SizedBox(height: 12), - if (!sentFromStack && !hasTx) + if (fundingNeedsAttention) + RoundedWhiteContainer( + child: Text( + "The saved deposit could not be safely submitted. Stack " + "Wallet will keep checking it and may only retry the " + "exact signed transaction when safe. Do not " + "start another Rosen deposit from this wallet until this " + "one is resolved. To cancel a permanently rejected deposit, " + "contact support about replacing the same " + "$replacement. " + "Deleting this swap does not cancel its signed transaction.", + style: STextStyles.itemSubtitle(context), + ), + ), + if (!isRosen && !sentFromStack && !hasTx) + isDesktop ? const _Divider() : const SizedBox(height: 12), + if (!isRosen && !sentFromStack && !hasTx) RoundedContainer( padding: isDesktop ? const EdgeInsets.all(16) : const EdgeInsets.all(12), color: isDesktop ? Theme.of(context).extension()!.popupBG - : Theme.of( - context, - ).extension()!.warningBackground, + : Theme.of(context) + .extension()! + .warningBackground, child: ConditionalParent( condition: isDesktop, builder: (child) => Column( @@ -481,9 +536,9 @@ class _TradeDetailsViewState extends ConsumerState { STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -500,34 +555,32 @@ class _TradeDetailsViewState extends ConsumerState { text: "You must send at least ${sendAmount.toStringAsFixed(trade.payInCurrency.toLowerCase() == "xmr" ? 12 : 8)} ${trade.payInCurrency.toUpperCase()}. ", style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of( - context, - ).extension()!.accentColorRed, - ) + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorRed, + ) : STextStyles.label(context).copyWith( - color: Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of(context) + .extension()! + .warningForeground, ), children: [ TextSpan( text: "If you send less than ${sendAmount.toStringAsFixed(trade.payInCurrency.toLowerCase() == "xmr" ? 12 : 8)} ${trade.payInCurrency.toUpperCase()}, your transaction may not be converted and it may not be refunded.", style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of( - context, - ).extension()!.accentColorRed, - ) + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorRed, + ) : STextStyles.label(context).copyWith( - color: Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of(context) + .extension()! + .warningForeground, ), ), ], @@ -535,6 +588,13 @@ class _TradeDetailsViewState extends ConsumerState { ), ), ), + if (isRosen && !hasTx && awaitingDeposit) + RoundedWhiteContainer( + child: Text( + rosenFundingMessage, + style: STextStyles.itemSubtitle(context), + ), + ), if (sentFromStack) isDesktop ? const _Divider() : const SizedBox(height: 12), if (sentFromStack) @@ -555,9 +615,11 @@ class _TradeDetailsViewState extends ConsumerState { CustomTextButton( text: "View transaction", onTap: () { - final coin = AppConfig.getCryptoCurrencyForTicker( - trade.payInCurrency, - )!; + final coin = isRosen + ? RosenFunding.sourceCoin(trade) + : AppConfig.getCryptoCurrencyForTicker( + trade.payInCurrency, + )!; if (isDesktop) { Navigator.of(context).push( @@ -629,9 +691,9 @@ class _TradeDetailsViewState extends ConsumerState { ], ), ), - if (!sentFromStack && !hasTx) + if (!isRosen && !sentFromStack && !hasTx) isDesktop ? const _Divider() : const SizedBox(height: 12), - if (!sentFromStack && !hasTx) + if (!isRosen && !sentFromStack && !hasTx) RoundedWhiteContainer( padding: isDesktop ? const EdgeInsets.all(16) @@ -670,9 +732,9 @@ class _TradeDetailsViewState extends ConsumerState { Assets.svg.copy, width: 12, height: 12, - color: Theme.of( - context, - ).extension()!.infoItemIcons, + color: Theme.of(context) + .extension()! + .infoItemIcons, ), const SizedBox(width: 4), Text( @@ -766,9 +828,9 @@ class _TradeDetailsViewState extends ConsumerState { Assets.svg.qrcode, width: 12, height: 12, - color: Theme.of( - context, - ).extension()!.infoItemIcons, + color: Theme.of(context) + .extension()! + .infoItemIcons, ), const SizedBox(width: 4), Text( @@ -818,9 +880,9 @@ class _TradeDetailsViewState extends ConsumerState { Assets.svg.copy, width: 12, height: 12, - color: Theme.of( - context, - ).extension()!.infoItemIcons, + color: Theme.of(context) + .extension()! + .infoItemIcons, ), const SizedBox(width: 4), Text( @@ -894,9 +956,9 @@ class _TradeDetailsViewState extends ConsumerState { Assets.svg.pencil, width: 10, height: 10, - color: Theme.of( - context, - ).extension()!.infoItemIcons, + color: Theme.of(context) + .extension()! + .infoItemIcons, ), const SizedBox(width: 4), Text( @@ -972,9 +1034,9 @@ class _TradeDetailsViewState extends ConsumerState { Assets.svg.pencil, width: 10, height: 10, - color: Theme.of( - context, - ).extension()!.infoItemIcons, + color: Theme.of(context) + .extension()! + .infoItemIcons, ), const SizedBox(width: 4), Text( @@ -1024,9 +1086,9 @@ class _TradeDetailsViewState extends ConsumerState { ), style: STextStyles.desktopTextExtraExtraSmall(context) .copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1129,9 +1191,9 @@ class _TradeDetailsViewState extends ConsumerState { }, child: SvgPicture.asset( Assets.svg.copy, - color: Theme.of( - context, - ).extension()!.infoItemIcons, + color: Theme.of(context) + .extension()! + .infoItemIcons, width: 12, ), ), @@ -1156,6 +1218,9 @@ class _TradeDetailsViewState extends ConsumerState { builder: (context) { late final String url; switch (trade.exchangeName) { + case RosenExchange.exchangeName: + url = "https://app.rosen.tech/events"; + break; case ChangeNowExchange.exchangeName: url = "https://changenow.io/exchange/txs/${trade.tradeId}"; @@ -1217,28 +1282,7 @@ class _TradeDetailsViewState extends ConsumerState { if (!isDesktop && showSendFromStackButton) SecondaryButton( label: "Send from ${AppConfig.prefix}", - onPressed: () { - CryptoCurrency coin; - try { - coin = AppConfig.getCryptoCurrencyForTicker( - trade.payInCurrency, - )!; - } catch (_) { - coin = AppConfig.getCryptoCurrencyByPrettyName( - trade.payInCurrency, - ); - } - final amount = Amount.fromDecimal( - sendAmount, - fractionDigits: coin.fractionDigits, - ); - final address = trade.payInAddress; - - Navigator.of(context).pushNamed( - SendFromView.routeName, - arguments: Tuple4(coin, amount, address, trade), - ); - }, + onPressed: sendFromStack, ), ], ), diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart index b131db8312..716827ec0c 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart @@ -36,6 +36,8 @@ import '../../../../../models/wallet_restore_state.dart'; import '../../../../../notifications/show_flush_bar.dart'; import '../../../../../services/address_book_service.dart'; import '../../../../../services/cakepay/cakepay_service.dart'; +import '../../../../../services/exchange/rosen/rosen_exchange.dart'; +import '../../../../../services/exchange/rosen/rosen_funding.dart'; import '../../../../../services/node_service.dart'; import '../../../../../services/shopinbit/shopinbit_service.dart'; import '../../../../../services/trade_notes_service.dart'; @@ -657,7 +659,7 @@ abstract class SWB { // restore trade history if (trades != null) { Logging.instance.d("SWB restoring trades"); - await _restoreTrades(trades); + await _restoreTrades(trades, oldToNewWalletIdMap); } // restore trade history lookup data for trades send from stack wallet @@ -1258,7 +1260,10 @@ abstract class SWB { await nodeService.updateDefaults(); } - static Future _restoreTrades(List trades) async { + static Future _restoreTrades( + List trades, + Map oldToNewWalletIdMap, + ) async { if (trades.isNotEmpty) { final tradesService = TradesService(); for (int i = 0; i < trades.length; i++) { @@ -1279,6 +1284,15 @@ abstract class SWB { } else { trade = Trade.fromMap(trades[i] as Map); } + // Keep existing source wallets if restoring their clones is cancelled. + if (trade.exchangeName == RosenExchange.exchangeName && + DB.instance.get( + boxName: DB.boxNameTradesV2, + key: trade.uuid, + ) == + null) { + trade = RosenFunding.remapFundingWallet(trade, oldToNewWalletIdMap); + } await tradesService.add( trade: trade, @@ -1305,6 +1319,27 @@ abstract class SWB { .toList(); lookup = lookup.copyWith(walletIds: walletIds); + final trade = DB.instance.get( + boxName: DB.boxNameTradesV2, + key: lookup.uuid, + ); + if (trade != null && + trade.exchangeName == RosenExchange.exchangeName && + trade.payInTxid.isNotEmpty) { + if (trade.payInTxid.toLowerCase() != lookup.txid.toLowerCase()) { + continue; + } + final data = trade.other == null ? null : jsonDecode(trade.other!); + final funding = data is Map ? data[RosenFunding.fundingKey] : null; + final walletId = funding is Map ? funding['walletId'] : null; + if (walletId is! String || walletId.isEmpty) { + throw const FormatException('Invalid restored Rosen funding wallet.'); + } + lookup = lookup.copyWith(walletIds: [walletId]); + await tradeTxidLookupDataService.save(tradeWalletLookup: lookup); + continue; + } + final oldLookup = DB.instance.get( boxName: DB.boxNameTradeLookup, key: lookup.uuid, diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart index 0cf8770a0b..1b977483a3 100644 --- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart +++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart @@ -14,19 +14,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app_config.dart'; +import '../../../exceptions/exchange/exchange_exception.dart'; import '../../../models/exchange/incomplete_exchange.dart'; import '../../../models/exchange/response_objects/trade.dart'; +import '../../../pages/exchange_view/rosen_quote_dialog.dart'; import '../../../pages/exchange_view/send_from_view.dart'; import '../../../providers/exchange/exchange_form_state_provider.dart'; import '../../../providers/global/trades_service_provider.dart'; import '../../../providers/global/wallets_provider.dart'; import '../../../route_generator.dart'; import '../../../services/exchange/exchange_response.dart'; +import '../../../services/exchange/rosen/rosen_exchange.dart'; +import '../../../services/exchange/rosen/rosen_funding.dart'; import '../../../services/notifications_api.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/assets.dart'; +import '../../../utilities/default_eth_tokens.dart'; import '../../../utilities/enums/exchange_rate_type_enum.dart'; +import '../../../utilities/show_loading.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -63,6 +69,7 @@ class StepScaffold extends ConsumerStatefulWidget { class _StepScaffoldState extends ConsumerState { int currentStep = 1; bool enableNext = false; + bool _creating = false; late final Duration duration; @@ -73,115 +80,144 @@ class _StepScaffoldState extends ConsumerState { } Future createTrade() async { - unawaited( - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => WillPopScope( - onWillPop: () async => false, - child: Container( - color: Theme.of(context) - .extension()! - .overlay - .withOpacity(0.6), - child: const CustomLoadingOverlay( - message: "Creating a trade", - eventBus: null, + if (_creating) return false; + setState(() => _creating = true); + try { + unawaited( + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => WillPopScope( + onWillPop: () async => false, + child: Container( + color: Theme.of(context) + .extension()! + .overlay + .withOpacity(0.6), + child: const CustomLoadingOverlay( + message: "Creating a trade", + eventBus: null, + ), ), ), ), - ), - ); + ); - final ExchangeResponse response = await ref - .read(efExchangeProvider) - .createTrade( - from: ref.read(desktopExchangeModelProvider)!.sendTicker, - fromNetwork: ref - .read(desktopExchangeModelProvider)! - .sendCurrency - .network, - to: ref.read(desktopExchangeModelProvider)!.receiveTicker, - toNetwork: ref - .read(desktopExchangeModelProvider)! - .receiveCurrency - .network, - fixedRate: - ref.read(desktopExchangeModelProvider)!.rateType != - ExchangeRateType.estimated, - amount: ref.read(desktopExchangeModelProvider)!.reversed - ? ref.read(desktopExchangeModelProvider)!.receiveAmount - : ref.read(desktopExchangeModelProvider)!.sendAmount, - addressTo: ref.read(desktopExchangeModelProvider)!.recipientAddress!, - extraId: ref.read(desktopExchangeModelProvider)!.extraId, - addressRefund: ref.read(desktopExchangeModelProvider)!.refundAddress!, - refundExtraId: - ref.read(desktopExchangeModelProvider)!.refundExtraId ?? "", - estimate: ref.read(desktopExchangeModelProvider)!.estimate, - reversed: ref.read(desktopExchangeModelProvider)!.reversed, - ); + final ExchangeResponse response = await ref + .read(efExchangeProvider) + .createTrade( + from: ref.read(desktopExchangeModelProvider)!.sendTicker, + fromNetwork: ref + .read(desktopExchangeModelProvider)! + .sendCurrency + .network, + to: ref.read(desktopExchangeModelProvider)!.receiveTicker, + toNetwork: ref + .read(desktopExchangeModelProvider)! + .receiveCurrency + .network, + fixedRate: + ref.read(desktopExchangeModelProvider)!.rateType != + ExchangeRateType.estimated, + amount: ref.read(desktopExchangeModelProvider)!.reversed + ? ref.read(desktopExchangeModelProvider)!.receiveAmount + : ref.read(desktopExchangeModelProvider)!.sendAmount, + addressTo: ref + .read(desktopExchangeModelProvider)! + .recipientAddress!, + extraId: ref.read(desktopExchangeModelProvider)!.extraId, + addressRefund: ref + .read(desktopExchangeModelProvider)! + .refundAddress!, + refundExtraId: + ref.read(desktopExchangeModelProvider)!.refundExtraId ?? "", + estimate: ref.read(desktopExchangeModelProvider)!.estimate, + reversed: ref.read(desktopExchangeModelProvider)!.reversed, + ); - if (response.value == null) { - if (mounted) { - Navigator.of(context).pop(); + if (response.value == null) { + if (mounted) { + Navigator.of(context).pop(); - String? message; - if (response.exception != null) { - message = response.exception!.toString(); - // TODO: better errors - if (message.startsWith("FormatException:") && - message.contains("")) { - message = "${ref.read(efExchangeProvider).name} server error"; + String? message; + if (response.exception?.type == ExchangeExceptionType.quoteChanged) { + final refresh = await showRosenQuoteChangedDialog(context); + if (!refresh || !mounted) return false; + final refreshed = await showLoading( + whileFuture: refreshRosenEstimate( + ref.read(desktopExchangeModelProvider)!, + ), + context: context, + rootNavigator: true, + message: 'Updating exchange rate', + onException: (error) => message = error.toString(), + ); + if (!mounted) return false; + if (refreshed != null) { + ref.read(ssss.notifier).state = refreshed; + return false; + } + } + if (response.exception != null) { + final detail = message ?? response.exception!.toString(); + message = detail; + // TODO: better errors + if (detail.startsWith("FormatException:") && + detail.contains("")) { + message = "${ref.read(efExchangeProvider).name} server error"; + } } - } - unawaited( - showDialog( - context: context, - barrierDismissible: true, - builder: (_) => SimpleDesktopDialog( - title: "Failed to create trade", - message: message ?? "", + unawaited( + showDialog( + context: context, + barrierDismissible: true, + builder: (_) => SimpleDesktopDialog( + title: "Failed to create trade", + message: message ?? "", + ), ), - ), - ); + ); + } + return false; } - return false; - } - // save trade to hive - await ref - .read(tradesServiceProvider) - .add(trade: response.value!, shouldNotifyListeners: true); + // save trade to hive + await ref + .read(tradesServiceProvider) + .add(trade: response.value!, shouldNotifyListeners: true); - String status = response.value!.status; + String status = response.value!.status; - ref.read(desktopExchangeModelProvider)!.trade = response.value!; + ref.read(desktopExchangeModelProvider)!.trade = response.value!; - // extra info if status is waiting - if (status == "Waiting") { - status += " for deposit"; - } + // extra info if status is waiting + if (status == "Waiting") { + status += " for deposit"; + } - if (mounted) { - Navigator.of(context).pop(); - } + if (mounted) { + Navigator.of(context).pop(); + } - unawaited( - NotificationApi.showNotification( - changeNowId: ref.read(desktopExchangeModelProvider)!.trade!.tradeId, - title: status, - body: - "Trade ID ${ref.read(desktopExchangeModelProvider)!.trade!.tradeId}", - walletId: "", - iconAssetName: Assets.svg.arrowRotate, - date: ref.read(desktopExchangeModelProvider)!.trade!.timestamp, - shouldWatchForUpdates: true, - coinName: "coinName", - ), - ); + unawaited( + NotificationApi.showNotification( + changeNowId: ref.read(desktopExchangeModelProvider)!.trade!.tradeId, + title: status, + body: + "Trade ID ${ref.read(desktopExchangeModelProvider)!.trade!.tradeId}", + walletId: "", + iconAssetName: Assets.svg.arrowRotate, + date: ref.read(desktopExchangeModelProvider)!.trade!.timestamp, + shouldWatchForUpdates: true, + coinName: "coinName", + ), + ); - return true; + return true; + } finally { + if (mounted) setState(() => _creating = false); + } // if (mounted) { // unawaited( // showDialog( @@ -214,9 +250,11 @@ class _StepScaffoldState extends ConsumerState { final model = ref.read(desktopExchangeModelProvider)!; final trade = model.trade!; final address = trade.payInAddress; - final coin = - AppConfig.getCryptoCurrencyForTicker(trade.payInCurrency) ?? - AppConfig.getCryptoCurrencyByPrettyName(trade.payInCurrency); + final isRosen = trade.exchangeName == RosenExchange.exchangeName; + final coin = isRosen + ? RosenFunding.sourceCoin(trade) + : AppConfig.getCryptoCurrencyForTicker(trade.payInCurrency) ?? + AppConfig.getCryptoCurrencyByPrettyName(trade.payInCurrency); final payInDecimal = model.payInDecimal; if (payInDecimal == null) { showDialog( @@ -231,7 +269,11 @@ class _StepScaffoldState extends ConsumerState { ); return; } - final amount = payInDecimal.toAmount(fractionDigits: coin.fractionDigits); + final amount = payInDecimal.toAmount( + fractionDigits: isRosen + ? DefaultTokens.rsFiro.decimals + : coin.fractionDigits, + ); showDialog( context: context, @@ -268,16 +310,22 @@ class _StepScaffoldState extends ConsumerState { Widget build(BuildContext context) { final model = ref.watch(desktopExchangeModelProvider); + final isRosen = model?.trade?.exchangeName == RosenExchange.exchangeName; + final bool canSendFromStack; if (currentStep != 4) { // set to true anyways to show back button canSendFromStack = true; } else { - canSendFromStack = - Util.isWalletCoinAndCanSendWithoutWalletOpenedIgnoringXMR( - model?.sendTicker ?? "", - ref.read(pWallets).wallets, - ); + canSendFromStack = isRosen + ? ref + .read(pWallets) + .wallets + .any((wallet) => RosenFunding.canFund(wallet, model!.trade!)) + : Util.isWalletCoinAndCanSendWithoutWalletOpenedIgnoringXMR( + model?.sendTicker ?? "", + ref.read(pWallets).wallets, + ); } return Column( @@ -378,7 +426,7 @@ class _StepScaffoldState extends ConsumerState { ), secondChild: PrimaryButton( label: "Confirm", - enabled: currentStep != 2 ? true : enableNext, + enabled: !_creating && (currentStep != 2 || enableNext), buttonHeight: ButtonHeight.l, onPressed: () async { if (currentStep == 3) { @@ -391,54 +439,63 @@ class _StepScaffoldState extends ConsumerState { }, ), ), - secondChild: PrimaryButton( - label: "Show QR code", - enabled: currentStep != 2 ? true : enableNext, - buttonHeight: ButtonHeight.l, - onPressed: () { - showDialog( - context: context, - barrierColor: Colors.transparent, - barrierDismissible: true, - builder: (_) { - return DesktopDialog( - maxHeight: 720, - maxWidth: 720, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - "Send ${ref.watch(desktopExchangeModelProvider.select((value) => value!.payInAmount))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))} to this address", - style: STextStyles.desktopH3(context), - ), - const SizedBox(height: 48), - Center( - child: QR( - // TODO: grab coin uri scheme from somewhere - // data: "${coin.uriScheme}:$receivingAddress", - data: ref.watch( - desktopExchangeModelProvider.select( - (value) => value!.trade!.payInAddress, + secondChild: isRosen + ? PrimaryButton( + label: "Done", + buttonHeight: ButtonHeight.l, + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(), + ) + : PrimaryButton( + label: "Show QR code", + enabled: currentStep != 2 ? true : enableNext, + buttonHeight: ButtonHeight.l, + onPressed: () { + showDialog( + context: context, + barrierColor: Colors.transparent, + barrierDismissible: true, + builder: (_) { + return DesktopDialog( + maxHeight: 720, + maxWidth: 720, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Text( + "Send ${ref.watch(desktopExchangeModelProvider.select((value) => value!.payInAmount))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))} to this address", + style: STextStyles.desktopH3(context), ), - ), - size: 290, + const SizedBox(height: 48), + Center( + child: QR( + // TODO: grab coin uri scheme from somewhere + // data: "${coin.uriScheme}:$receivingAddress", + data: ref.watch( + desktopExchangeModelProvider.select( + (value) => + value!.trade!.payInAddress, + ), + ), + size: 290, + ), + ), + const SizedBox(height: 48), + SecondaryButton( + label: "Cancel", + width: 310, + buttonHeight: ButtonHeight.l, + onPressed: Navigator.of(context).pop, + ), + ], ), - ), - const SizedBox(height: 48), - SecondaryButton( - label: "Cancel", - width: 310, - buttonHeight: ButtonHeight.l, - onPressed: Navigator.of(context).pop, - ), - ], - ), - ); - }, - ); - }, - ), + ); + }, + ); + }, + ), ), ), ], diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart index 15c0e491d5..2fa0de3992 100644 --- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart +++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart @@ -11,11 +11,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:tuple/tuple.dart'; import '../../../../app_config.dart'; import '../../../../models/contact_address_entry.dart'; import '../../../../providers/providers.dart'; +import '../../../../services/exchange/rosen/rosen_exchange.dart'; +import '../../../../services/exchange/rosen/rosen_funding.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/address_utils.dart'; import '../../../../utilities/clipboard_interface.dart'; @@ -23,6 +24,7 @@ import '../../../../utilities/constants.dart'; import '../../../../utilities/extra_id_currency_support.dart'; import '../../../../utilities/logger.dart'; import '../../../../utilities/text_styles.dart'; +import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../widgets/custom_buttons/blue_text_button.dart'; import '../../../../widgets/desktop/desktop_dialog.dart'; import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; @@ -30,6 +32,7 @@ import '../../../../widgets/icon_widgets/addressbook_icon.dart'; import '../../../../widgets/icon_widgets/clipboard_icon.dart'; import '../../../../widgets/icon_widgets/x_icon.dart'; import '../../../../widgets/rounded_white_container.dart'; +import '../../../../widgets/stack_dialog.dart'; import '../../../../widgets/stack_text_field.dart'; import '../../../../widgets/textfield_icon_button.dart'; import '../../../my_stack_view/wallet_view/sub_widgets/address_book_address_chooser/address_book_address_chooser.dart'; @@ -63,6 +66,9 @@ class _DesktopStep2State extends ConsumerState { late final FocusNode _toMemoFocusNode; late final FocusNode _refundMemoFocusNode; + bool get _isRosen => + ref.read(efExchangeProvider).name == RosenExchange.exchangeName; + bool get _showRecipientMemo => ref.read(efExchangeProvider).supportsExtraId && ExtraIdCurrencySupport.mayRequire( @@ -99,32 +105,60 @@ class _DesktopStep2State extends ConsumerState { void selectRecipientAddressFromStack() async { try { - final coin = AppConfig.getCryptoCurrencyForTicker( - ref.read(desktopExchangeModelProvider)!.receiveTicker, - )!; - - final info = await showDialog?>( + final ticker = ref.read(desktopExchangeModelProvider)!.receiveTicker; + final coin = _isRosen && ticker.toLowerCase() == "rsfiro" + ? Ethereum(CryptoCurrencyNetwork.main) + : AppConfig.getCryptoCurrencyForTicker(ticker)!; + + final info = + await showDialog< + ({String walletId, String address, String walletName}) + >( + context: context, + barrierColor: Colors.transparent, + builder: (context) => DesktopDialog( + maxWidth: 720, + maxHeight: 670, + child: Padding( + padding: const EdgeInsets.all(32), + child: DesktopChooseAddressFromStack( + coin: coin, + transparentOnly: _isRosen, + ), + ), + ), + ); + if (!mounted) return; + + if (info != null) { + if (_isRosen && ticker.toLowerCase() == "rsfiro") { + await RosenFunding.registerToken( + ref.read(pWallets).getWallet(info.walletId), + ); + if (!mounted) return; + } + _toController.text = info.walletName; + ref.read(desktopExchangeModelProvider)!.recipientAddress = info.address; + } + } catch (e, s) { + Logging.instance.e( + "Failed to select recipient wallet", + error: e, + stackTrace: s, + ); + if (!mounted) return; + await showDialog( context: context, - barrierColor: Colors.transparent, - builder: (context) => DesktopDialog( - maxWidth: 720, - maxHeight: 670, - child: Padding( - padding: const EdgeInsets.all(32), - child: DesktopChooseAddressFromStack(coin: coin), - ), + builder: (_) => StackOkDialog( + title: "Unable to select wallet", + message: e.toString().replaceFirst("Exception:", "").trim(), + maxWidth: 400, + desktopPopRootNavigator: true, ), ); - - if (info is Tuple2) { - _toController.text = info.item1; - ref.read(desktopExchangeModelProvider)!.recipientAddress = info.item2; - } - } catch (e, s) { - Logging.instance.i("$e\n$s", error: e, stackTrace: s); } - widget.enableNextChanged.call(_next()); + if (mounted) widget.enableNextChanged.call(_next()); } void selectRefundAddressFromStack() async { @@ -133,21 +167,25 @@ class _DesktopStep2State extends ConsumerState { ref.read(desktopExchangeModelProvider)!.sendTicker, )!; - final info = await showDialog?>( - context: context, - barrierColor: Colors.transparent, - builder: (context) => DesktopDialog( - maxWidth: 720, - maxHeight: 670, - child: Padding( - padding: const EdgeInsets.all(32), - child: DesktopChooseAddressFromStack(coin: coin), - ), - ), - ); - if (info is Tuple2) { - _refundController.text = info.item1; - ref.read(desktopExchangeModelProvider)!.refundAddress = info.item2; + final info = + await showDialog< + ({String walletId, String address, String walletName}) + >( + context: context, + barrierColor: Colors.transparent, + builder: (context) => DesktopDialog( + maxWidth: 720, + maxHeight: 670, + child: Padding( + padding: const EdgeInsets.all(32), + child: DesktopChooseAddressFromStack(coin: coin), + ), + ), + ); + + if (info != null) { + _refundController.text = info.walletName; + ref.read(desktopExchangeModelProvider)!.refundAddress = info.address; } } catch (e, s) { Logging.instance.i("$e\n$s", error: e, stackTrace: s); @@ -333,7 +371,9 @@ class _DesktopStep2State extends ConsumerState { ), const SizedBox(height: 8), Text( - "Enter your recipient and refund addresses", + doesRefundAddress + ? "Enter your recipient and refund addresses" + : "Enter your recipient address", style: STextStyles.desktopTextExtraExtraSmall(context), textAlign: TextAlign.center, ), @@ -342,20 +382,28 @@ class _DesktopStep2State extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "Recipient Wallet", + _isRosen && + ref + .read(desktopExchangeModelProvider)! + .receiveTicker + .toLowerCase() == + "firo" + ? "Recipient Wallet (transparent FIRO)" + : "Recipient Wallet", style: STextStyles.desktopTextExtraExtraSmall(context).copyWith( color: Theme.of(context) .extension()! .textFieldActiveSearchIconRight, ), ), - if (AppConfig.isStackCoin( - ref.watch( - desktopExchangeModelProvider.select( - (value) => value!.receiveTicker, - ), - ), - )) + if (_isRosen || + AppConfig.isStackCoin( + ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.receiveTicker, + ), + ), + )) CustomTextButton( text: "Choose from ${AppConfig.prefix}", onTap: selectRecipientAddressFromStack, diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_4.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_4.dart index f8f85c29a5..6820a41e71 100644 --- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_4.dart +++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_4.dart @@ -15,6 +15,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../app_config.dart'; import '../../../../providers/providers.dart'; +import '../../../../services/exchange/rosen/rosen_exchange.dart'; +import '../../../../services/exchange/rosen/rosen_funding.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../widgets/rounded_container.dart'; @@ -34,19 +36,6 @@ class _DesktopStep4State extends ConsumerState { Timer? _statusTimer; - bool _isWalletCoinAndHasWallet(String ticker) { - try { - final coin = AppConfig.getCryptoCurrencyForTicker(ticker); - return ref - .read(pWallets) - .wallets - .where((e) => e.info.coin == coin) - .isNotEmpty; - } catch (_) { - return false; - } - } - Future _updateStatus() async { final trade = ref.read(desktopExchangeModelProvider)?.trade; @@ -92,44 +81,70 @@ class _DesktopStep4State extends ConsumerState { @override Widget build(BuildContext context) { + final model = ref.watch(desktopExchangeModelProvider)!; + final status = _statusString == "New" + ? model.trade?.status ?? "New" + : _statusString; + final statusString = status == "Waiting" ? "Waiting for deposit" : status; + final isRosen = model.trade?.exchangeName == RosenExchange.exchangeName; + final canFund = + isRosen && + ref + .watch(pWallets) + .wallets + .any((wallet) => RosenFunding.canFund(wallet, model.trade!)); return Column( + crossAxisAlignment: isRosen + ? CrossAxisAlignment.stretch + : CrossAxisAlignment.center, children: [ Text( - "Send ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker.toUpperCase()))} to the address below", + isRosen + ? "Send with Rosen Bridge" + : "Send ${model.sendTicker.toUpperCase()} to the address below", style: STextStyles.desktopTextMedium(context), ), const SizedBox(height: 8), Text( - "Send ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker.toUpperCase()))} to the address below. Once it is received, ${ref.watch(desktopExchangeModelProvider.select((value) => value!.trade?.exchangeName))} will send the ${ref.watch(desktopExchangeModelProvider.select((value) => value!.receiveTicker.toUpperCase()))} to the recipient address you provided. You can find this trade details and check its status in the list of trades.", + isRosen + ? "Send from ${AppConfig.appName} to include the required bridge data. Your pending bridge appears in your swaps." + : "Send ${model.sendTicker.toUpperCase()} to the address below. Once it is received, ${model.trade?.exchangeName} will send the ${model.receiveTicker.toUpperCase()} to the recipient address you provided. You can find this trade details and check its status in the list of trades.", style: STextStyles.desktopTextExtraExtraSmall(context), ), const SizedBox(height: 20), RoundedContainer( color: Theme.of(context).extension()!.warningBackground, - child: RichText( - text: TextSpan( - text: - "You must send at least ${ref.watch(desktopExchangeModelProvider.select((value) => value!.payInAmount))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))}. ", - style: STextStyles.label700(context).copyWith( - color: Theme.of(context) - .extension()! - .warningForeground, - fontSize: 14, - ), - children: [ - TextSpan( - text: - "If you send less than ${ref.watch(desktopExchangeModelProvider.select((value) => value!.payInAmount))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))}, your transaction may not be converted and it may not be refunded.", - style: STextStyles.label(context).copyWith( - color: Theme.of(context) - .extension()! - .warningForeground, - fontSize: 14, + child: isRosen + ? Text( + model.sendTicker.toLowerCase() == "firo" + ? "Use your transparent FIRO balance. Stack Wallet adds the required Rosen Bridge data automatically." + : "Use an Ethereum wallet holding rsFIRO and enough ETH for network fees.", + style: STextStyles.label(context), + ) + : RichText( + text: TextSpan( + text: + "You must send at least ${model.payInAmount} ${model.sendTicker}. ", + style: STextStyles.label700(context).copyWith( + color: Theme.of(context) + .extension()! + .warningForeground, + fontSize: 14, + ), + children: [ + TextSpan( + text: + "If you send less than ${model.payInAmount} ${model.sendTicker}, your transaction may not be converted and it may not be refunded.", + style: STextStyles.label(context).copyWith( + color: Theme.of(context) + .extension()! + .warningForeground, + fontSize: 14, + ), + ), + ], ), ), - ], - ), - ), ), const SizedBox(height: 20), RoundedWhiteContainer( @@ -137,54 +152,36 @@ class _DesktopStep4State extends ConsumerState { padding: const EdgeInsets.all(0), child: Column( children: [ - DesktopStepItem( - vertical: true, - copyableValue: true, - label: - "Send ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker.toUpperCase()))} to this address", - value: - ref.watch( - desktopExchangeModelProvider.select( - (value) => value!.trade?.payInAddress, - ), - ) ?? - "Error", - ), - Container( - height: 1, - color: Theme.of(context).extension()!.background, - ), - if (ref.watch( - desktopExchangeModelProvider.select( - (value) => value!.trade?.payInExtraId, - ), - ) != - null) + if (!isRosen) ...[ DesktopStepItem( vertical: true, - label: "Memo", - value: - ref.watch( - desktopExchangeModelProvider.select( - (value) => value!.trade?.payInExtraId, - ), - ) ?? - "Error", + copyableValue: true, + label: + "Send ${model.sendTicker.toUpperCase()} to this address", + value: model.trade?.payInAddress ?? "Error", ), - if (ref.watch( - desktopExchangeModelProvider.select( - (value) => value!.trade?.payInExtraId, - ), - ) != - null) Container( height: 1, color: Theme.of(context).extension()!.background, ), + if (model.trade?.payInExtraId != null) ...[ + DesktopStepItem( + vertical: true, + label: "Memo", + value: model.trade?.payInExtraId ?? "Error", + ), + Container( + height: 1, + color: Theme.of(context) + .extension()! + .background, + ), + ], + ], DesktopStepItem( label: "Amount", value: - "${ref.watch(desktopExchangeModelProvider.select((value) => value!.payInAmount))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker.toUpperCase()))}", + "${model.payInAmount} ${isRosen ? model.sendTicker : model.sendTicker.toUpperCase()}", ), Container( height: 1, @@ -192,13 +189,7 @@ class _DesktopStep4State extends ConsumerState { ), DesktopStepItem( label: "Trade ID", - value: - ref.watch( - desktopExchangeModelProvider.select( - (value) => value!.trade?.tradeId, - ), - ) ?? - "Error", + value: model.trade?.tradeId ?? "Error", ), Container( height: 1, @@ -214,12 +205,12 @@ class _DesktopStep4State extends ConsumerState { style: STextStyles.desktopTextExtraExtraSmall(context), ), Text( - _statusString, + statusString, style: STextStyles.desktopTextExtraExtraSmall(context) .copyWith( color: Theme.of(context) .extension()! - .colorForStatus(_statusString), + .colorForStatus(statusString), ), ), ], @@ -228,6 +219,11 @@ class _DesktopStep4State extends ConsumerState { ], ), ), + if (isRosen && !canFund) + Text( + "Add a ${model.sendTicker.toLowerCase() == "firo" ? "FIRO" : "Ethereum"} wallet to fund this swap.", + style: STextStyles.label(context), + ), ], ); } diff --git a/lib/pages_desktop_specific/desktop_exchange/subwidgets/desktop_choose_address_from_stack.dart b/lib/pages_desktop_specific/desktop_exchange/subwidgets/desktop_choose_address_from_stack.dart index 8eaf949914..10b0729826 100644 --- a/lib/pages_desktop_specific/desktop_exchange/subwidgets/desktop_choose_address_from_stack.dart +++ b/lib/pages_desktop_specific/desktop_exchange/subwidgets/desktop_choose_address_from_stack.dart @@ -11,7 +11,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:tuple/tuple.dart'; import '../../../app_config.dart'; import '../../../providers/providers.dart'; @@ -36,9 +35,14 @@ import '../../../widgets/textfield_icon_button.dart'; import '../../../widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart'; class DesktopChooseAddressFromStack extends ConsumerStatefulWidget { - const DesktopChooseAddressFromStack({super.key, required this.coin}); + const DesktopChooseAddressFromStack({ + super.key, + required this.coin, + this.transparentOnly = false, + }); final CryptoCurrency coin; + final bool transparentOnly; @override ConsumerState createState() => @@ -108,9 +112,9 @@ class _DesktopChooseFromStackState }); }, style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, + color: Theme.of(context) + .extension()! + .textFieldActiveText, height: 1.8, ), decoration: @@ -167,9 +171,9 @@ class _DesktopChooseFromStackState return Column( children: [ RoundedWhiteContainer( - borderColor: Theme.of( - context, - ).extension()!.background, + borderColor: Theme.of(context) + .extension()! + .background, child: Center( child: Text( "No ${widget.coin.ticker.toUpperCase()} wallets", @@ -191,8 +195,10 @@ class _DesktopChooseFromStackState primary: false, itemCount: walletIds.length, separatorBuilder: (_, __) => const SizedBox(height: 5), - itemBuilder: (context, index) => - _WalletRow(walletId: walletIds[index]), + itemBuilder: (context, index) => _WalletRow( + walletId: walletIds[index], + transparentOnly: widget.transparentOnly, + ), ); }, ), @@ -245,9 +251,14 @@ class _BalanceDisplay extends ConsumerWidget { } class _WalletRow extends ConsumerWidget { - const _WalletRow({super.key, required this.walletId}); + const _WalletRow({ + super.key, + required this.walletId, + required this.transparentOnly, + }); final String walletId; + final bool transparentOnly; @override Widget build(BuildContext context, WidgetRef ref) { @@ -267,9 +278,9 @@ class _WalletRow extends ConsumerWidget { ref.watch(pWalletName(walletId)), style: STextStyles.desktopTextExtraExtraSmall(context) .copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -286,7 +297,11 @@ class _WalletRow extends ConsumerWidget { wallet.info.cachedReceivingAddress; if (context.mounted) { - Navigator.of(context).pop(Tuple2(wallet.info.name, address)); + Navigator.of(context).pop(( + walletId: walletId, + address: address, + walletName: wallet.info.name, + )); } }, ), @@ -315,78 +330,84 @@ class _WalletRow extends ConsumerWidget { ], ), const SizedBox(height: 10), - Row( - children: [ - const SizedBox( - width: 12 + 32, // space + size of WalletInfoCoinIcon - ), - Text( - "Spark", - style: STextStyles.desktopTextExtraExtraSmall(context).copyWith( - color: Theme.of(context).extension()!.textDark, + if (!transparentOnly) + Row( + children: [ + const SizedBox( + width: 12 + 32, // space + size of WalletInfoCoinIcon ), - ), - const Spacer(), - _BalanceDisplay(walletId: walletId, balanceType: .private), - const SizedBox(width: 80), - CustomTextButton( - text: "Select wallet", - onTap: () async { - Future _future() async { - final wallet = - ref.read(pWallets).getWallet(walletId) - as SparkInterface; - - final sparkAddress = await wallet - .getCurrentReceivingSparkAddress(); - if (sparkAddress != null) { - return sparkAddress.value; + Text( + "Spark", + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ), + const Spacer(), + _BalanceDisplay(walletId: walletId, balanceType: .private), + const SizedBox(width: 80), + CustomTextButton( + text: "Select wallet", + onTap: () async { + Future _future() async { + final wallet = + ref.read(pWallets).getWallet(walletId) + as SparkInterface; + + final sparkAddress = await wallet + .getCurrentReceivingSparkAddress(); + if (sparkAddress != null) { + return sparkAddress.value; + } + + return (await wallet.generateNextSparkAddress( + saveToDB: true, + )).value; } - return (await wallet.generateNextSparkAddress( - saveToDB: true, - )).value; - } - - Exception? ex; - final sparkAddress = await showLoading( - context: context, - message: "Fetching Spark address", - rootNavigator: Util.isDesktop, - delay: const Duration(milliseconds: 1200), - whileFutureAlt: _future, - onException: (e) => ex = e, - ); - - if (context.mounted) { - if (ex != null) { - await showDialog( - context: context, - builder: (context) => StackOkDialog( - title: "Error", - message: ex - .toString() - .replaceFirst("Exception:", "") - .trim(), - maxWidth: 400, - desktopPopRootNavigator: true, - ), - ); - } else { - Navigator.of(context).pop( - sparkAddress == null - ? null - : Tuple2( - "${ref.read(pWalletName(walletId))} (Spark)", - sparkAddress, - ), - ); + Exception? ex; + final sparkAddress = await showLoading( + context: context, + message: "Fetching Spark address", + rootNavigator: Util.isDesktop, + delay: const Duration(milliseconds: 1200), + whileFutureAlt: _future, + onException: (e) => ex = e, + ); + + if (context.mounted) { + if (ex != null) { + await showDialog( + context: context, + builder: (context) => StackOkDialog( + title: "Error", + message: ex + .toString() + .replaceFirst("Exception:", "") + .trim(), + maxWidth: 400, + desktopPopRootNavigator: true, + ), + ); + } else { + Navigator.of(context).pop( + sparkAddress == null + ? null + : ( + walletId: walletId, + address: sparkAddress, + walletName: + "${ref.read(pWalletName(walletId))} (Spark)", + ), + ); + } } - } - }, - ), - ], - ), + }, + ), + ], + ), const SizedBox(height: 10), Row( children: [ @@ -411,9 +432,11 @@ class _WalletRow extends ConsumerWidget { wallet.info.cachedReceivingAddress; if (context.mounted) { - Navigator.of( - context, - ).pop(Tuple2("${wallet.info.name} (Transparent)", address)); + Navigator.of(context).pop(( + walletId: walletId, + address: address, + walletName: "${wallet.info.name} (Transparent)", + )); } }, ), diff --git a/lib/services/ethereum/ethereum_api.dart b/lib/services/ethereum/ethereum_api.dart index cf00ce3312..8df7b2ede2 100644 --- a/lib/services/ethereum/ethereum_api.dart +++ b/lib/services/ethereum/ethereum_api.dart @@ -46,7 +46,79 @@ abstract class EthereumAPI { static String get stackBaseServer => Ethereum(CryptoCurrencyNetwork.main).defaultNode(isPrimary: true).host; - static HTTP client = HTTP(); + static HTTP client = const HTTP(); + + static Future _rpc(String method, List params) async { + final response = await client + .post( + url: Uri.parse(stackBaseServer), + headers: const {'content-type': 'application/json'}, + body: jsonEncode({ + 'jsonrpc': '2.0', + 'method': method, + 'params': params, + 'id': 1, + }), + proxyInfo: !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ) + .timeout(const Duration(seconds: 30)); + if (response.code != 200) { + throw EthApiException( + '$method failed with status code: ${response.code}', + ); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map || + decoded['jsonrpc'] != '2.0' || + decoded['id'] != 1 || + decoded['error'] != null || + !decoded.containsKey('result')) { + throw const FormatException('Invalid Ethereum RPC response.'); + } + return decoded['result']; + } + + static int _hexInt(Object? value) { + if (value is! String || !RegExp(r'^0x[0-9a-fA-F]+$').hasMatch(value)) { + throw const FormatException('Invalid Ethereum block number.'); + } + return int.parse(value.substring(2), radix: 16); + } + + static Future getTransactionReceiptStatus( + String txid, { + required int confirmations, + }) async { + if (confirmations <= 0) { + throw ArgumentError.value(confirmations, 'confirmations'); + } + if (!RegExp(r'^(0x)?[0-9a-fA-F]{64}$').hasMatch(txid)) { + throw const FormatException('Invalid Ethereum transaction ID.'); + } + final normalized = txid.startsWith('0x') + ? txid.toLowerCase() + : '0x${txid.toLowerCase()}'; + final result = await _rpc('eth_getTransactionReceipt', [normalized]); + if (result == null) return null; + if (result is! Map) { + throw const FormatException('Invalid Ethereum receipt response.'); + } + if (result['transactionHash'] is! String || + (result['transactionHash'] as String).toLowerCase() != normalized) { + throw const FormatException('Ethereum receipt transaction mismatch.'); + } + if (result['status'] == '0x1') return true; + if (result['status'] != '0x0') { + throw const FormatException('Invalid Ethereum receipt status.'); + } + final receiptHeight = _hexInt(result['blockNumber']); + final chainHeight = _hexInt(await _rpc('eth_blockNumber', const [])); + return chainHeight - receiptHeight + 1 >= confirmations ? false : null; + } static Future>> getEthTransactions({ required String address, diff --git a/lib/services/exchange/exchange.dart b/lib/services/exchange/exchange.dart index b3d6faebd9..584254f4fe 100644 --- a/lib/services/exchange/exchange.dart +++ b/lib/services/exchange/exchange.dart @@ -20,6 +20,7 @@ import 'exchange_response.dart'; import 'exolix/exolix_exchange.dart'; import 'lets_exchange/lets_exchange_exchange.dart'; import 'nanswap/nanswap_exchange.dart'; +import 'rosen/rosen_exchange.dart'; import 'simpleswap/simpleswap_exchange.dart'; import 'trocador/trocador_exchange.dart'; import 'wizard_swap/wizard_swap_exchange.dart'; @@ -37,6 +38,8 @@ abstract class Exchange { // return MajesticBankExchange.instance; case TrocadorExchange.exchangeName: return TrocadorExchange.instance; + case RosenExchange.exchangeName: + return RosenExchange.instance; case NanswapExchange.exchangeName: return NanswapExchange.instance; case WizardSwapExchange.exchangeName: diff --git a/lib/services/exchange/exchange_data_loading_service.dart b/lib/services/exchange/exchange_data_loading_service.dart index 549074db06..0686c02608 100644 --- a/lib/services/exchange/exchange_data_loading_service.dart +++ b/lib/services/exchange/exchange_data_loading_service.dart @@ -29,6 +29,7 @@ import 'cyphergoat/cyphergoat_exchange.dart'; import 'exolix/exolix_exchange.dart'; import 'lets_exchange/lets_exchange_exchange.dart'; import 'nanswap/nanswap_exchange.dart'; +import 'rosen/rosen_exchange.dart'; import 'trocador/trocador_exchange.dart'; import 'wizard_swap/wizard_swap_exchange.dart'; @@ -47,14 +48,13 @@ class ExchangeDataLoadingService { VoidCallback? onLoadingError; VoidCallback? onLoadingComplete; - static const int cacheVersion = 1; + static const int cacheVersion = 2; static int get currentCacheVersion => DB.instance.get( - boxName: DB.boxNameDBInfo, - key: "exchange_data_cache_version", - ) - as int? ?? + boxName: DB.boxNameDBInfo, + key: "exchange_data_cache_version", + ) as int? ?? 0; Future _updateCurrentCacheVersion(int version) async { @@ -130,7 +130,7 @@ class ExchangeDataLoadingService { if (contract != null) { currencies = await (await isar).currencies .filter() - .tokenContractEqualTo(contract) + .tokenContractEqualTo(contract, caseSensitive: fuzzyNet != "eth") .and() .group( (q) => rateType == ExchangeRateType.fixed @@ -222,6 +222,7 @@ class ExchangeDataLoadingService { // Add to this list when adding an exchange which doesn't supports Tor. if (!Prefs.instance.useTor) { futures.add(_loadChangeNowCurrencies()); + futures.add(loadRosenCurrencies()); } // wait for all loading futures to complete @@ -244,6 +245,22 @@ class ExchangeDataLoadingService { } } + Future loadRosenCurrencies() async { + final response = await RosenExchange.instance.getAllCurrencies(false); + if (response.value == null) { + Logging.instance.w("loadRosenCurrencies: $response"); + return; + } + final db = await isar; + await db.writeTxn(() async { + await db.currencies + .where() + .exchangeNameEqualTo(RosenExchange.exchangeName) + .deleteAll(); + await db.currencies.putAll(response.value!); + }); + } + Future _loadChangeNowCurrencies() async { if (_isar == null) { await initDB(); diff --git a/lib/services/exchange/rosen/rosen_api.dart b/lib/services/exchange/rosen/rosen_api.dart new file mode 100644 index 0000000000..89c6f72e31 --- /dev/null +++ b/lib/services/exchange/rosen/rosen_api.dart @@ -0,0 +1,154 @@ +import 'dart:convert'; + +import '../../../app_config.dart'; +import '../../../networking/http.dart'; +import '../../../utilities/prefs.dart'; +import '../../tor_service.dart'; +import 'rosen_fees.dart'; + +export 'rosen_fees.dart'; + +class RosenApi { + static const firoConfirmationWindow = 10; + static const ethereumConfirmationWindow = 50; + + RosenApi._(); + + static final instance = RosenApi._(); + + // Rosen mainnet config 7.1.1, verified against app.rosen.tech on 2026-10-08. + // https://github.com/rosen-bridge/ui/blob/dev/apps/rosen/configs/generate.mjs + static const firoLockAddress = 'aEF6fyd5jjCPcbiEBZJ2g8583caUme8T7Y'; + static const ethereumLockAddress = + '0x451698faa07fc68301af622a3ad42205f13c6e4b'; + static const _ergoFiroToken = + '581d7df25808881b2b8b9b4e03e2f637c46a94f74a69a5da36434125bacb4e08'; + static const _minimumFeeToken = + 'e2ed4d64393222db666f20e67803e9e6fbe6d64531e14ff52ddd95615b0cbf17'; + + final _client = const HTTP(); + + Future _get(Uri url) async { + final response = await _client + .get( + url: url, + headers: {'Accept': 'application/json'}, + connectionTimeout: const Duration(seconds: 20), + proxyInfo: + AppConfig.hasFeature(AppFeature.tor) && Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ) + .timeout(const Duration(seconds: 30)); + if (response.code != 200) { + throw StateError('Rosen API returned HTTP ${response.code}'); + } + return jsonDecode(response.body); + } + + Future quote({ + required bool fromFiro, + required BigInt amount, + int? sourceHeight, + }) async { + // The funding wallet supplies its chain tip; discovery uses Rosen's scanners. + var height = sourceHeight; + if (height == null) { + final heights = + await _get(Uri.https('app.rosen.tech', '/api/v1/heights')) as List; + final source = fromFiro ? 'firo' : 'ethereum'; + height = int.parse( + heights + .singleWhere((entry) => entry['network'] == source)['height'] + .toString(), + ); + } + final candidates = >[]; + var offset = 0; + while (true) { + final result = await _get( + Uri.https( + 'api.ergoplatform.com', + '/api/v1/boxes/unspent/byTokenId/$_minimumFeeToken', + {'offset': '$offset', 'limit': '100'}, + ), + ) as Map; + final items = result['items'] as List; + for (final item in items) { + final assets = item['assets'] as List; + if (assets.length == 2 && + assets.any((asset) => asset['tokenId'] == _minimumFeeToken) && + assets.any((asset) => asset['tokenId'] == _ergoFiroToken) && + item['spentTransactionId'] == null && + item['mainChain'] == true) { + candidates.add(Map.from(item as Map)); + } + } + offset += items.length; + if (offset >= int.parse(result['total'].toString())) break; + if (items.isEmpty || offset > 10000) { + throw const FormatException('Incomplete Rosen fee configuration'); + } + } + if (candidates.length != 1) { + throw StateError('Expected one active Rosen FIRO fee configuration'); + } + final registers = Map.from( + candidates.single['additionalRegisters'] as Map, + ); + final quote = RosenQuote.fromRegisters( + registers, + fromFiro: fromFiro, + height: height, + amount: amount, + ); + // Refuse a quote whose fees are scheduled to change during confirmation. + final next = RosenQuote.fromRegisters( + registers, + fromFiro: fromFiro, + height: + height + + (fromFiro ? firoConfirmationWindow : ethereumConfirmationWindow), + amount: amount, + ); + if (quote.bridgeFee != next.bridgeFee || + quote.networkFee != next.networkFee) { + throw StateError('Rosen fees are changing. Please try again shortly.'); + } + return quote; + } + + /// An unobserved source transaction remains pending, including across restarts. + Future?> getEvent(String sourceTxId) async { + if (!RegExp(r'^(0x)?[0-9a-fA-F]{64}$').hasMatch(sourceTxId)) { + throw const FormatException('Invalid Rosen source transaction ID'); + } + final normalized = sourceTxId.toLowerCase().replaceFirst( + RegExp(r'^0x'), + '', + ); + final result = await _get( + Uri.https('app.rosen.tech', '/api/v1/events', { + 'sourceTxId*': normalized, + 'limit': '100', + }), + ) as Map; + final matches = (result['items'] as List) + .where( + (item) => + item['sourceTxId'] is String && + (item['sourceTxId'] as String).toLowerCase().replaceFirst( + RegExp(r'^0x'), + '', + ) == + normalized, + ) + .toList(); + if (matches.length > 1) { + throw StateError('Multiple Rosen events for this source transaction'); + } + return matches.isEmpty + ? null + : Map.from(matches.single as Map); + } +} diff --git a/lib/services/exchange/rosen/rosen_exchange.dart b/lib/services/exchange/rosen/rosen_exchange.dart new file mode 100644 index 0000000000..c44cac8937 --- /dev/null +++ b/lib/services/exchange/rosen/rosen_exchange.dart @@ -0,0 +1,505 @@ +import 'dart:convert'; + +import 'package:decimal/decimal.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../db/hive/db.dart'; +import '../../../exceptions/exchange/exchange_exception.dart'; +import '../../../models/exchange/response_objects/estimate.dart'; +import '../../../models/exchange/response_objects/range.dart'; +import '../../../models/exchange/response_objects/trade.dart'; +import '../../../models/isar/exchange_cache/currency.dart'; +import '../../../models/isar/exchange_cache/pair.dart'; +import '../../../models/trade_wallet_lookup.dart'; +import '../../../utilities/default_eth_tokens.dart'; +import '../../ethereum/ethereum_api.dart'; +import '../exchange.dart'; +import '../exchange_response.dart'; +import 'rosen_api.dart'; +import 'rosen_protocol.dart'; + +class RosenExchange extends Exchange { + RosenExchange._(); + static final instance = RosenExchange._(); + static const exchangeName = 'Rosen Bridge'; + + @override + String get name => exchangeName; + @override + bool get supportsRefundAddress => false; + @override + bool get supportsExtraId => false; + + static bool isFiro(Trade trade) => + trade.payInCurrency.toUpperCase() == 'FIRO'; + + static bool _pair(String from, String? fromNet, String to, String? toNet) => + (from.toUpperCase() == 'FIRO' && + fromNet == 'firo' && + to.toUpperCase() == 'RSFIRO' && + toNet == 'eth') || + (from.toUpperCase() == 'RSFIRO' && + fromNet == 'eth' && + to.toUpperCase() == 'FIRO' && + toNet == 'firo'); + + static void _checkPair( + String from, + String? fromNet, + String to, + String? toNet, + bool fixed, + bool reversed, + ) { + if (!_pair(from, fromNet, to, toNet) || fixed || reversed) { + throw const FormatException( + 'Rosen supports estimated FIRO ↔ rsFIRO (Ethereum) swaps using the send amount.', + ); + } + } + + Future> _response(Future Function() action) async { + try { + return ExchangeResponse(value: await action()); + } catch (e) { + return ExchangeResponse( + exception: e is ExchangeException + ? e + : ExchangeException(e.toString(), ExchangeExceptionType.generic), + ); + } + } + + @override + Future>> getAllCurrencies(bool fixedRate) => + _response( + () async => fixedRate + ? [] + : [ + Currency( + exchangeName: name, + ticker: 'FIRO', + name: 'Firo', + network: 'firo', + image: '', + isFiat: false, + rateType: SupportedRateType.estimated, + isStackCoin: true, + tokenContract: null, + isAvailable: true, + ), + Currency( + exchangeName: name, + ticker: DefaultTokens.rsFiro.symbol, + name: DefaultTokens.rsFiro.name, + network: 'eth', + image: '', + isFiat: false, + rateType: SupportedRateType.estimated, + isStackCoin: false, + tokenContract: DefaultTokens.rsFiro.address, + isAvailable: true, + ), + ], + ); + + @override + Future> getRange( + String from, + String? fromNetwork, + String to, + String? toNetwork, + bool fixedRate, + ) => _response(() async { + _checkPair(from, fromNetwork, to, toNetwork, fixedRate, false); + final quote = await RosenApi.instance.quote( + fromFiro: from.toUpperCase() == 'FIRO', + amount: BigInt.zero, + ); + return Range( + min: Decimal.parse(RosenProtocol.formatAmount(quote.minimum)), + max: Decimal.parse(RosenProtocol.formatAmount(RosenProtocol.maxAmount)), + ); + }); + + @override + Future>> getEstimates( + String from, + String? fromNetwork, + String to, + String? toNetwork, + Decimal amount, + bool fixedRate, + bool reversed, + ) => _response(() async { + _checkPair(from, fromNetwork, to, toNetwork, fixedRate, reversed); + final rawAmount = RosenProtocol.parseAmount(amount.toString()); + final quote = await RosenApi.instance.quote( + fromFiro: from.toUpperCase() == 'FIRO', + amount: rawAmount, + ); + if (rawAmount < quote.minimum || quote.receiveAmount <= BigInt.zero) { + throw const FormatException('Amount is below the Rosen bridge minimum.'); + } + return [ + Estimate( + estimatedAmount: Decimal.parse( + RosenProtocol.formatAmount(quote.receiveAmount), + ), + fixedRate: false, + reversed: false, + rateId: '${from.toLowerCase()}:${quote.fingerprint}', + exchangeProvider: name, + ), + ]; + }); + + @override + Future> createTrade({ + required String from, + required String to, + required String? fromNetwork, + required String? toNetwork, + required bool fixedRate, + required Decimal amount, + required String addressTo, + String? extraId, + required String addressRefund, + required String refundExtraId, + Estimate? estimate, + required bool reversed, + }) => _response(() async { + _checkPair(from, fromNetwork, to, toNetwork, fixedRate, reversed); + if ((extraId?.isNotEmpty ?? false) || + addressRefund.isNotEmpty || + refundExtraId.isNotEmpty) { + throw const FormatException( + 'Rosen does not support memo or refund addresses.', + ); + } + final fromFiro = from.toUpperCase() == 'FIRO'; + final rawAmount = RosenProtocol.parseAmount(amount.toString()); + final quote = await RosenApi.instance.quote( + fromFiro: fromFiro, + amount: rawAmount, + ); + if (estimate != null && + (estimate.rateId != '${from.toLowerCase()}:${quote.fingerprint}' || + quote.receiveAmount <= BigInt.zero || + estimate.estimatedAmount != + Decimal.parse( + RosenProtocol.formatAmount(quote.receiveAmount), + ))) { + throw ExchangeException( + 'Rosen fees changed. Refresh the swap quote before continuing.', + ExchangeExceptionType.quoteChanged, + ); + } + if (rawAmount < quote.minimum || quote.receiveAmount <= BigInt.zero) { + throw const FormatException('Amount is below the Rosen bridge minimum.'); + } + final receiveAmount = RosenProtocol.formatAmount(quote.receiveAmount); + final id = const Uuid().v4(); + final now = DateTime.now(); + final trade = Trade( + uuid: id, + tradeId: id, + rateType: 'estimated', + direction: 'direct', + timestamp: now, + updatedAt: now, + payInCurrency: fromFiro ? 'FIRO' : 'rsFIRO', + payInAmount: RosenProtocol.formatAmount(rawAmount), + payInAddress: fromFiro + ? RosenApi.firoLockAddress + : RosenApi.ethereumLockAddress, + payInNetwork: fromNetwork!, + payInExtraId: '', + payInTxid: '', + payOutCurrency: fromFiro ? 'rsFIRO' : 'FIRO', + payOutAmount: receiveAmount, + payOutAddress: addressTo, + payOutNetwork: toNetwork!, + payOutExtraId: '', + payOutTxid: '', + refundAddress: '', + refundExtraId: '', + status: 'Waiting', + exchangeName: name, + other: jsonEncode({ + 'version': 1, + 'bridgeFee': quote.bridgeFee.toString(), + 'networkFee': quote.networkFee.toString(), + 'tokenContract': DefaultTokens.rsFiro.address, + }), + ); + validatedMetadata(trade); + return trade; + }); + + static Trade _latest(Trade trade) => + DB.instance.get(boxName: DB.boxNameTradesV2, key: trade.uuid) ?? + trade; + + static Future _markFundingObserved(Trade trade) { + final db = DB.instance; + return db.mutex.protect(() async { + final trades = db.hive.box(DB.boxNameTradesV2); + final current = trades.get(trade.uuid); + if (current == null) return trade; + final data = jsonDecode(current.other!) as Map; + final value = data['funding']; + if (value is! Map || value['state'] == 'broadcast') return current; + final funding = Map.from(value); + final walletId = funding['walletId']; + final txid = funding['txid']; + if (walletId is! String || + txid is! String || + txid != current.payInTxid.toLowerCase()) { + return current; + } + final updated = current.copyWith( + other: jsonEncode({ + ...data, + 'funding': {...funding, 'state': 'broadcast'}, + }), + ); + await db.hive + .box(DB.boxNameTradeLookup) + .put( + current.uuid, + TradeWalletLookup( + uuid: current.uuid, + txid: txid, + tradeId: current.tradeId, + walletIds: [walletId], + ), + ); + await trades.put(current.uuid, updated); + return updated; + }); + } + + static bool sameVersion(Trade first, Trade second) => + jsonEncode(first.toMap()) == jsonEncode(second.toMap()); + + static void requireUnfunded(Trade trade) { + if (trade.payInTxid.isNotEmpty || + trade.payOutTxid.isNotEmpty || + !{'new', 'waiting'}.contains(trade.status.toLowerCase())) { + throw StateError('Only an unfunded Rosen swap can be refreshed or sent.'); + } + } + + static Trade currentUnfunded(Trade trade) { + final current = DB.instance.get( + boxName: DB.boxNameTradesV2, + key: trade.uuid, + ); + if (current == null) { + throw StateError('This Rosen swap is no longer available.'); + } + requireUnfunded(current); + if (!sameVersion(trade, current)) { + throw ExchangeException( + 'This swap quote was updated. Refresh it before sending.', + ExchangeExceptionType.quoteChanged, + ); + } + return current; + } + + /// Replace only quote-dependent fields, retaining the user's amount and destination. + static Trade refreshCandidate(Trade trade, RosenQuote quote) { + requireUnfunded(trade); + final amount = RosenProtocol.parseAmount(trade.payInAmount); + if (amount < quote.minimum || + quote.receiveAmount <= BigInt.zero || + amount - quote.bridgeFee - quote.networkFee != quote.receiveAmount) { + throw const FormatException('Amount is below the Rosen bridge minimum.'); + } + final data = jsonDecode(trade.other!) as Map; + data.remove('metadata'); + final updated = trade.copyWith( + updatedAt: DateTime.now(), + payOutAmount: RosenProtocol.formatAmount(quote.receiveAmount), + other: jsonEncode({ + ...data, + 'bridgeFee': quote.bridgeFee.toString(), + 'networkFee': quote.networkFee.toString(), + }), + ); + validatedMetadata(updated); + return updated; + } + + static Future saveRefreshedTrade( + Trade original, + RosenQuote quote, + ) async { + final db = DB.instance; + return db.mutex.protect(() async { + currentUnfunded(original); + final updated = refreshCandidate(original, quote); + await db.hive.box(DB.boxNameTradesV2).put(original.uuid, updated); + return updated; + }); + } + + /// Rebuild metadata from the trade, and fail closed on edited or stale records. + static String validatedMetadata(Trade trade) { + _checkPair( + trade.payInCurrency, + trade.payInNetwork, + trade.payOutCurrency, + trade.payOutNetwork, + false, + false, + ); + if (trade.exchangeName != exchangeName) { + throw StateError('Not a Rosen swap.'); + } + final data = jsonDecode(trade.other!) as Map; + final bridgeFee = BigInt.parse(data['bridgeFee'] as String); + final networkFee = BigInt.parse(data['networkFee'] as String); + final amount = RosenProtocol.parseAmount(trade.payInAmount); + if (data['version'] != 1 || + data['tokenContract'] != DefaultTokens.rsFiro.address || + amount <= bridgeFee + networkFee || + amount - bridgeFee - networkFee != + RosenProtocol.parseAmount(trade.payOutAmount) || + trade.payInAddress != + (isFiro(trade) + ? RosenApi.firoLockAddress + : RosenApi.ethereumLockAddress)) { + throw const FormatException('Invalid Rosen swap data.'); + } + return RosenProtocol.metadata( + fromFiro: isFiro(trade), + destination: trade.payOutAddress, + bridgeFee: bridgeFee, + networkFee: networkFee, + ); + } + + static Future _validateCurrentFees( + Trade trade, { + int? sourceHeight, + }) async { + validatedMetadata(trade); + final data = jsonDecode(trade.other!) as Map; + final quote = await RosenApi.instance.quote( + fromFiro: isFiro(trade), + amount: RosenProtocol.parseAmount(trade.payInAmount), + sourceHeight: sourceHeight, + ); + if (!quote.hasFees( + BigInt.parse(data['bridgeFee'] as String), + BigInt.parse(data['networkFee'] as String), + )) { + throw ExchangeException( + 'Rosen fees changed. Refresh the swap quote before sending.', + ExchangeExceptionType.quoteChanged, + ); + } + } + + static Future validateFunding(Trade trade, {int? sourceHeight}) async { + currentUnfunded(trade); + await _validateCurrentFees(trade, sourceHeight: sourceHeight); + currentUnfunded(trade); + } + + static Future validateFundingRecovery( + Trade trade, { + int? sourceHeight, + }) => _validateCurrentFees(trade, sourceHeight: sourceHeight); + + @override + Future> updateTrade(Trade trade) => + _response(() async { + trade = _latest(trade); + if (trade.payInTxid.isEmpty || + {'finished', 'failed'}.contains(trade.status.toLowerCase())) { + return trade; + } + final event = await RosenApi.instance.getEvent(trade.payInTxid); + trade = _latest(trade); + if ({'finished', 'failed'}.contains(trade.status.toLowerCase())) { + return trade; + } + if (event == null) { + final receiptStatus = isFiro(trade) + ? null + : await EthereumAPI.getTransactionReceiptStatus( + trade.payInTxid, + confirmations: RosenApi.ethereumConfirmationWindow, + ); + if (receiptStatus != null) trade = await _markFundingObserved(trade); + final currentStatus = trade.status.toLowerCase(); + return trade.copyWith( + status: receiptStatus == false + ? 'Failed' + : {'exchanging', 'sending'}.contains(currentStatus) + ? trade.status + : receiptStatus == null && currentStatus == 'verifying' + ? 'Verifying' + : 'Confirming', + updatedAt: DateTime.now(), + ); + } + final fromFiro = isFiro(trade); + final data = jsonDecode(trade.other!) as Map; + if (event['fromChain'] != (fromFiro ? 'firo' : 'ethereum') || + event['toChain'] != (fromFiro ? 'ethereum' : 'firo') || + (fromFiro + ? (event['toAddress'] as String).toLowerCase() != + trade.payOutAddress.toLowerCase() + : event['toAddress'] != trade.payOutAddress) || + event['sourceChainTokenId'].toString().toLowerCase() != + (fromFiro ? 'firo' : DefaultTokens.rsFiro.address) || + event['bridgeFee'].toString() != data['bridgeFee'] || + event['networkFee'].toString() != data['networkFee'] || + BigInt.parse(event['amount'].toString()) != + RosenProtocol.parseAmount(trade.payInAmount)) { + throw const FormatException('Rosen event does not match this swap.'); + } + trade = await _markFundingObserved(trade); + final payoutTxid = event['paymentTxId'] as String?; + final hasPayout = RosenProtocol.isTransactionId(payoutTxid); + final status = RosenProtocol.swapStatus( + event['status'], + payoutTxid, + flowStatuses: event['statuses'], + ); + return trade.copyWith( + status: status, + payOutTxid: hasPayout ? payoutTxid : trade.payOutTxid, + updatedAt: DateTime.now(), + ); + }); + + @override + Future> getTrade(String tradeId) async { + final trades = DB.instance + .values(boxName: DB.boxNameTradesV2) + .where((e) => e.tradeId == tradeId && e.exchangeName == name); + if (trades.isEmpty) { + return ExchangeResponse( + exception: ExchangeException( + 'Rosen swap not found locally.', + ExchangeExceptionType.orderNotFound, + ), + ); + } + return updateTrade(trades.first); + } + + @override + Future>> getTrades() => _response( + () async => DB.instance + .values(boxName: DB.boxNameTradesV2) + .where((e) => e.exchangeName == name) + .toList(), + ); +} diff --git a/lib/services/exchange/rosen/rosen_fees.dart b/lib/services/exchange/rosen/rosen_fees.dart new file mode 100644 index 0000000000..ab10c1dc0c --- /dev/null +++ b/lib/services/exchange/rosen/rosen_fees.dart @@ -0,0 +1,172 @@ +import 'dart:convert'; + +/// Atomic FIRO amounts (8 decimals), including Rosen's destination network fee. +class RosenQuote { + final BigInt bridgeFee; + final BigInt networkFee; + final BigInt minimum; + final BigInt receiveAmount; + + const RosenQuote({ + required this.bridgeFee, + required this.networkFee, + required this.minimum, + required this.receiveAmount, + }); + + /// Includes each fee component: an unchanged total can still change metadata. + String get fingerprint => '$bridgeFee:$networkFee:$receiveAmount'; + + bool hasFees(BigInt bridge, BigInt network) => + bridgeFee == bridge && networkFee == network; + + /// Rosen minimum-fee registers, selected by source height and destination. + /// https://github.com/rosen-bridge/utils/tree/dev/packages/minimum-fee + factory RosenQuote.fromRegisters( + Map registers, { + required bool fromFiro, + required int height, + required BigInt amount, + }) { + if (amount.isNegative || height <= 0) { + throw const FormatException('Invalid Rosen amount or source height'); + } + final chains = _RegisterReader(registers['R4'], 0x1a).strings(); + final heights = _RegisterReader(registers['R5'], 0x1c).matrix(32); + final bridgeFees = _RegisterReader(registers['R6'], 0x1d).matrix(64); + final networkFees = _RegisterReader(registers['R7'], 0x1d).matrix(64); + final ratios = _RegisterReader(registers['R9'], 0x1d).matrix(64); + final source = chains.indexOf(fromFiro ? 'firo' : 'ethereum'); + final destination = chains.indexOf(fromFiro ? 'ethereum' : 'firo'); + if (source < 0 || + destination < 0 || + chains.toSet().length != chains.length) { + throw const FormatException('Rosen route is unavailable'); + } + for (final matrix in [heights, bridgeFees, networkFees, ratios]) { + if (matrix.length != heights.length || + matrix.any((row) => row.length != chains.length)) { + throw const FormatException('Invalid Rosen fee register dimensions'); + } + } + for (var i = heights.length - 1; i >= 0; i--) { + if (heights[i][source].isNegative) { + throw const FormatException('Rosen source chain is disabled'); + } + // Rosen intentionally activates each configuration AFTER this height. + if (BigInt.from(height) <= heights[i][source]) continue; + final base = bridgeFees[i][destination]; + final network = networkFees[i][destination]; + final ratio = ratios[i][destination]; + final divisor = BigInt.from(10000); + if (base.isNegative || + network.isNegative || + ratio.isNegative || + ratio >= divisor) { + throw const FormatException('Rosen destination fees are unavailable'); + } + final variable = amount * ratio ~/ divisor; + final bridge = base > variable ? base : variable; + final baseMinimum = base + network + BigInt.one; + // Includes the percentage fee: the receiver must get at least one atom. + final ratioMinimum = network * divisor ~/ (divisor - ratio) + BigInt.one; + return RosenQuote( + bridgeFee: bridge, + networkFee: network, + minimum: baseMinimum > ratioMinimum ? baseMinimum : ratioMinimum, + receiveAmount: amount - bridge - network, + ); + } + throw const FormatException('No active Rosen fee schedule'); + } +} + +/// Only the Sigma collection types Rosen uses for chains, heights and fees. +/// Decodes serialized values so large fees never pass through floating point. +class _RegisterReader { + final List _bytes; + int _offset = 0; + + _RegisterReader(dynamic register, int type) : _bytes = _hex(register) { + if (_byte() != type) { + throw const FormatException('Unexpected Rosen fee register type'); + } + } + + static List _hex(dynamic register) { + final value = register is Map ? register['serializedValue'] : null; + if (value is! String || + value.isEmpty || + value.length.isOdd || + value.length > 65536 || + !RegExp(r'^[0-9a-fA-F]+$').hasMatch(value)) { + throw const FormatException('Invalid Rosen fee register'); + } + return [ + for (var i = 0; i < value.length; i += 2) + int.parse(value.substring(i, i + 2), radix: 16), + ]; + } + + int _byte() { + if (_offset >= _bytes.length) { + throw const FormatException('Truncated Rosen fee register'); + } + return _bytes[_offset++]; + } + + BigInt _unsigned(int bits) { + var value = BigInt.zero; + for (var shift = 0; shift < bits; shift += 7) { + final byte = _byte(); + value |= BigInt.from(byte & 0x7f) << shift; + if (byte & 0x80 == 0) { + if (value.bitLength > bits) break; + return value; + } + } + throw const FormatException('Rosen fee integer overflow'); + } + + int _length() { + final length = _unsigned(32).toInt(); + if (length > _bytes.length - _offset) { + throw const FormatException('Invalid Rosen register collection length'); + } + return length; + } + + void _finish() { + if (_offset != _bytes.length) { + throw const FormatException('Trailing Rosen fee register bytes'); + } + } + + List strings() { + final result = []; + final count = _length(); + for (var i = 0; i < count; i++) { + final length = _length(); + result.add(utf8.decode(_bytes.sublist(_offset, _offset + length))); + _offset += length; + } + _finish(); + return result; + } + + List> matrix(int bits) { + final result = >[]; + final count = _length(); + for (var i = 0; i < count; i++) { + final length = _length(); + final row = []; + for (var j = 0; j < length; j++) { + final value = _unsigned(bits); + row.add((value >> 1) ^ -(value & BigInt.one)); + } + result.add(row); + } + _finish(); + return result; + } +} diff --git a/lib/services/exchange/rosen/rosen_funding.dart b/lib/services/exchange/rosen/rosen_funding.dart new file mode 100644 index 0000000000..f9b4e1bc5e --- /dev/null +++ b/lib/services/exchange/rosen/rosen_funding.dart @@ -0,0 +1,1233 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:isar_community/isar.dart'; +import 'package:mutex/mutex.dart'; +import 'package:wallet/wallet.dart' as eth; +import 'package:web3dart/json_rpc.dart' show RPCError; +import 'package:web3dart/web3dart.dart' as web3; + +import '../../../db/hive/db.dart'; +import '../../../exceptions/electrumx/no_such_transaction.dart'; +import '../../../exceptions/exchange/exchange_exception.dart'; +import '../../../exceptions/json_rpc/json_rpc_exception.dart'; +import '../../../models/exchange/response_objects/trade.dart'; +import '../../../models/input.dart'; +import '../../../models/isar/models/ethereum/eth_contract.dart'; +import '../../../models/trade_wallet_lookup.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/default_eth_tokens.dart'; +import '../../../utilities/enums/fee_rate_type_enum.dart'; +import '../../../utilities/extensions/extensions.dart'; +import '../../../utilities/logger.dart'; +import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/models/tx_data.dart'; +import '../../../wallets/wallet/impl/ethereum_wallet.dart'; +import '../../../wallets/wallet/impl/firo_wallet.dart'; +import '../../../wallets/wallet/impl/sub_wallets/eth_token_wallet.dart'; +import '../../../wallets/wallet/wallet.dart'; +import '../../../wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart'; +import 'rosen_api.dart'; +import 'rosen_exchange.dart'; +import 'rosen_protocol.dart'; + +enum _FundingConflict { none, pending, confirmed, exact, unknown } + +/// Bridge transactions retain the normal swap confirmation and wallet signing. +class RosenFunding { + static const fundingKey = 'funding'; + static const submittingState = 'submitting'; + static const broadcastState = 'broadcast'; + static const needsAttentionState = 'needsAttention'; + static const failedState = 'failed'; + static const _rpcTimeout = Duration(seconds: 30); + static final Map _walletLocks = {}; + + static Future _saveFundingLookup({ + required Trade trade, + required String walletId, + required String txid, + }) => DB.instance.hive + .box(DB.boxNameTradeLookup) + .put( + trade.uuid, + TradeWalletLookup( + uuid: trade.uuid, + txid: txid, + tradeId: trade.tradeId, + walletIds: [walletId], + ), + ); + + static CryptoCurrency sourceCoin(Trade trade) => RosenExchange.isFiro(trade) + ? Firo(CryptoCurrencyNetwork.main) + : Ethereum(CryptoCurrencyNetwork.main); + + static bool canFund(Wallet wallet, Trade trade) => + !wallet.info.isViewOnly && + wallet.cryptoCurrency == sourceCoin(trade) && + (RosenExchange.isFiro(trade) + ? wallet is FiroWallet + : wallet is EthereumWallet); + + static EthContract _tokenFor(Wallet wallet) => DefaultTokens.rsFiro.copyWith( + id: Isar.autoIncrement, + address: wallet.info.tokenContractAddresses.firstWhere( + (address) => address.toLowerCase() == DefaultTokens.rsFiro.address, + orElse: () => DefaultTokens.rsFiro.address, + ), + ); + + static Future registerToken(Wallet wallet) async { + if (wallet is! EthereumWallet || + wallet.cryptoCurrency.network != CryptoCurrencyNetwork.main) { + throw StateError('rsFIRO requires an Ethereum mainnet wallet.'); + } + final token = _tokenFor(wallet); + final stored = await wallet.mainDB + .getEthContracts() + .filter() + .addressEqualTo(token.address, caseSensitive: false) + .findFirst(); + if (stored == null) await wallet.mainDB.putEthContract(token); + if (!wallet.info.tokenContractAddresses.any( + (address) => address.toLowerCase() == DefaultTokens.rsFiro.address, + )) { + await wallet.updateTokenContracts([ + ...wallet.info.tokenContractAddresses, + stored?.address ?? token.address, + ]); + } + } + + static Future _validatePendingTransfer( + web3.Web3Client client, + eth.EthereumAddress sender, + eth.EthereumAddress contract, + Uint8List data, + ) async { + final String result; + try { + result = await client + .callRaw( + sender: sender, + contract: contract, + data: data, + atBlock: const web3.BlockNum.pending(), + ) + .timeout(_rpcTimeout); + } on RPCError catch (error) { + throw StateError( + 'The pending rsFIRO transfer could not be validated. ' + 'Check your rsFIRO balance and try again. ${error.message}', + ); + } + if (RosenProtocol.tokenContract + .function('transfer') + .decodeReturnValues(result) + .single != + true) { + throw StateError('The pending rsFIRO transfer would fail.'); + } + } + + /// Save the exact signed transaction before making the network request. + static Future recordFundingIntent({ + required Trade trade, + required String walletId, + required String txid, + required String raw, + int? nonce, + int? sourceHeight, + List>? firoInputs, + }) async { + if (!RosenProtocol.isTransactionId(txid)) { + throw StateError('The wallet created an invalid transaction ID.'); + } + txid = txid.toLowerCase(); + if (raw.isEmpty || + raw.length.isOdd || + !RegExp(r'^(0x)?[0-9a-fA-F]+$').hasMatch(raw)) { + throw StateError('The wallet created an invalid signed transaction.'); + } + raw = raw.toLowerCase(); + final db = DB.instance; + await db.mutex.protect(() async { + final trades = db.hive.box(DB.boxNameTradesV2); + final current = trades.get(trade.uuid); + if (current == null) { + throw StateError('This Rosen swap is no longer available.'); + } + final data = jsonDecode(current.other!) as Map; + final funding = data[fundingKey]; + if (current.payInTxid.isEmpty) { + RosenExchange.currentUnfunded(trade); + await trades.put( + current.uuid, + current.copyWith( + payInTxid: txid, + status: 'Verifying', + updatedAt: DateTime.now(), + other: jsonEncode({ + ...data, + fundingKey: { + 'walletId': walletId, + 'txid': txid, + 'raw': raw, + 'state': submittingState, + if (nonce != null) 'nonce': nonce, + if (sourceHeight != null) 'height': sourceHeight, + if (firoInputs != null) 'inputs': firoInputs, + }, + }), + ), + ); + } else if (current.payInTxid != txid || + funding is! Map || + funding['walletId'] != walletId || + funding['txid'] != txid || + funding['raw'] != raw) { + throw StateError('This Rosen swap already has another deposit.'); + } + try { + await _saveFundingLookup( + trade: current, + walletId: walletId, + txid: txid, + ); + } catch (e, s) { + Logging.instance.e( + 'Rosen funding lookup will be repaired during recovery', + error: e, + stackTrace: s, + ); + } + }); + } + + static Map? _funding(Trade trade) { + final value = + (jsonDecode(trade.other!) as Map)[fundingKey]; + if (value == null) return null; + if (value is! Map) { + throw const FormatException('Invalid Rosen funding journal.'); + } + final funding = Map.from(value); + final state = funding['state']; + if (state != submittingState && + state != broadcastState && + state != needsAttentionState && + state != failedState) { + throw const FormatException('Invalid Rosen funding journal state.'); + } + return funding; + } + + static String? fundingWalletId(Trade trade) { + final funding = _funding(trade); + return funding == null || + {broadcastState, failedState}.contains(funding['state']) + ? null + : funding['walletId'] as String?; + } + + static String? recoveryWalletId(Trade trade) { + final funding = _funding(trade); + return funding == null || + funding['state'] == failedState || + { + 'finished', + 'failed', + 'exchanging', + 'sending', + }.contains(trade.status.toLowerCase()) + ? null + : funding['walletId'] as String?; + } + + static Trade remapFundingWallet(Trade trade, Map walletIds) { + if (trade.exchangeName != RosenExchange.exchangeName) return trade; + final funding = _funding(trade); + if (funding == null) return trade; + final walletId = funding['walletId']; + if (walletId is! String || walletId.isEmpty) { + throw const FormatException('Invalid Rosen funding wallet.'); + } + final restoredId = walletIds[walletId] ?? walletId; + if (restoredId.isEmpty) { + throw const FormatException('Invalid restored Rosen funding wallet.'); + } + if (restoredId == walletId) return trade; + return trade.copyWith( + other: jsonEncode({ + ...jsonDecode(trade.other!) as Map, + fundingKey: {...funding, 'walletId': restoredId}, + }), + ); + } + + static bool needsAttention(Trade trade) => + _funding(trade)?['state'] == needsAttentionState; + + static bool _requiresAttention(Object error) { + final message = switch (error) { + JsonRpcException() => error.message, + RPCError() => '${error.message} ${error.data ?? ''}', + _ => error.toString(), + }.toLowerCase(); + return const [ + 'transaction was rejected by network rules', + 'bad-txns', + 'mandatory-script-verify', + 'non-mandatory-script-verify', + 'nonce too low', + 'invalid sender', + 'intrinsic gas too low', + 'replacement transaction underpriced', + 'already known', + ].any(message.contains); + } + + static bool _historicalDataUnavailable(RPCError error) { + final message = '${error.message} ${error.data ?? ''}'.toLowerCase(); + return const [ + 'missing trie node', + 'state unavailable', + 'state is unavailable', + 'state not available', + 'state is not available', + 'no state available', + 'history has been pruned', + 'history is pruned', + 'history pruned', + 'block body unavailable', + 'block body is unavailable', + 'block body not available', + 'block body is not available', + ].any(message.contains); + } + + static Future<_FundingConflict> _fundingConflict({ + required Wallet wallet, + required Map funding, + required String txid, + }) async { + if (wallet is FiroWallet) { + final savedInputs = funding['inputs']; + if (savedInputs == null) return _FundingConflict.none; + if (savedInputs is! List) { + throw const FormatException('Invalid FIRO funding inputs.'); + } + final inputs = <({String txid, int vout, String scripthash})>[]; + for (final entry in savedInputs) { + if (entry is! Map || + entry['txid'] is! String || + entry['vout'] is! int || + entry['scripthash'] is! String) { + throw const FormatException('Invalid FIRO funding input.'); + } + inputs.add(( + txid: (entry['txid'] as String).toLowerCase(), + vout: entry['vout'] as int, + scripthash: entry['scripthash'] as String, + )); + } + final height = await wallet.fetchChainHeight().timeout(_rpcTimeout); + final seen = {}; + var result = _FundingConflict.none; + for (final scripthash + in inputs.map((input) => input.scripthash).toSet()) { + final history = await wallet.electrumXClient + .getHistory(scripthash: scripthash) + .timeout(_rpcTimeout); + for (final item in history) { + final candidate = item['tx_hash']; + final blockHeight = item['height']; + if (candidate is! String || blockHeight is! int) { + continue; + } + if (!RosenProtocol.isTransactionId(candidate)) { + throw const FormatException('Invalid FIRO transaction history.'); + } + if (!seen.add(candidate.toLowerCase())) continue; + final response = await wallet.electrumXClient + .request( + command: 'blockchain.transaction.get', + args: [candidate, false], + ) + .timeout(_rpcTimeout); + if (response is! String) { + throw const FormatException('Invalid FIRO transaction response.'); + } + final decoded = decodeFiroTransaction(response); + if (decoded.txid.toLowerCase() != candidate.toLowerCase()) { + throw const FormatException('FIRO transaction hash mismatch.'); + } + if (candidate.toLowerCase() == txid.toLowerCase()) { + return _FundingConflict.exact; + } + final spendsSavedInput = decoded.prevouts.any( + (prevout) => inputs.any( + (input) => + input.txid == prevout.txid.toLowerCase() && + input.vout == prevout.vout, + ), + ); + if (spendsSavedInput) { + if (blockHeight > 0 && + height - blockHeight + 1 >= RosenApi.firoConfirmationWindow) { + return _FundingConflict.confirmed; + } + result = _FundingConflict.pending; + } + } + } + return result; + } + + final nonce = funding['nonce']; + final sourceHeight = funding['height']; + if (nonce is! int || sourceHeight is! int || sourceHeight < 0) { + return _FundingConflict.none; + } + final ethereum = wallet as EthereumWallet; + final client = ethereum.getEthClient(); + try { + final sender = await ethereum.getMyWeb3Address(); + Future<_FundingConflict> pendingOrNone() async => + await client + .getTransactionCount( + sender, + atBlock: const web3.BlockNum.pending(), + ) + .timeout(_rpcTimeout) > + nonce + ? _FundingConflict.pending + : _FundingConflict.none; + final height = await client.getBlockNumber().timeout(_rpcTimeout); + final safeHeight = height - RosenApi.ethereumConfirmationWindow + 1; + if (safeHeight < sourceHeight) return await pendingOrNone(); + Future countAt(int height) => client + .getTransactionCount(sender, atBlock: web3.BlockNum.exact(height)) + .timeout(_rpcTimeout); + if (await countAt(safeHeight) <= nonce) return await pendingOrNone(); + + var low = sourceHeight >= RosenApi.ethereumConfirmationWindow - 1 + ? sourceHeight - RosenApi.ethereumConfirmationWindow + 1 + : 0; + var high = safeHeight; + try { + while (low < high) { + final middle = (low + high) ~/ 2; + if (await countAt(middle) > nonce) { + high = middle; + } else { + low = middle + 1; + } + } + } on RPCError catch (error) { + if (_historicalDataUnavailable(error)) { + return _FundingConflict.unknown; + } + rethrow; + } + final blockNumber = '0x${low.toRadixString(16)}'; + late final Map block; + try { + block = await client + .makeRPCCall>('eth_getBlockByNumber', [ + blockNumber, + true, + ]) + .timeout(_rpcTimeout); + } on RPCError catch (error) { + if (_historicalDataUnavailable(error)) { + return _FundingConflict.unknown; + } + rethrow; + } + final transactions = block['transactions']; + if (block['number'] != blockNumber || + transactions is! List || + transactions.isEmpty) { + throw const FormatException('Invalid Ethereum block response.'); + } + for (final value in transactions) { + if (value is! Map || value['from'] is! String) { + throw const FormatException('Invalid Ethereum block response.'); + } + final valueNonce = value['nonce']; + if (valueNonce is! String || + !RegExp(r'^0x[0-9a-fA-F]+$').hasMatch(valueNonce)) { + throw const FormatException('Invalid Ethereum transaction nonce.'); + } + final hash = value['hash']; + if (hash is! String || !RosenProtocol.isTransactionId(hash)) { + throw const FormatException('Invalid Ethereum transaction response.'); + } + if ((value['from'] as String).toLowerCase() != + sender.with0x.toLowerCase() || + int.parse(valueNonce.substring(2), radix: 16) != nonce) { + continue; + } + return hash.toLowerCase() == txid.toLowerCase() + ? _FundingConflict.exact + : _FundingConflict.confirmed; + } + return _FundingConflict.confirmed; + } finally { + await client.dispose(); + } + } + + static Future _markFundingState({ + required Trade trade, + required String walletId, + required String txid, + required String raw, + required String state, + String? reason, + }) async { + final db = DB.instance; + return db.mutex.protect(() async { + final trades = db.hive.box(DB.boxNameTradesV2); + final current = trades.get(trade.uuid); + if (current == null) { + throw StateError('This Rosen swap is no longer available.'); + } + final data = jsonDecode(current.other!) as Map; + final funding = _funding(current); + if (current.payInTxid.toLowerCase() != txid.toLowerCase() || + funding == null || + funding['walletId'] != walletId || + funding['txid'] != txid || + funding['raw'] != raw) { + throw StateError('The Rosen funding journal changed.'); + } + await _saveFundingLookup(trade: current, walletId: walletId, txid: txid); + final terminal = { + 'finished', + 'failed', + }.contains(current.status.toLowerCase()); + final progressed = { + 'exchanging', + 'sending', + }.contains(current.status.toLowerCase()); + final status = terminal || (state == broadcastState && progressed) + ? current.status + : state == broadcastState + ? 'Confirming' + : state == failedState + ? 'Failed' + : 'Verifying'; + final sameReason = + state != needsAttentionState || funding['reason'] == reason; + if ((funding['state'] == broadcastState && (terminal || progressed)) || + (funding['state'] == state && + current.status == status && + sameReason)) { + return current; + } + final updatedFunding = {...funding, 'state': state}; + if (state == needsAttentionState) { + updatedFunding['reason'] = reason; + } else { + updatedFunding.remove('reason'); + } + final updated = current.copyWith( + status: status, + updatedAt: terminal ? current.updatedAt : DateTime.now(), + other: jsonEncode({...data, fundingKey: updatedFunding}), + ); + await trades.put(current.uuid, updated); + return updated; + }); + } + + static Future _recoverFundingIntent({ + required Wallet wallet, + required Trade trade, + }) async { + final current = DB.instance.get( + boxName: DB.boxNameTradesV2, + key: trade.uuid, + ); + if (current == null) { + throw StateError('This Rosen swap is no longer available.'); + } + final funding = _funding(current); + if (funding == null || + (funding['state'] == broadcastState && + recoveryWalletId(current) == null)) { + return current; + } + final walletId = funding['walletId']; + final txid = funding['txid']; + final raw = funding['raw']; + if (walletId is! String || + txid is! String || + raw is! String || + wallet.walletId != walletId || + !canFund(wallet, current) || + current.payInTxid.toLowerCase() != txid.toLowerCase() || + !RosenProtocol.isTransactionId(txid)) { + throw const FormatException('Invalid Rosen funding journal.'); + } + Future mark(String state, {String? reason}) => _markFundingState( + trade: current, + walletId: walletId, + txid: txid, + raw: raw, + state: state, + reason: reason, + ); + if (wallet is EthereumWallet) { + if (wallet.prefs.useTor) { + throw StateError('Ethereum bridge funding is unavailable over Tor.'); + } + final client = wallet.getEthClient(); + try { + if (await client.getChainId().timeout(_rpcTimeout) != BigInt.one) { + throw StateError('The Ethereum node must use mainnet.'); + } + } finally { + await client.dispose(); + } + } + + Future transactionExists() async { + if (wallet is FiroWallet) { + try { + final found = await wallet.electrumXClient + .request( + command: 'blockchain.transaction.get', + args: [txid, false], + ) + .timeout(_rpcTimeout); + if (found is! String || + firoTransactionFromHex(found).txid.toLowerCase() != + txid.toLowerCase()) { + throw StateError('FIRO node returned another transaction.'); + } + return true; + } catch (error) { + if (error is NoSuchTransactionException || + error.toString().contains( + 'No such mempool or blockchain transaction', + )) { + return false; + } + rethrow; + } + } + final client = (wallet as EthereumWallet).getEthClient(); + try { + final transaction = await client + .getTransactionByHash(txid) + .timeout(_rpcTimeout); + if (transaction != null && + transaction.hash.toLowerCase() != txid.toLowerCase()) { + throw StateError('Ethereum node returned another transaction.'); + } + if (transaction != null) return true; + final receipt = await client + .getTransactionReceipt(txid) + .timeout(_rpcTimeout); + if (receipt == null) return false; + if (web3 + .bytesToHex(receipt.transactionHash, include0x: true) + .toLowerCase() != + txid.toLowerCase()) { + throw StateError('Ethereum node returned another receipt.'); + } + return true; + } finally { + await client.dispose(); + } + } + + if (funding['state'] == failedState) return current; + if (await transactionExists()) { + return mark(broadcastState); + } + if (funding['state'] == broadcastState) { + // Retry a dropped deposit only with its saved signed bytes. + final reopened = await mark(submittingState); + if (_funding(reopened)?['state'] != submittingState) return reopened; + } + final conflict = await _fundingConflict( + wallet: wallet, + funding: funding, + txid: txid, + ); + if (conflict == _FundingConflict.exact) { + return mark(broadcastState); + } + if (conflict == _FundingConflict.confirmed) { + if (!await transactionExists()) { + return mark(failedState); + } + return mark(broadcastState); + } + if (conflict == _FundingConflict.unknown && await transactionExists()) { + return mark(broadcastState); + } + if (conflict == _FundingConflict.pending || + conflict == _FundingConflict.unknown) { + if (funding['state'] != needsAttentionState) { + await mark(needsAttentionState, reason: 'conflict'); + } + throw StateError( + conflict == _FundingConflict.pending + ? 'Another transaction is using the saved Rosen deposit funds.' + : 'The saved Rosen deposit could not be verified.', + ); + } + if (funding['state'] == needsAttentionState) { + if (!{'fees', 'conflict'}.contains(funding['reason'])) { + throw StateError( + 'This Rosen deposit needs attention and will not be rebroadcast.', + ); + } + } + + final int sourceHeight; + if (wallet is FiroWallet) { + sourceHeight = await wallet.fetchChainHeight().timeout(_rpcTimeout); + } else { + final client = (wallet as EthereumWallet).getEthClient(); + try { + sourceHeight = await client.getBlockNumber().timeout(_rpcTimeout); + } finally { + await client.dispose(); + } + } + try { + await RosenExchange.validateFundingRecovery( + current, + sourceHeight: sourceHeight, + ); + } on ExchangeException catch (e) { + if (e.type != ExchangeExceptionType.quoteChanged) rethrow; + await mark(needsAttentionState, reason: 'fees'); + throw StateError( + 'Rosen fees changed before the saved deposit could be submitted.', + ); + } + + String response; + try { + if (wallet is FiroWallet) { + if (firoTransactionFromHex(raw).txid.toLowerCase() != + txid.toLowerCase()) { + throw const FormatException('FIRO funding journal hash mismatch.'); + } + response = await wallet.electrumXClient + .broadcastTransaction(rawTx: raw) + .timeout(_rpcTimeout); + } else { + final bytes = raw.toUint8ListFromHex; + if (web3 + .bytesToHex(web3.keccak256(bytes), include0x: true) + .toLowerCase() != + txid.toLowerCase()) { + throw const FormatException( + 'Ethereum funding journal hash mismatch.', + ); + } + final client = (wallet as EthereumWallet).getEthClient(); + try { + response = await client + .sendRawTransaction(bytes) + .timeout(_rpcTimeout); + } finally { + await client.dispose(); + } + } + } catch (e) { + if (await transactionExists()) { + return mark(broadcastState); + } + if (_requiresAttention(e)) { + await mark(needsAttentionState, reason: 'rejected'); + throw StateError('The saved Rosen deposit was rejected.'); + } + if (e is FormatException) { + await mark(needsAttentionState, reason: 'invalid'); + throw StateError( + 'The saved Rosen deposit is invalid and needs attention.', + ); + } + rethrow; + } + if (response.toLowerCase() != txid.toLowerCase() && + !await transactionExists()) { + await mark(needsAttentionState, reason: 'unexpectedResponse'); + throw StateError('The node returned an unexpected transaction ID.'); + } + return mark(broadcastState); + } + + static Future recoverFundingIntent({ + required Wallet wallet, + required Trade trade, + }) => _walletLocks + .putIfAbsent(wallet.walletId, Mutex.new) + .protect(() => _recoverFundingIntent(wallet: wallet, trade: trade)); + + static Future _recoverOtherFunding({ + required Wallet wallet, + required String tradeUuid, + }) async { + for (final trade in DB.instance.values( + boxName: DB.boxNameTradesV2, + )) { + if (trade.uuid != tradeUuid && + trade.exchangeName == RosenExchange.exchangeName && + fundingWalletId(trade) == wallet.walletId) { + try { + await _recoverFundingIntent(wallet: wallet, trade: trade); + } catch (e, s) { + Logging.instance.e( + 'A previous Rosen deposit is still pending submission', + error: e, + stackTrace: s, + ); + throw StateError( + 'A previous Rosen deposit from this wallet is still being ' + 'submitted.', + ); + } + } + } + } + + static Future refreshTrade({ + required Wallet wallet, + required Trade trade, + }) async { + // Refresh the persisted request if this screen holds an old quote. + final current = DB.instance.get( + boxName: DB.boxNameTradesV2, + key: trade.uuid, + ); + if (current == null) { + throw StateError('This Rosen swap is no longer available.'); + } + RosenExchange.requireUnfunded(current); + RosenExchange.validatedMetadata(current); + if (!canFund(wallet, current)) { + throw StateError('Choose a spendable wallet on the source network.'); + } + final int height; + if (wallet is FiroWallet) { + height = await wallet.fetchChainHeight(); + } else { + final ethereum = wallet as EthereumWallet; + if (ethereum.prefs.useTor) { + throw StateError('Ethereum bridge funding is unavailable over Tor.'); + } + final client = ethereum.getEthClient(); + try { + if (await client.getChainId() != BigInt.one) { + throw StateError('The Ethereum node must use mainnet.'); + } + height = await client.getBlockNumber(); + } finally { + await client.dispose(); + } + } + final quote = await RosenApi.instance.quote( + fromFiro: RosenExchange.isFiro(current), + amount: RosenProtocol.parseAmount(current.payInAmount), + sourceHeight: height, + ); + return RosenExchange.saveRefreshedTrade(current, quote); + } + + static Future prepareSend({ + required Wallet wallet, + required Trade trade, + }) async { + if (!canFund(wallet, trade)) { + throw StateError('Choose a spendable wallet on the source network.'); + } + final metadata = RosenExchange.validatedMetadata(trade); + final amount = Amount( + rawValue: RosenProtocol.parseAmount(trade.payInAmount), + fractionDigits: DefaultTokens.rsFiro.decimals, + ); + final data = TxData( + recipients: [ + TxRecipient( + address: trade.payInAddress, + amount: amount, + isChange: false, + addressType: wallet.cryptoCurrency.getAddressType( + trade.payInAddress, + )!, + ), + ], + feeRateType: FeeRateType.average, + ); + if (wallet is FiroWallet) { + await RosenExchange.validateFunding( + trade, + sourceHeight: await wallet.fetchChainHeight(), + ); + final prepared = await wallet.prepareSend( + txData: data.copyWith(opReturnData: metadata), + ); + // Transparent send-all subtracts the fee; the deposit must stay exact. + if (prepared.amountWithoutChange != amount) { + throw StateError( + 'Leave enough transparent FIRO to pay the mining fee.', + ); + } + return prepared; + } + + final ethereum = wallet as EthereumWallet; + if (ethereum.prefs.useTor) { + throw StateError('Ethereum bridge funding is unavailable over Tor.'); + } + final client = ethereum.getEthClient(); + try { + final sender = await ethereum.getMyWeb3Address(); + final prep = await ethereum.internalSharedPrepareSend( + txData: data, + myWeb3Address: sender, + ); + if (prep.chainId != BigInt.one) { + throw StateError('The Ethereum node must use mainnet.'); + } + await RosenExchange.validateFunding( + trade, + sourceHeight: await client.getBlockNumber(), + ); + final contract = RosenProtocol.tokenContract; + final decimals = await client.call( + contract: contract, + function: contract.function('decimals'), + params: [], + ); + if (decimals.single != BigInt.from(DefaultTokens.rsFiro.decimals)) { + throw StateError('Unexpected rsFIRO token precision.'); + } + final calldata = RosenProtocol.transferData( + lockAddress: RosenApi.ethereumLockAddress, + amount: amount.raw, + metadata: metadata, + ).toUint8ListFromHex; + final tokenAddress = eth.EthereumAddress.fromHex( + DefaultTokens.rsFiro.address, + ); + await _validatePendingTransfer(client, sender, tokenAddress, calldata); + final gas = await client.estimateGas( + sender: sender, + to: tokenAddress, + data: calldata, + ); + final gasLimit = + (gas * BigInt.from(120) + BigInt.from(99)) ~/ BigInt.from(100); + final maxFee = prep.maxFeePerGas; + final fee = Amount(rawValue: gasLimit * maxFee, fractionDigits: 18); + final ethBalance = await client.getBalance( + sender, + atBlock: const web3.BlockNum.pending(), + ); + if (ethBalance.getInWei < fee.raw) { + throw StateError('Insufficient ETH for the bridge transaction gas.'); + } + await registerToken(ethereum); + return data.copyWith( + fee: fee, + chainId: prep.chainId, + nonce: prep.nonce, + web3dartTransaction: web3.Transaction( + to: tokenAddress, + data: calldata, + value: eth.EtherAmount.zero(), + maxGas: gasLimit.toInt(), + nonce: prep.nonce, + maxFeePerGas: eth.EtherAmount.inWei(maxFee), + maxPriorityFeePerGas: eth.EtherAmount.inWei( + prep.maxPriorityFeePerGas, + ), + ), + ); + } finally { + await client.dispose(); + } + } + + static Future confirmSend({ + required Wallet wallet, + required Trade trade, + required TxData txData, + }) async { + if (!canFund(wallet, trade)) { + throw StateError('Invalid bridge source wallet.'); + } + final lock = _walletLocks.putIfAbsent(wallet.walletId, Mutex.new); + return lock.protect(() async { + String? intentTxid; + String? intentRaw; + String? firoTxid; + int? ethereumNonce; + int? fundingHeight; + List>? firoInputs; + Future beforeBroadcast(String raw) async { + final txid = + firoTxid ?? + web3.bytesToHex( + web3.keccak256(raw.toUint8ListFromHex), + include0x: true, + ); + await recordFundingIntent( + trade: trade, + walletId: wallet.walletId, + txid: txid, + raw: raw, + nonce: ethereumNonce, + sourceHeight: fundingHeight, + firoInputs: firoInputs, + ); + intentTxid = txid; + intentRaw = raw.toLowerCase(); + } + + try { + await _recoverOtherFunding(wallet: wallet, tradeUuid: trade.uuid); + final current = RosenExchange.currentUnfunded(trade); + if (!canFund(wallet, current)) { + throw StateError('Invalid bridge source wallet.'); + } + final metadata = RosenExchange.validatedMetadata(current); + final amount = RosenProtocol.parseAmount(current.payInAmount); + if (txData.amountWithoutChange?.raw != amount || + txData.recipients?.where((e) => !e.isChange).length != 1 || + txData.recipients!.firstWhere((e) => !e.isChange).address != + current.payInAddress) { + throw StateError('The transaction does not match this bridge swap.'); + } + + TxData sent; + if (wallet is FiroWallet) { + if (txData.opReturnData != metadata) { + throw StateError('Missing Rosen OP_RETURN.'); + } + final inputs = txData.usedUTXOs; + if (inputs == null || + inputs.isEmpty || + inputs.any((input) => input is! StandardInput) || + txData.fee == null) { + throw StateError('Invalid FIRO bridge transaction.'); + } + final standardInputs = inputs.cast(); + firoTxid = verifyFiroOpReturnTransaction( + raw: txData.raw ?? '', + data: metadata, + expectedInputs: standardInputs.map( + (input) => ( + txid: input.utxo.txid, + vout: input.utxo.vout, + value: input.value, + ), + ), + expectedOutputs: txData.recipients!.map( + (output) => ( + script: RosenProtocol.firoScript(output.address), + value: output.amount.raw, + ), + ), + expectedFee: txData.fee!.raw, + ); + fundingHeight = await wallet.fetchChainHeight().timeout(_rpcTimeout); + await RosenExchange.validateFunding( + current, + sourceHeight: fundingHeight, + ); + final available = {}; + for (final address in standardInputs.map((e) => e.address).toSet()) { + if (address == null) { + throw StateError('Invalid FIRO bridge transaction input.'); + } + final utxos = await wallet.electrumXClient + .getUTXOs( + scripthash: wallet.cryptoCurrency.addressToScriptHash( + address: address, + ), + ) + .timeout(_rpcTimeout); + for (final utxo in utxos) { + final value = BigInt.tryParse(utxo['value'].toString()); + if (value == null) { + throw StateError('Invalid FIRO transaction input value.'); + } + available['${utxo['tx_hash'].toString().toLowerCase()}:' + '${utxo['tx_pos']}'] = + value; + } + } + if (standardInputs.any((input) { + final key = '${input.utxo.txid.toLowerCase()}:${input.utxo.vout}'; + return available[key] != input.value; + })) { + throw StateError( + 'The FIRO transaction inputs changed. Prepare this swap again.', + ); + } + firoInputs = standardInputs + .map( + (input) => { + 'txid': input.utxo.txid.toLowerCase(), + 'vout': input.utxo.vout, + 'scripthash': wallet.cryptoCurrency.addressToScriptHash( + address: input.address!, + ), + }, + ) + .toList(); + sent = await wallet.confirmSend( + txData: txData, + beforeBroadcast: beforeBroadcast, + ); + } else { + final ethereum = wallet as EthereumWallet; + if (ethereum.prefs.useTor) { + throw StateError( + 'Ethereum bridge funding is unavailable over Tor.', + ); + } + final tx = txData.web3dartTransaction; + final expected = RosenProtocol.transferData( + lockAddress: RosenApi.ethereumLockAddress, + amount: amount, + metadata: metadata, + ); + final maxGas = tx?.maxGas; + final maxFee = tx?.maxFeePerGas?.getInWei; + final priorityFee = tx?.maxPriorityFeePerGas?.getInWei; + if (tx == null || + txData.chainId != BigInt.one || + tx.to?.with0x.toLowerCase() != DefaultTokens.rsFiro.address || + tx.value?.getInWei != BigInt.zero || + tx.data?.toHex != expected || + tx.gasPrice != null || + maxGas == null || + maxGas <= 0 || + tx.nonce == null || + tx.nonce != txData.nonce || + maxFee == null || + maxFee <= BigInt.zero || + priorityFee == null || + priorityFee.isNegative || + priorityFee > maxFee || + txData.fee?.fractionDigits != 18 || + txData.fee?.raw != BigInt.from(maxGas) * maxFee) { + throw StateError('Invalid rsFIRO bridge transaction.'); + } + ethereumNonce = tx.nonce; + final client = ethereum.getEthClient(); + try { + if (await client.getChainId().timeout(_rpcTimeout) != BigInt.one) { + throw StateError('The Ethereum node must use mainnet.'); + } + fundingHeight = await client.getBlockNumber().timeout(_rpcTimeout); + await RosenExchange.validateFunding( + current, + sourceHeight: fundingHeight, + ); + final sender = await ethereum.getMyWeb3Address(); + if (tx.from != null && tx.from != sender) { + throw StateError('Invalid rsFIRO bridge transaction sender.'); + } + if (tx.nonce != + await client + .getTransactionCount( + sender, + atBlock: const web3.BlockNum.pending(), + ) + .timeout(_rpcTimeout)) { + throw StateError( + 'The wallet nonce changed. Prepare this swap again.', + ); + } + final ethBalance = await client + .getBalance(sender, atBlock: const web3.BlockNum.pending()) + .timeout(_rpcTimeout); + if (ethBalance.getInWei < txData.fee!.raw) { + throw StateError( + 'Insufficient ETH for the bridge transaction gas.', + ); + } + await _validatePendingTransfer(client, sender, tx.to!, tx.data!); + } finally { + await client.dispose(); + } + // Record token history and its contract instead of an ETH payment. + final tokenWallet = Wallet.loadTokenWallet( + ethWallet: ethereum, + contract: _tokenFor(wallet), + ) as EthTokenWallet; + sent = await tokenWallet.confirmSend( + txData: txData, + beforeBroadcast: beforeBroadcast, + ); + } + if (intentTxid == null || + sent.txid?.toLowerCase() != intentTxid!.toLowerCase()) { + throw StateError('The broadcast transaction ID changed.'); + } + await _markFundingState( + trade: current, + walletId: wallet.walletId, + txid: intentTxid!, + raw: intentRaw!, + state: broadcastState, + ); + return sent; + } catch (e, s) { + if (intentTxid == null) rethrow; + String? fundingState; + try { + final recovered = await _recoverFundingIntent( + wallet: wallet, + trade: trade, + ); + fundingState = _funding(recovered)?['state'] as String?; + } catch (recoveryError, recoveryStack) { + final latest = DB.instance.get( + boxName: DB.boxNameTradesV2, + key: trade.uuid, + ); + fundingState = latest == null + ? null + : _funding(latest)?['state'] as String?; + Logging.instance.e( + 'Rosen transaction $intentTxid remains pending recovery', + error: recoveryError, + stackTrace: recoveryStack, + ); + } + Logging.instance.e( + 'Rosen transaction $intentTxid was recorded before broadcast; ' + 'the network or local update result is uncertain', + error: e, + stackTrace: s, + ); + if (fundingState == failedState) { + throw StateError('The saved Rosen deposit was rejected.'); + } + if (fundingState == needsAttentionState) { + throw StateError( + 'The saved Rosen deposit needs attention and was not ' + 'rebroadcast.', + ); + } + return txData.copyWith(txid: intentTxid, txHash: intentTxid); + } + }); + } +} diff --git a/lib/services/exchange/rosen/rosen_protocol.dart b/lib/services/exchange/rosen/rosen_protocol.dart new file mode 100644 index 0000000000..fb12d66f80 --- /dev/null +++ b/lib/services/exchange/rosen/rosen_protocol.dart @@ -0,0 +1,133 @@ +import 'package:coinlib/coinlib.dart' as coinlib; +import 'package:wallet/wallet.dart' show EthereumAddress; +import 'package:web3dart/web3dart.dart' as web3; + +import '../../../utilities/default_eth_tokens.dart'; +import '../../../utilities/extensions/extensions.dart'; +import '../../../wallets/crypto_currency/crypto_currency.dart'; + +/// Rosen's v1 metadata, shared by FIRO OP_RETURN and appended ERC-20 calldata. +/// https://github.com/rosen-bridge/ui/tree/dev/networks/firo/src/utils.ts +class RosenProtocol { + static final maxAmount = (BigInt.one << 64) - BigInt.one; + + static final tokenContract = web3.DeployedContract( + web3.ContractAbi.fromJson('''[ + {"type":"function","name":"decimals","stateMutability":"view","inputs":[],"outputs":[{"name":"","type":"uint8"}]}, + {"type":"function","name":"transfer","stateMutability":"nonpayable","inputs":[{"name":"to","type":"address"},{"name":"amount","type":"uint256"}],"outputs":[{"name":"","type":"bool"}]} + ]''', DefaultTokens.rsFiro.name), + EthereumAddress.fromHex(DefaultTokens.rsFiro.address), + ); + + static bool isTransactionId(String? txid) => + txid != null && RegExp(r'^(0x)?[0-9a-fA-F]{64}$').hasMatch(txid); + + static String swapStatus( + Object? status, + String? payoutTxid, { + Object? flowStatuses, + }) { + final value = status is Map ? status['status'] : status; + final normalized = value is String ? value.toLowerCase() : ''; + // Fraud belongs to one flow; a later flow can still complete this event. + final completed = + {'completed', 'successful'}.contains(normalized) || + (normalized == 'multiple_flows' && + flowStatuses is List && + flowStatuses.contains('successful')); + return completed + ? isTransactionId(payoutTxid) + ? 'Finished' + : 'Sending' + : 'Exchanging'; + } + + static BigInt parseAmount(String value) { + if (!RegExp(r'^\d+(\.\d{1,8})?$').hasMatch(value)) { + throw const FormatException( + 'Use an amount with at most 8 decimal places.', + ); + } + final parts = value.split('.'); + final amount = + BigInt.parse(parts[0]) * BigInt.from(100000000) + + BigInt.parse(parts.length == 1 ? '0' : parts[1].padRight(8, '0')); + if (amount > maxAmount) throw const FormatException('Amount is too large.'); + return amount; + } + + static String formatAmount(BigInt value) { + if (value.isNegative) throw ArgumentError.value(value, 'value'); + final padded = value.toString().padLeft(9, '0'); + final fraction = padded + .substring(padded.length - 8) + .replaceFirst(RegExp(r'0+$'), ''); + return '${padded.substring(0, padded.length - 8)}${fraction.isEmpty ? '' : '.$fraction'}'; + } + + static String _uint(BigInt value, int bytes) { + if (value.isNegative || value >= BigInt.one << (bytes * 8)) { + throw ArgumentError( + 'Value does not fit an unsigned $bytes-byte integer.', + ); + } + return value.toRadixString(16).padLeft(bytes * 2, '0'); + } + + static String ethereumAddress(String address) { + if (!RegExp(r'^0x[0-9a-fA-F]{40}$').hasMatch(address) || + BigInt.parse(address.substring(2), radix: 16) == BigInt.zero) { + throw const FormatException('Enter a valid Ethereum mainnet address.'); + } + return EthereumAddress.fromHex(address, enforceEip55: true).without0x; + } + + /// address-codec encodes FIRO as its output script, not its Base58 payload. + static String firoScript(String address) { + try { + final program = coinlib.Address.fromString( + address, + Firo(CryptoCurrencyNetwork.main).networkParams, + ).program; + if (program is coinlib.P2PKH || program is coinlib.P2SH) { + return program.script.compiled.toHex; + } + } on Exception { + // Normalize malformed and wrong-network address errors. + } + throw const FormatException('Use a transparent FIRO mainnet address.'); + } + + static String metadata({ + required bool fromFiro, + required String destination, + required BigInt bridgeFee, + required BigInt networkFee, + }) { + final address = fromFiro + ? ethereumAddress(destination) + : firoScript(destination); + return '${fromFiro ? '03' : '07'}' + '${_uint(bridgeFee, 8)}${_uint(networkFee, 8)}' + '${_uint(BigInt.from(address.length ~/ 2), 1)}$address'; + } + + static String transferData({ + required String lockAddress, + required BigInt amount, + required String metadata, + }) { + if (amount <= BigInt.zero || amount > maxAmount) { + throw ArgumentError('Invalid rsFIRO amount.'); + } + if (metadata.length.isOdd || + !RegExp(r'^[0-9a-fA-F]*$').hasMatch(metadata)) { + throw const FormatException('Invalid hexadecimal data.'); + } + final encoded = tokenContract.function('transfer').encodeCall([ + EthereumAddress.fromHex('0x${ethereumAddress(lockAddress)}'), + amount, + ]); + return '${encoded.toHex}${metadata.toUint8ListFromHex.toHex}'; + } +} diff --git a/lib/services/notifications_service.dart b/lib/services/notifications_service.dart index fa7575d314..9c4a3ad751 100644 --- a/lib/services/notifications_service.dart +++ b/lib/services/notifications_service.dart @@ -24,6 +24,8 @@ import '../wallets/crypto_currency/crypto_currency.dart'; import '../wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; import 'exchange/exchange.dart'; import 'exchange/exchange_response.dart'; +import 'exchange/rosen/rosen_exchange.dart'; +import 'exchange/rosen/rosen_funding.dart'; import 'node_service.dart'; import 'notifications_api.dart'; import 'trade_service.dart'; @@ -300,6 +302,25 @@ class NotificationsService extends ChangeNotifier { await tradesService.edit(trade: trade, shouldNotifyListeners: true); } } + + // A stalled bridge node must not delay the existing provider updates. + for (final trade in tradesService.trades) { + if (trade.exchangeName != RosenExchange.exchangeName) continue; + try { + final walletId = RosenFunding.recoveryWalletId(trade); + if (walletId == null) continue; + await RosenFunding.recoverFundingIntent( + wallet: Wallets.sharedInstance.getWallet(walletId), + trade: trade, + ); + } catch (e, s) { + Logging.instance.e( + 'Rosen funding recovery remains pending', + error: e, + stackTrace: s, + ); + } + } } bool get hasUnreadNotifications { diff --git a/lib/services/trade_service.dart b/lib/services/trade_service.dart index e15216a337..19f45b557d 100644 --- a/lib/services/trade_service.dart +++ b/lib/services/trade_service.dart @@ -12,8 +12,12 @@ import 'package:flutter/cupertino.dart'; import '../db/hive/db.dart'; import '../models/exchange/response_objects/trade.dart'; +import 'exchange/rosen/rosen_exchange.dart'; class TradesService extends ChangeNotifier { + /// Trade getters read the DB directly; notify after an atomic bridge refresh. + void refresh() => notifyListeners(); + List get trades { final list = DB.instance.values(boxName: DB.boxNameTradesV2); list.sort( @@ -38,11 +42,24 @@ class TradesService extends ChangeNotifier { required Trade trade, required bool shouldNotifyListeners, }) async { - await DB.instance.put( - boxName: DB.boxNameTradesV2, - key: trade.uuid, - value: trade, - ); + if (trade.exchangeName == RosenExchange.exchangeName) { + final db = DB.instance; + await db.mutex.protect(() async { + final box = db.hive.box(DB.boxNameTradesV2); + // Keep signed deposits, including newer backups of unfunded swaps. + final current = box.get(trade.uuid); + if (current == null || + (current.payInTxid.isEmpty && trade.payInTxid.isNotEmpty)) { + await box.put(trade.uuid, trade); + } + }); + } else { + await DB.instance.put( + boxName: DB.boxNameTradesV2, + key: trade.uuid, + value: trade, + ); + } if (shouldNotifyListeners) { notifyListeners(); @@ -53,6 +70,60 @@ class TradesService extends ChangeNotifier { required Trade trade, required bool shouldNotifyListeners, }) async { + if (trade.exchangeName == RosenExchange.exchangeName) { + final db = DB.instance; + // Read and merge under the same lock as writes: a poll can finish after funding. + await db.mutex.protect(() async { + final box = db.hive.box(DB.boxNameTradesV2); + final current = box.get(trade.uuid); + if (current == null) { + throw StateError("Cannot edit a swap that does not exist."); + } + // Quote refreshes have a compare-and-save path. Polls and funding updates + // must keep the persisted request that was actually shown or broadcast. + var updated = current.copyWith( + status: trade.status, + payInTxid: trade.payInTxid, + payOutTxid: trade.payOutTxid, + updatedAt: trade.updatedAt.isBefore(current.updatedAt) + ? current.updatedAt + : trade.updatedAt, + ); + if (current.payInTxid.isNotEmpty) { + updated = updated.copyWith( + payInTxid: current.payInTxid, + status: + const ["new", "waiting"].contains(updated.status.toLowerCase()) + ? current.status + : updated.status, + payOutTxid: updated.payOutTxid.isEmpty + ? current.payOutTxid + : updated.payOutTxid, + ); + } + if ({ + 'verifying', + 'exchanging', + 'sending', + }.contains(current.status.toLowerCase()) && + updated.status.toLowerCase() == 'confirming') { + updated = updated.copyWith(status: current.status); + } + if (const [ + "finished", + "failed", + ].contains(current.status.toLowerCase()) && + !const [ + "finished", + "failed", + ].contains(updated.status.toLowerCase())) { + updated = current; + } + await box.put(trade.uuid, updated); + }); + if (shouldNotifyListeners) notifyListeners(); + return; + } if (DB.instance.get(boxName: DB.boxNameTradesV2, key: trade.uuid) == null) { throw Exception("Attempted to edit a trade that does not exist in Hive!"); diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index f5444c7428..ee23f20ef5 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -683,6 +683,7 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { Future confirmSend({ required TxData txData, TxData Function(TxData txData, String myAddress)? prepareTempTx, + Future Function(String raw)? beforeBroadcast, }) async { final client = getEthClient(); if (_credentials == null) { @@ -690,11 +691,33 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { } try { - final txid = await client.sendTransaction( - _credentials!, - txData.web3dartTransaction!, - chainId: txData.chainId!.toInt(), - ); + final String txid; + if (beforeBroadcast == null) { + txid = await client.sendTransaction( + _credentials!, + txData.web3dartTransaction!, + chainId: txData.chainId!.toInt(), + ); + } else { + var signed = await client.signTransaction( + _credentials!, + txData.web3dartTransaction!, + chainId: txData.chainId!.toInt(), + ); + if (txData.web3dartTransaction!.isEIP1559) { + signed = web3.prependTransactionType(0x02, signed); + } + txid = web3.bytesToHex(web3.keccak256(signed), include0x: true); + await beforeBroadcast(web3.bytesToHex(signed, include0x: true)); + final response = await client + .sendRawTransaction(signed) + .timeout(const Duration(seconds: 30)); + if (response.toLowerCase() != txid) { + throw StateError( + 'Ethereum node returned an unexpected transaction ID.', + ); + } + } final data = (prepareTempTx ?? _prepareTempTx)( txData.copyWith(txid: txid, txHash: txid), diff --git a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart index 1c61f297b4..ce59a59095 100644 --- a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart @@ -272,17 +272,14 @@ class EthTokenWallet extends Wallet { } @override - Future confirmSend({required TxData txData}) async { - try { - return await ethWallet.confirmSend( - txData: txData, - prepareTempTx: _prepareTempTx, - ); - } catch (e) { - // rethrow to pass error in alert - rethrow; - } - } + Future confirmSend({ + required TxData txData, + Future Function(String raw)? beforeBroadcast, + }) => ethWallet.confirmSend( + txData: txData, + prepareTempTx: _prepareTempTx, + beforeBroadcast: beforeBroadcast, + ); @override Future estimateFeeFor(Amount amount, BigInt feeRate) async { diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index f2d5eb05b9..948a51d0c0 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -35,6 +35,7 @@ import '../impl/peercoin_wallet.dart'; import '../intermediate/bip39_hd_wallet.dart'; import 'cpfp_interface.dart'; import 'electrum_fee_planner.dart'; +import 'firo_op_return.dart'; import 'mweb_fee_utils.dart'; import 'mweb_interface.dart'; import 'paynym_interface.dart'; @@ -563,7 +564,7 @@ mixin ElectrumXInterface final coinlib.CoinSelection selection = coinlib.CoinSelection.optimal( candidates: candidates, - recipients: [recipientOutput], + recipients: [recipientOutput, ?_opReturnOutput(txData)], changeProgram: changeProgram, feePerKb: feePerKb, minFee: minFee, @@ -707,12 +708,22 @@ mixin ElectrumXInterface } } + coinlib.Output? _opReturnOutput(TxData txData) { + final hex = txData.opReturnData; + if (hex == null || hex.isEmpty) return null; + if (cryptoCurrency is! Firo) { + throw UnsupportedError('OP_RETURN sends are only supported for Firo'); + } + return firoOpReturnOutput(hex); + } + /// Builds and signs a transaction Future buildTransaction({ required TxData txData, required List inputsWithKeys, }) async { Logging.instance.d("Starting buildTransaction ----------"); + final opReturnOutput = _opReturnOutput(txData); // temp tx data to show in gui while waiting for real data from server final List tempInputs = []; @@ -870,62 +881,16 @@ mixin ElectrumXInterface ); } - // Add OP_RETURN output if provided (for Rosen Bridge and other protocols) - // Currently only supported for Firo - if (cryptoCurrency is Firo && - txData.opReturnData != null && - txData.opReturnData!.isNotEmpty) { - try { - final opReturnBytes = txData.opReturnData!.toUint8ListFromHex; - - // Validate OP_RETURN size (Bitcoin/Firo limit is 80 bytes) - if (opReturnBytes.length > 80) { - throw Exception( - "OP_RETURN data exceeds 80 byte limit: ${opReturnBytes.length} bytes", - ); - } - - // Encode push data: OP_PUSHDATA1 (0x4c) for 76-80 bytes, direct length otherwise - final pushData = opReturnBytes.length <= 75 - ? Uint8List.fromList([opReturnBytes.length, ...opReturnBytes]) - : Uint8List.fromList([ - 0x4c, - opReturnBytes.length, - ...opReturnBytes, - ]); - - final opReturnScript = Uint8List.fromList([ - 0x6a, // OP_RETURN opcode - ...pushData, - ]); - - final opReturnOutput = coinlib.Output.fromScriptBytes( - BigInt.zero, // OP_RETURN outputs have 0 value - opReturnScript, - ); - - clTx = clTx.addOutput(opReturnOutput); - - Logging.instance.i( - "Added OP_RETURN output with ${opReturnBytes.length} bytes of data", - ); - - tempOutputs.add( - OutputV2.isarCantDoRequiredInDefaultConstructor( - scriptPubKeyHex: opReturnScript.toHex, - valueStringSats: "0", - addresses: [], - walletOwns: false, - ), - ); - } catch (e, s) { - Logging.instance.e( - "Failed to add OP_RETURN output", - error: e, - stackTrace: s, - ); - throw Exception("Invalid OP_RETURN data: $e"); - } + if (opReturnOutput != null) { + clTx = clTx.addOutput(opReturnOutput); + tempOutputs.add( + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: opReturnOutput.scriptPubKey.toHex, + valueStringSats: "0", + addresses: [], + walletOwns: false, + ), + ); } if (isMweb) { if (hasNonWitnessInput) { @@ -1863,13 +1828,20 @@ mixin ElectrumXInterface } @override - Future confirmSend({required TxData txData}) async { + Future confirmSend({ + required TxData txData, + Future Function(String raw)? beforeBroadcast, + }) async { try { Logging.instance.d("confirmSend txData: $txData"); - final txHash = await electrumXClient.broadcastTransaction( + if (beforeBroadcast != null) await beforeBroadcast(txData.raw!); + final broadcasting = electrumXClient.broadcastTransaction( rawTx: txData.raw!, ); + final txHash = await (beforeBroadcast == null + ? broadcasting + : broadcasting.timeout(const Duration(seconds: 30))); Logging.instance.d("Sent txHash: $txHash"); txData = txData.copyWith( diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart b/lib/wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart new file mode 100644 index 0000000000..7f578ca33e --- /dev/null +++ b/lib/wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart @@ -0,0 +1,320 @@ +import 'dart:typed_data'; + +import 'package:coinlib/coinlib.dart' as coinlib; + +typedef FiroTransactionInput = ({String txid, int vout, BigInt value}); +typedef FiroTransactionOutput = ({String script, BigInt value}); +typedef FiroTransactionPrevout = ({String txid, int vout}); +typedef FiroDecodedTransaction = ({ + String txid, + List prevouts, +}); + +typedef _ParsedFiroTransaction = ({ + coinlib.Transaction transaction, + bool isWitness, + List prevouts, +}); + +final _secp256k1Field = + (BigInt.one << 256) - (BigInt.one << 32) - BigInt.from(977); +final _secp256k1Order = BigInt.parse( + 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141', + radix: 16, +); + +/// The same zero-value output must be used for fee selection and signing. +coinlib.Output firoOpReturnOutput(String hex) { + if (hex.startsWith('0x')) hex = hex.substring(2); + if (hex.isEmpty || + hex.length.isOdd || + !RegExp(r'^[0-9a-fA-F]+$').hasMatch(hex)) { + throw const FormatException('Invalid OP_RETURN hex'); + } + if (hex.length > 160) { + throw const FormatException('OP_RETURN data exceeds 80 byte limit'); + } + + final bytes = coinlib.hexToBytes(hex); + return coinlib.Output.fromScriptBytes( + BigInt.zero, + Uint8List.fromList([ + 0x6a, + if (bytes.length > 75) 0x4c, + bytes.length, + ...bytes, + ]), + ); +} + +/// Verify the signed bytes that will be broadcast, not only the TxData fields. +/// [expectedOutputs] contains every payment/change output; [data] is appended. +/// Returns the transaction ID derived from [raw] after successful validation. +String verifyFiroOpReturnTransaction({ + required String raw, + required String data, + required Iterable expectedInputs, + required Iterable expectedOutputs, + required BigInt expectedFee, +}) { + late final _ParsedFiroTransaction parsed; + try { + parsed = _parseFiroTransaction(raw); + } on FormatException { + throw StateError( + 'The signed FIRO transaction does not match this bridge swap.', + ); + } + final tx = parsed.transaction; + final inputs = expectedInputs.toList(); + final outputs = [ + for (final output in expectedOutputs) + coinlib.Output.fromScriptBytes( + output.value, + coinlib.hexToBytes(output.script), + ), + firoOpReturnOutput(data), + ]; + final expectedInputKeys = [ + for (final input in inputs) + coinlib.OutPoint.fromHex(input.txid, input.vout).toHex(), + ]; + final actualInputKeys = [ + for (final input in tx.inputs) input.prevOut.toHex(), + ]; + final expectedOutputKeys = [for (final output in outputs) output.toHex()]; + final actualOutputKeys = [for (final output in tx.outputs) output.toHex()]; + final inputValue = inputs.fold( + BigInt.zero, + (sum, input) => sum + input.value, + ); + final outputValue = tx.outputs.fold( + BigInt.zero, + (sum, output) => sum + output.value, + ); + if (tx.version != 1 || + parsed.isWitness || + tx.inputs.any((input) => !_isStructurallySignedP2pkh(input.scriptSig)) || + !_sameItems(expectedInputKeys, actualInputKeys) || + !_sameItems(expectedOutputKeys, actualOutputKeys) || + expectedFee.isNegative || + inputValue - outputValue != expectedFee) { + throw StateError( + 'The signed FIRO transaction does not match this bridge swap.', + ); + } + return tx.txid; +} + +/// Decodes a FIRO transaction and derives its standard no-witness transaction +/// ID and input outpoints from the raw bytes. +FiroDecodedTransaction decodeFiroTransaction(String raw) { + final parsed = _parseFiroTransaction(raw); + return (txid: parsed.transaction.txid, prevouts: parsed.prevouts); +} + +/// Decode the non-witness FIRO transaction layout used by bridge sends. +coinlib.Transaction firoTransactionFromHex(String raw) { + final parsed = _parseFiroTransaction(raw); + if (parsed.isWitness) { + throw const FormatException('Invalid FIRO bridge transaction.'); + } + return parsed.transaction; +} + +// ponytail: pinned coinlib misreads legacy flags; replace after its decoder fix. +_ParsedFiroTransaction _parseFiroTransaction(String raw) { + try { + final reader = coinlib.BytesReader(coinlib.hexToBytes(raw)); + final packedVersion = reader.readInt32(); + var inputCount = _readCompactSize(reader); + var isWitness = false; + if (inputCount == BigInt.zero) { + final flags = reader.readUInt8(); + if (flags != 1) { + throw const FormatException('Invalid FIRO transaction flags.'); + } + isWitness = true; + inputCount = _readCompactSize(reader); + } + final rawInputs = List.generate( + _checkedCount(reader, inputCount, 41), + (_) => _readRawInput(reader), + ); + final outputs = List.generate( + _checkedCount(reader, _readCompactSize(reader), 9), + (_) => _readOutput(reader), + ); + if (isWitness) { + for (var i = 0; i < rawInputs.length; i++) { + final itemCount = _checkedCount( + reader, + _readCompactSize(reader), + 1, + allowZero: true, + ); + for (var j = 0; j < itemCount; j++) { + _readVarSlice(reader); + } + } + } + final locktime = reader.readUInt32(); + final version = packedVersion & 0xffff; + final type = (packedVersion >> 16) & 0xffff; + final extraPayload = version == 3 && type != 0 + ? _readVarSlice(reader) + : null; + if (!reader.atEnd) { + throw const FormatException('Invalid FIRO transaction.'); + } + + final transaction = coinlib.Transaction( + version: packedVersion, + inputs: rawInputs, + outputs: outputs, + locktime: locktime, + vExtraData: extraPayload, + ); + return ( + transaction: transaction, + isWitness: isWitness, + prevouts: List.unmodifiable([ + for (final input in rawInputs) + ( + txid: coinlib.bytesToHex( + Uint8List.fromList(input.prevOut.hash.reversed.toList()), + ), + vout: input.prevOut.n, + ), + ]), + ); + } on FormatException { + rethrow; + } on Exception { + throw const FormatException('Invalid FIRO transaction.'); + } +} + +BigInt _readCompactSize(coinlib.BytesReader reader) { + final first = reader.readUInt8(); + if (first < 0xfd) return BigInt.from(first); + final value = switch (first) { + 0xfd => BigInt.from(reader.readUInt16()), + 0xfe => BigInt.from(reader.readUInt32()), + _ => reader.readUInt64(), + }; + if ((first == 0xfd && value < BigInt.from(0xfd)) || + (first == 0xfe && value <= BigInt.from(0xffff)) || + (first == 0xff && value <= BigInt.from(0xffffffff))) { + throw const FormatException('Non-canonical FIRO compact size.'); + } + return value; +} + +Uint8List _readVarSlice(coinlib.BytesReader reader) => reader.readSlice( + _checkedCount(reader, _readCompactSize(reader), 1, allowZero: true), +); + +coinlib.RawInput _readRawInput(coinlib.BytesReader reader) => coinlib.RawInput( + prevOut: coinlib.OutPoint.fromReader(reader), + scriptSig: _readVarSlice(reader), + sequence: reader.readUInt32(), +); + +coinlib.Output _readOutput(coinlib.BytesReader reader) => + coinlib.Output.fromScriptBytes(reader.readUInt64(), _readVarSlice(reader)); + +int _checkedCount( + coinlib.BytesReader reader, + BigInt value, + int minimumSize, { + bool allowZero = false, +}) { + final remaining = reader.bytes.lengthInBytes - reader.offset; + if ((!allowZero && value == BigInt.zero) || + value > BigInt.from(remaining ~/ minimumSize)) { + throw const FormatException('Invalid FIRO transaction item count.'); + } + return value.toInt(); +} + +bool _sameItems(List first, List second) { + if (first.length != second.length) return false; + first.sort(); + second.sort(); + for (var i = 0; i < first.length; i++) { + if (first[i] != second[i]) return false; + } + return true; +} + +// Checks BIP66/P2PKH shape without claiming to verify a signature without the +// previous output script. +bool _isStructurallySignedP2pkh(Uint8List script) { + try { + final reader = coinlib.BytesReader(script); + final signature = reader.readVarSlice(); + final publicKey = reader.readVarSlice(); + return reader.atEnd && + _isCanonicalSignature(signature) && + _isSecp256k1Point(publicKey); + } on Exception { + return false; + } +} + +bool _isCanonicalSignature(Uint8List signature) { + if (signature.length < 9 || + signature.length > 73 || + signature[0] != 0x30 || + signature[1] != signature.length - 3 || + signature[2] != 0x02 || + signature.last != 1) { + return false; + } + final rLength = signature[3]; + final sType = 4 + rLength; + if (!_isCanonicalScalar(signature, 4, rLength) || + sType + 2 >= signature.length - 1 || + signature[sType] != 0x02) { + return false; + } + final sLength = signature[sType + 1]; + final sStart = sType + 2; + return sStart + sLength == signature.length - 1 && + _isCanonicalScalar(signature, sStart, sLength); +} + +bool _isCanonicalScalar(Uint8List bytes, int start, int length) { + if (length == 0 || + start + length >= bytes.length || + (bytes[start] & 0x80) != 0 || + (length > 1 && bytes[start] == 0 && (bytes[start + 1] & 0x80) == 0)) { + return false; + } + final value = BigInt.parse( + coinlib.bytesToHex(bytes.sublist(start, start + length)), + radix: 16, + ); + return value > BigInt.zero && value < _secp256k1Order; +} + +bool _isSecp256k1Point(Uint8List key) { + final compressed = key.length == 33 && (key.first == 2 || key.first == 3); + final uncompressed = key.length == 65 && key.first == 4; + if (!compressed && !uncompressed) return false; + final x = BigInt.parse(coinlib.bytesToHex(key.sublist(1, 33)), radix: 16); + if (x >= _secp256k1Field) return false; + final ySquared = + (x.modPow(BigInt.from(3), _secp256k1Field) + BigInt.from(7)) % + _secp256k1Field; + if (compressed) { + final y = ySquared.modPow( + (_secp256k1Field + BigInt.one) >> 2, + _secp256k1Field, + ); + return y * y % _secp256k1Field == ySquared; + } + final y = BigInt.parse(coinlib.bytesToHex(key.sublist(33)), radix: 16); + return y < _secp256k1Field && y * y % _secp256k1Field == ySquared; +} diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart index 8729d02ac6..4dff065221 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart @@ -728,12 +728,17 @@ mixin MwebInterface // =========================================================================== @override - Future confirmSend({required TxData txData}) async { + Future confirmSend({ + required TxData txData, + Future Function(String raw)? beforeBroadcast, + }) { if (txData.type.isMweb()) { - return await _confirmSendMweb(txData: txData); - } else { - return await super.confirmSend(txData: txData); + if (beforeBroadcast != null) { + throw UnsupportedError('Pre-broadcast MWEB callbacks are unsupported.'); + } + return _confirmSendMweb(txData: txData); } + return super.confirmSend(txData: txData, beforeBroadcast: beforeBroadcast); } @override diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index 671b029ab7..80642d76b1 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -663,6 +663,11 @@ mixin SparkInterface required TxData txData, bool requireChaumV2 = false, }) async { + if (txData.opReturnData != null) { + throw ArgumentError( + 'OP_RETURN sends require the Firo transparent balance', + ); + } if (isViewOnly) { throw Exception("Spending is not supported for view only wallets"); } @@ -2749,6 +2754,11 @@ mixin SparkInterface /// /// See https://docs.google.com/document/d/1RG52GoYTZDvKlZz_3G4sQu-PpT6JWSZGHLNswWcrE3o Future prepareSparkMintTransaction({required TxData txData}) async { + if (txData.opReturnData != null) { + throw ArgumentError( + 'OP_RETURN sends require the Firo transparent balance', + ); + } if (isViewOnly) { throw Exception("Minting is not supported for view only wallets"); } diff --git a/lib/widgets/icon_widgets/exchange_icon.dart b/lib/widgets/icon_widgets/exchange_icon.dart index d9ceacd445..4039bb64b1 100644 --- a/lib/widgets/icon_widgets/exchange_icon.dart +++ b/lib/widgets/icon_widgets/exchange_icon.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import '../../services/exchange/exchange.dart'; +import '../../services/exchange/rosen/rosen_exchange.dart'; import '../../utilities/assets.dart'; import '../../utilities/util.dart'; @@ -13,6 +14,9 @@ class ExchangeIcon extends StatelessWidget { @override Widget build(BuildContext context) { final isDesktop = Util.isDesktop; + if (exchange.name == RosenExchange.exchangeName) { + return Icon(Icons.swap_horiz, size: isDesktop ? 32 : 24); + } final asset = Assets.exchange .getIconFor(exchangeName: exchange.name) .toLowerCase(); diff --git a/test/pages/exchange_view/confirm_rosen_send_test.dart b/test/pages/exchange_view/confirm_rosen_send_test.dart new file mode 100644 index 0000000000..8a79e1e6dd --- /dev/null +++ b/test/pages/exchange_view/confirm_rosen_send_test.dart @@ -0,0 +1,409 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:coinlib/coinlib.dart' as coinlib; +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:json_rpc_2/json_rpc_2.dart' show RpcException; +import 'package:platform/platform.dart' as platform; +import 'package:stackwallet/db/hive/db.dart'; +import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; +import 'package:stackwallet/models/exchange/response_objects/trade.dart'; +import 'package:stackwallet/models/input.dart'; +import 'package:stackwallet/models/isar/models/isar_models.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/models/trade_wallet_lookup.dart'; +import 'package:stackwallet/pages/exchange_view/confirm_change_now_send.dart'; +import 'package:stackwallet/pages/send_view/sub_widgets/sending_transaction_dialog.dart'; +import 'package:stackwallet/providers/providers.dart'; +import 'package:stackwallet/services/exchange/rosen/rosen_exchange.dart'; +import 'package:stackwallet/services/exchange/rosen/rosen_funding.dart'; +import 'package:stackwallet/services/exchange/rosen/rosen_protocol.dart'; +import 'package:stackwallet/services/price_service.dart'; +import 'package:stackwallet/services/wallets.dart'; +import 'package:stackwallet/themes/coin_image_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/amount/amount_formatter.dart'; +import 'package:stackwallet/utilities/amount/amount_unit.dart'; +import 'package:stackwallet/utilities/extensions/extensions.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/stack_dialog.dart'; + +import '../../sample_data/theme_json.dart'; +import '../../services/exchange/rosen/rosen_test_utils.dart'; + +void main() { + late Directory directory; + late Box trades; + late Box lookups; + late Trade trade; + final http = RosenTestHttp( + (url) => url.host == 'app.rosen.tech' + ? [ + {'network': 'firo', 'height': 1378335}, + ] + : { + 'items': [rosenFeeBox()], + 'total': 1, + }, + ); + + Future setUpBoxes() async { + directory = await Directory.systemTemp.createTemp('rosen-confirm-ui-'); + final hive = DB.instance.hive; + hive.init(directory.path); + if (!hive.isAdapterRegistered(Trade.typeId)) { + hive.registerAdapter(TradeAdapter()); + } + final adapter = TradeWalletLookupAdapter(); + if (!hive.isAdapterRegistered(adapter.typeId)) { + hive.registerAdapter(adapter); + } + trades = await hive.openBox(DB.boxNameTradesV2); + lookups = await hive.openBox(DB.boxNameTradeLookup); + await http.run(() async { + trade = (await RosenExchange.instance.createTrade( + from: 'FIRO', + fromNetwork: 'firo', + to: 'rsFIRO', + toNetwork: 'eth', + fixedRate: false, + reversed: false, + amount: Decimal.fromInt(100), + addressTo: '0x00112233445566778899aabbccddeeff00112233', + addressRefund: '', + refundExtraId: '', + )).value!.copyWith(tradeId: 'test-trade'); + }); + await trades.put(trade.uuid, trade); + } + + for (final operatingSystem in ['android', 'macos']) { + for (final outcome in ['uncertain', 'rejected', 'submitted']) { + testWidgets( + '$operatingSystem saved $outcome deposit shows its verified submission state', + (tester) async { + final previousPlatform = Util.layoutPlatform; + final previousIsIpad = Util.isIpad; + addTearDown(() { + Util.layoutPlatform = previousPlatform; + Util.isIpad = previousIsIpad; + }); + Util.layoutPlatform = platform.FakePlatform( + operatingSystem: operatingSystem, + ); + Util.isIpad = false; + await tester.runAsync(setUpBoxes); + await tester.binding.setSurfaceSize(const Size(1000, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + addTearDown(() async { + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 10)); + final closing = Future.wait([lookups.close(), trades.close()]); + for (var i = 0; i < 10; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(); + } + await tester.runAsync(() async { + await closing; + await directory.delete(recursive: true); + }); + }); + final txData = _prepared(trade); + final wallet = _FundingWallet(txData, outcome); + final observer = _UnlockObserver(); + final stackTheme = StackTheme.fromJson(json: lightThemeJsonMap); + await http.run(() async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + themeProvider.overrideWithValue(StateController(stackTheme)), + pWallets.overrideWithValue(_Wallets(wallet)), + mainDBProvider.overrideWithValue(_FailingNotesDB()), + pWalletCoin(wallet.walletId) + .overrideWithValue(wallet.cryptoCurrency), + pWalletName(wallet.walletId) + .overrideWithValue('Bridge wallet'), + pAmountFormatter(wallet.cryptoCurrency).overrideWithValue( + AmountFormatter( + unit: AmountUnit.normal, + locale: 'en_US', + coin: wallet.cryptoCurrency, + maxDecimals: 8, + ), + ), + coinImageSecondaryProvider(wallet.cryptoCurrency) + .overrideWithValue('firo.svg'), + priceAnd24hChangeNotifierProvider.overrideWithValue( + PriceService('USD'), + ), + ], + child: MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: const TextScaler.linear(0.7)), + child: child!, + ), + navigatorObservers: [observer], + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(stackTheme)], + ), + initialRoute: ConfirmChangeNowSendView.routeName, + routes: { + '/': (_) => const SizedBox.shrink(), + ConfirmChangeNowSendView.routeName: (_) => + ConfirmChangeNowSendView( + txData: txData, + walletId: wallet.walletId, + trade: trade, + ), + }, + ), + ), + ); + await tester.tap(find.widgetWithText(PrimaryButton, 'Send')); + await tester.pump(); + for (var i = 0; i < 10; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(); + } + await tester.pump(const Duration(seconds: 3)); + await tester.pump(); + if (outcome == 'submitted') { + await tester.pump(const Duration(seconds: 5)); + await tester.pump(const Duration(milliseconds: 500)); + } + expect(wallet.attempted.isCompleted, isTrue); + final saved = trades.get(trade.uuid)!; + expect(saved.payInTxid, isNotEmpty); + expect( + RosenFunding.fundingWalletId(saved), + outcome == 'submitted' ? null : wallet.walletId, + ); + expect( + find.text( + outcome == 'rejected' + ? 'Bridge deposit needs attention' + : outcome == 'submitted' + ? 'Bridge deposit submitted' + : 'Bridge deposit pending verification', + ), + findsOneWidget, + ); + expect(find.byType(SendingTransactionDialog), findsNothing); + expect(find.textContaining('Do not send again'), findsOneWidget); + expect(find.text('Broadcast transaction failed'), findsNothing); + final dialog = tester.widget( + find.byType(StackOkDialog), + ); + await tester.tap( + find.descendant( + of: find.byType(StackOkDialog), + matching: find.text('OK'), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(ConfirmChangeNowSendView), findsOneWidget); + expect(dialog.title, startsWith('Bridge deposit')); + expect( + tester + .widget( + find.widgetWithText(PrimaryButton, 'Send'), + ) + .enabled, + isFalse, + ); + expect(wallet.confirmations, 1); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 6)); + }); + }, + ); + } + } +} + +class _UnlockObserver extends NavigatorObserver { + bool unlocked = false; + @override + void didPush(Route route, Route? previousRoute) { + if (!unlocked && + (route is PopupRoute || + route.settings.name == '/confirmsendlockscreen')) { + unlocked = true; + scheduleMicrotask(() => navigator!.pop(true)); + } + } +} + +class _Wallets extends Fake implements Wallets { + _Wallets(this.wallet); + final Wallet wallet; + @override + Wallet getWallet(String walletId) => wallet; +} + +class _Info extends Fake implements WalletInfo { + @override + bool get isViewOnly => false; + @override + CryptoCurrency get coin => Firo(CryptoCurrencyNetwork.main); +} + +class _FailingNotesDB extends Fake implements MainDB { + @override + Future putTransactionNote(TransactionNote note) async => + throw StateError('Local note storage unavailable'); +} + +class _FundingWallet extends Fake implements FiroWallet { + _FundingWallet(this.txData, this.outcome) + : electrumXClient = _Node(txData, outcome == 'rejected'); + final TxData txData; + final String outcome; + final attempted = Completer(); + int confirmations = 0; + @override + final ElectrumXClient electrumXClient; + @override + final Firo cryptoCurrency = Firo(CryptoCurrencyNetwork.main); + @override + final WalletInfo info = _Info(); + @override + String get walletId => 'confirmation-wallet'; + @override + Future fetchChainHeight({int retries = 1}) async => 1378335; + @override + Future refresh() async {} + @override + Future confirmSend({ + required TxData txData, + Future Function(String)? beforeBroadcast, + }) async { + confirmations++; + await beforeBroadcast!(txData.raw!); + attempted.complete(); + if (outcome == 'submitted') { + return txData.copyWith(txid: firoTransactionFromHex(txData.raw!).txid); + } + throw StateError('Network acceptance unknown'); + } +} + +class _Node extends Fake implements ElectrumXClient { + _Node(this.txData, this.needsAttention); + final TxData txData; + final bool needsAttention; + @override + Future request({ + required String command, + List args = const [], + String? requestID, + int retries = 2, + Duration requestTimeout = const Duration(seconds: 60), + }) async { + if (needsAttention) { + throw RpcException(-1, 'No such mempool or blockchain transaction'); + } + throw StateError('Node unavailable'); + } + + @override + Future>> getUTXOs({ + required String scripthash, + String? requestID, + }) async => [ + { + 'tx_hash': '00' * 32, + 'tx_pos': 0, + 'value': txData.amountWithoutChange!.raw.toInt() + 1000, + }, + ]; + @override + Future>> getHistory({ + required String scripthash, + String? requestID, + }) async => []; + @override + Future broadcastTransaction({ + required String rawTx, + String? requestID, + }) async => throw RpcException(-26, 'bad-txns-inputs-missingorspent'); +} + +TxData _prepared(Trade trade) { + final amount = RosenProtocol.parseAmount(trade.payInAmount); + final metadata = RosenExchange.validatedMetadata(trade); + const signature = + '304402206687c87c5f80c4e2a4e63fed02b0de65bcd38b77255b1de3f375f5d7' + 'a1b124c902202d5997a82c65d254e08ecc2ef7ba15995df3adfefa509b6ce0fdd' + '65642a4062f'; + const publicKey = + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + final raw = coinlib.Transaction( + version: 1, + inputs: [ + coinlib.RawInput( + prevOut: coinlib.OutPoint(Uint8List(32), 0), + scriptSig: '47${signature}0121$publicKey'.toUint8ListFromHex, + ), + ], + outputs: [ + coinlib.Output.fromScriptBytes( + amount, + RosenProtocol.firoScript(trade.payInAddress).toUint8ListFromHex, + ), + firoOpReturnOutput(metadata), + ], + ).toHex(); + return TxData( + recipients: [ + TxRecipient( + address: trade.payInAddress, + amount: Amount(rawValue: amount, fractionDigits: 8), + isChange: false, + addressType: Firo(CryptoCurrencyNetwork.main).defaultAddressType, + ), + ], + fee: Amount(rawValue: BigInt.from(1000), fractionDigits: 8), + raw: raw, + opReturnData: metadata, + usedUTXOs: [ + StandardInput( + UTXO( + walletId: 'confirmation-wallet', + txid: '00' * 32, + vout: 0, + value: (amount + BigInt.from(1000)).toInt(), + name: '', + isBlocked: false, + blockedReason: null, + isCoinbase: false, + blockHash: null, + blockHeight: null, + blockTime: null, + address: trade.payInAddress, + ), + ), + ], + ); +} diff --git a/test/pages/exchange_view/desktop_step_4_test.dart b/test/pages/exchange_view/desktop_step_4_test.dart new file mode 100644 index 0000000000..7093d88150 --- /dev/null +++ b/test/pages/exchange_view/desktop_step_4_test.dart @@ -0,0 +1,186 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/exchange/incomplete_exchange.dart'; +import 'package:stackwallet/models/exchange/response_objects/trade.dart'; +import 'package:stackwallet/models/isar/exchange_cache/currency.dart'; +import 'package:stackwallet/models/isar/exchange_cache/pair.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart'; +import 'package:stackwallet/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_4.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/services/exchange/rosen/rosen_exchange.dart'; +import 'package:stackwallet/services/wallets.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/enums/exchange_rate_type_enum.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/wallet/impl/ethereum_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/qr.dart'; + +import '../../sample_data/theme_json.dart'; + +void main() { + for (final fromFiro in [true, false]) { + for (final hasWallet in [true, false]) { + testWidgets('Rosen ${fromFiro ? 'FIRO' : 'rsFIRO'} instructions ' + '${hasWallet ? 'with' : 'without'} a funding wallet', (tester) async { + final ticker = fromFiro ? 'FIRO' : 'rsFIRO'; + final colors = await _pumpStep( + tester, + _Trade(RosenExchange.exchangeName, ticker, 'Confirming'), + hasWallet ? [fromFiro ? _FiroWallet() : _EthereumWallet()] : [], + ); + expect(find.text('Send with Rosen Bridge'), findsOneWidget); + expect(find.text('1.23456789 $ticker'), findsOneWidget); + expect( + find.textContaining( + fromFiro ? 'transparent FIRO balance' : 'enough ETH', + ), + findsOneWidget, + ); + expect( + find.text( + 'Add a ${fromFiro ? 'FIRO' : 'Ethereum'} wallet to fund this swap.', + ), + hasWallet ? findsNothing : findsOneWidget, + ); + expect(find.text('bridge-trade'), findsOneWidget); + expect(find.text('deposit-address'), findsNothing); + expect(find.text('deposit-memo'), findsNothing); + expect(find.text('Memo'), findsNothing); + expect(find.byType(SimpleCopyButton), findsNothing); + expect(find.byType(QR), findsNothing); + expect( + tester.widget(find.text('Confirming')).style!.color, + colors.colorForStatus('Confirming'), + ); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + } + + testWidgets('ordinary provider keeps its address, memo and deposit warning', ( + tester, + ) async { + final colors = await _pumpStep( + tester, + _Trade('ChangeNOW', 'btc', 'Waiting'), + [], + ); + expect(find.text('Send BTC to the address below'), findsOneWidget); + expect(find.text('Send BTC to this address'), findsOneWidget); + expect(find.text('deposit-address'), findsOneWidget); + expect(find.text('Memo'), findsOneWidget); + expect(find.text('deposit-memo'), findsOneWidget); + expect(find.byType(SimpleCopyButton), findsOneWidget); + expect(find.text('1.23456789 BTC'), findsOneWidget); + expect( + find.textContaining( + 'You must send at least 1.23456789 btc.', + findRichText: true, + ), + findsOneWidget, + ); + expect(find.textContaining('Add a '), findsNothing); + expect( + tester.widget(find.text('Waiting for deposit')).style!.color, + colors.colorForStatus('Waiting for deposit'), + ); + await tester.pumpWidget(const SizedBox.shrink()); + }); +} + +Future _pumpStep( + WidgetTester tester, + Trade trade, + List wallets, +) async { + final currency = Currency( + exchangeName: trade.exchangeName, + ticker: trade.payInCurrency, + name: trade.payInCurrency, + network: '', + image: '', + isFiat: false, + rateType: SupportedRateType.estimated, + isStackCoin: true, + tokenContract: null, + ); + final model = IncompleteExchangeModel( + sendCurrency: currency, + receiveCurrency: currency, + rateInfo: '', + sendAmount: Decimal.one, + receiveAmount: Decimal.one, + rateType: ExchangeRateType.estimated, + reversed: false, + walletInitiated: false, + )..trade = trade; + final colors = StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ); + await tester.binding.setSurfaceSize(const Size(1000, 1000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + desktopExchangeModelProvider.overrideWithValue(model), + pWallets.overrideWithValue(_Wallets(wallets)), + ], + child: MaterialApp( + theme: ThemeData(extensions: [colors]), + home: const Scaffold(body: DesktopStep4()), + ), + ), + ); + expect(tester.takeException(), isNull); + return colors; +} + +class _Trade extends Fake implements Trade { + _Trade(this.exchangeName, this.payInCurrency, this.status); + @override + final String exchangeName; + @override + final String payInCurrency; + @override + final String status; + @override + String get payInAmount => '1.23456789'; + @override + String get payInAddress => 'deposit-address'; + @override + String get payInExtraId => 'deposit-memo'; + @override + String get tradeId => 'bridge-trade'; +} + +class _Wallets extends Fake implements Wallets { + _Wallets(this.wallets); + @override + final List wallets; +} + +class _Info extends Fake implements WalletInfo { + @override + bool get isViewOnly => false; +} + +class _FiroWallet extends Fake implements FiroWallet { + @override + final Firo cryptoCurrency = Firo(CryptoCurrencyNetwork.main); + @override + final WalletInfo info = _Info(); +} + +class _EthereumWallet extends Fake implements EthereumWallet { + @override + final Ethereum cryptoCurrency = Ethereum(CryptoCurrencyNetwork.main); + @override + final WalletInfo info = _Info(); +} diff --git a/test/pages/exchange_view/exchange_form_debounce_test.dart b/test/pages/exchange_view/exchange_form_debounce_test.dart index 7f118dc5b2..5d0dbf3d73 100644 --- a/test/pages/exchange_view/exchange_form_debounce_test.dart +++ b/test/pages/exchange_view/exchange_form_debounce_test.dart @@ -8,9 +8,13 @@ import 'package:stackwallet/models/isar/exchange_cache/currency.dart'; import 'package:stackwallet/models/isar/exchange_cache/pair.dart'; import 'package:stackwallet/models/isar/stack_theme.dart'; import 'package:stackwallet/pages/exchange_view/exchange_form.dart'; +import 'package:stackwallet/pages/exchange_view/sub_widgets/rate_type_toggle.dart'; import 'package:stackwallet/providers/exchange/exchange_form_state_provider.dart'; import 'package:stackwallet/providers/global/locale_provider.dart'; import 'package:stackwallet/providers/global/prefs_provider.dart'; +import 'package:stackwallet/services/exchange/change_now/change_now_exchange.dart'; +import 'package:stackwallet/services/exchange/exolix/exolix_exchange.dart'; +import 'package:stackwallet/services/exchange/trocador/trocador_exchange.dart'; import 'package:stackwallet/services/locale_service.dart'; import 'package:stackwallet/themes/stack_colors.dart'; import 'package:stackwallet/themes/theme_service.dart'; @@ -139,6 +143,59 @@ void main() { return (container, sendField, receiveField); } + for (final exchange in [ + ChangeNowExchange.instance, + ExolixExchange.instance, + TrocadorExchange.instance, + ]) { + testWidgets('${exchange.name} rate switches preserve the receive amount', ( + tester, + ) async { + final container = await pumpForm( + tester, + receive: currency('RECEIVE'), + sendAmount: Decimal.one, + receiveAmount: Decimal.fromInt(2), + fixedRate: true, + ); + container.read(efExchangeProvider.notifier).state = exchange; + container.read(efExchangeProviderNameProvider.notifier).state = + exchange.name; + + final receiveField = find.byType(TextField).last; + await tester.tap(receiveField); + await tester.pump(); + await tester.pump(); + await tester.enterText(receiveField, '7.12345678'); + await tester.pump(const Duration(seconds: 2)); + expect(container.read(efReversedProvider), isTrue); + + await tester.tap(find.byType(RateTypeToggle)); + await tester.pump(); + await tester.pump(); + expect(container.read(efRateTypeProvider), ExchangeRateType.estimated); + expect(container.read(efReversedProvider), isTrue); + expect( + container.read(efReceiveAmountProvider), + Decimal.parse('7.12345678'), + ); + expect( + tester.widget(receiveField).controller!.text, + '7.12345678', + ); + + await tester.tap(find.byType(RateTypeToggle)); + await tester.pump(); + await tester.pump(); + expect(container.read(efRateTypeProvider), ExchangeRateType.fixed); + expect(container.read(efReversedProvider), isTrue); + expect( + container.read(efReceiveAmountProvider), + Decimal.parse('7.12345678'), + ); + }); + } + testWidgets("currency change cancels stale amount debounce", (tester) async { final container = await pumpForm(tester); final pair = container.read(efCurrencyPairProvider); diff --git a/test/pages/exchange_view/send_from_view_test.dart b/test/pages/exchange_view/send_from_view_test.dart new file mode 100644 index 0000000000..bec90f5f5c --- /dev/null +++ b/test/pages/exchange_view/send_from_view_test.dart @@ -0,0 +1,209 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:platform/platform.dart' as platform; +import 'package:stackwallet/models/balance.dart'; +import 'package:stackwallet/models/exchange/response_objects/trade.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/exchange_view/send_from_view.dart'; +import 'package:stackwallet/pages/send_view/sub_widgets/building_transaction_dialog.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/services/wallets.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/amount/amount_formatter.dart'; +import 'package:stackwallet/utilities/amount/amount_unit.dart'; +import 'package:stackwallet/utilities/stack_file_system.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart'; + +import '../../sample_data/theme_json.dart'; + +void main() { + for (final desktop in [false, true]) { + testWidgets( + '${desktop ? 'desktop' : 'mobile'} cancellation permits a new build ' + 'without losing its guard', + (tester) async { + final previousPlatform = Util.layoutPlatform; + final previousWidth = Util.screenWidth; + final previousIsIpad = Util.isIpad; + final previousThemesDir = StackFileSystem.themesDir; + addTearDown(() { + Util.layoutPlatform = previousPlatform; + Util.screenWidth = previousWidth; + Util.isIpad = previousIsIpad; + StackFileSystem.themesDir = previousThemesDir; + }); + Util.layoutPlatform = platform.FakePlatform( + operatingSystem: desktop ? 'macos' : 'android', + ); + Util.screenWidth = desktop ? 1200 : 600; + Util.isIpad = false; + StackFileSystem.themesDir = Directory('test/sample_data').absolute; + await tester.binding.setSurfaceSize(const Size(1200, 1000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final wallet = _FiroWallet(); + final coin = wallet.cryptoCurrency; + final balance = Balance.zeroFor(currency: coin); + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + await tester.pumpWidget( + ProviderScope( + overrides: [ + pWallets.overrideWithValue(_Wallets(wallet)), + pWalletCoin('test').overrideWithValue(coin), + pWalletName('test').overrideWithValue('Test FIRO wallet'), + pWalletBalance('test').overrideWithValue(balance), + pWalletBalanceTertiary('test').overrideWithValue(balance), + themeProvider.overrideWithValue(StateController(theme)), + pAmountFormatter(coin).overrideWithValue( + AmountFormatter( + unit: AmountUnit.normal, + locale: 'en_US', + coin: coin, + maxDecimals: 8, + ), + ), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(theme)], + ), + home: Scaffold( + body: SendFromCard( + walletId: 'test', + amount: Amount(rawValue: BigInt.one, fractionDigits: 8), + address: _address, + trade: _trade(), + ), + ), + ), + ), + ); + final publicButton = find.byKey( + const Key('walletsSheetItemButtonFiroPublicKey_test'), + ); + try { + await tester.tap(find.text('Test FIRO wallet')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + await tester.tap(find.text('Use private balance')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(wallet.privateBuilds, 1); + expect(find.byType(BuildingTransactionDialog), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(BuildingTransactionDialog), findsNothing); + expect(wallet.privateResult.isCompleted, isFalse); + + await tester.tap(publicButton); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(wallet.publicResults, hasLength(1)); + expect(find.byType(BuildingTransactionDialog), findsOneWidget); + + wallet.privateResult.complete(TxData()); + await tester.pump(const Duration(seconds: 3)); + expect(find.byType(BuildingTransactionDialog), findsOneWidget); + // Exercise the actual button callback while the new build is busy. + tester.widget(publicButton).onPressed!(); + await tester.pump(); + expect(wallet.publicResults, hasLength(1)); + + await tester.tap(find.text('Cancel')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(BuildingTransactionDialog), findsNothing); + await tester.tap(publicButton); + await tester.pump(); + expect(wallet.publicResults, hasLength(2)); + expect(tester.takeException(), isNull); + } finally { + if (find.text('Cancel').evaluate().isNotEmpty) { + await tester.tap(find.text('Cancel')); + await tester.pump(); + } + await tester.pumpWidget(const SizedBox.shrink()); + if (!wallet.privateResult.isCompleted) { + wallet.privateResult.complete(TxData()); + } + for (final result in wallet.publicResults) { + result.complete(TxData()); + } + await tester.pump(const Duration(seconds: 3)); + } + }, + ); + } +} + +const _address = 'aEF6fyd5jjCPcbiEBZJ2g8583caUme8T7Y'; + +Trade _trade() => Trade( + uuid: 'test', + tradeId: 'test', + rateType: 'floating', + direction: 'direct', + timestamp: DateTime(2026), + updatedAt: DateTime(2026), + payInCurrency: 'FIRO', + payInAmount: '0.00000001', + payInAddress: _address, + payInNetwork: 'firo', + payInExtraId: '', + payInTxid: '', + payOutCurrency: 'BTC', + payOutAmount: '0.00000001', + payOutAddress: '', + payOutNetwork: 'btc', + payOutExtraId: '', + payOutTxid: '', + refundAddress: '', + refundExtraId: '', + status: 'waiting', + exchangeName: 'ChangeNOW', +); + +class _FiroWallet extends FiroWallet { + _FiroWallet() : super(CryptoCurrencyNetwork.main); + + int privateBuilds = 0; + final privateResult = Completer(); + final publicResults = >[]; + + @override + Future prepareSendSpark({ + required TxData txData, + bool requireChaumV2 = false, + }) { + privateBuilds++; + return privateResult.future; + } + + @override + Future prepareSend({required TxData txData}) { + final result = Completer(); + publicResults.add(result); + return result.future; + } +} + +class _Wallets extends Fake implements Wallets { + _Wallets(this.wallet); + + final FiroWallet wallet; + + @override + FiroWallet getWallet(String walletId) => wallet; +} diff --git a/test/pages/exchange_view/step_2_view_test.dart b/test/pages/exchange_view/step_2_view_test.dart new file mode 100644 index 0000000000..6875a90d80 --- /dev/null +++ b/test/pages/exchange_view/step_2_view_test.dart @@ -0,0 +1,146 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:platform/platform.dart' as platform; +import 'package:stackwallet/models/exchange/incomplete_exchange.dart'; +import 'package:stackwallet/models/isar/exchange_cache/currency.dart'; +import 'package:stackwallet/models/isar/exchange_cache/pair.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/exchange_view/exchange_step_views/step_2_view.dart'; +import 'package:stackwallet/providers/exchange/exchange_form_state_provider.dart'; +import 'package:stackwallet/providers/exchange/exchange_send_from_wallet_id_provider.dart'; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/services/exchange/rosen/rosen_exchange.dart'; +import 'package:stackwallet/services/wallets.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/utilities/enums/exchange_rate_type_enum.dart'; +import 'package:stackwallet/utilities/stack_file_system.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:tuple/tuple.dart'; + +import '../../sample_data/theme_json.dart'; + +void main() { + for (final closeBeforeAddress in [false, true]) { + testWidgets( + closeBeforeAddress + ? 'recipient prefill is ignored after the view closes' + : 'recipient prefill enables Next when no refund is required', + (tester) async { + final previousThemesDir = StackFileSystem.themesDir; + final previousPlatform = Util.layoutPlatform; + final previousIsIpad = Util.isIpad; + addTearDown(() { + StackFileSystem.themesDir = previousThemesDir; + Util.layoutPlatform = previousPlatform; + Util.isIpad = previousIsIpad; + }); + StackFileSystem.themesDir = Directory('test/sample_data').absolute; + Util.layoutPlatform = platform.FakePlatform(operatingSystem: 'android'); + Util.isIpad = false; + await tester.binding.setSurfaceSize(const Size(1200, 1000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + final wallet = _FiroWallet(); + final model = IncompleteExchangeModel( + sendCurrency: _currency('rsFIRO'), + receiveCurrency: _currency('FIRO'), + rateInfo: '', + sendAmount: Decimal.one, + receiveAmount: Decimal.one, + rateType: ExchangeRateType.estimated, + reversed: false, + walletInitiated: true, + ); + addTearDown(model.dispose); + await tester.pumpWidget( + ProviderScope( + overrides: [ + pWallets.overrideWithValue(_Wallets(wallet)), + efExchangeProvider.overrideWithValue( + StateController(RosenExchange.instance), + ), + exchangeSendFromWalletIdStateProvider.overrideWithValue( + StateController(Tuple2('test', wallet.cryptoCurrency)), + ), + themeProvider.overrideWithValue(StateController(theme)), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(theme)], + ), + home: Step2View(model: model), + ), + ), + ); + final next = find.byWidgetPredicate( + (widget) => widget is PrimaryButton && widget.label == 'Next', + ); + expect(tester.widget(next).enabled, isFalse); + if (closeBeforeAddress) { + await tester.pumpWidget(const SizedBox.shrink()); + } + wallet.address.complete( + Address( + walletId: 'test', + value: _address, + publicKey: [], + derivationIndex: 0, + derivationPath: null, + type: wallet.cryptoCurrency.defaultAddressType, + subType: AddressSubType.receiving, + ), + ); + await tester.pump(); + if (closeBeforeAddress) { + expect(model.recipientAddress, isNull); + } else { + expect(model.recipientAddress, _address); + expect(tester.widget(next).enabled, isTrue); + } + expect(tester.takeException(), isNull); + }, + ); + } +} + +const _address = 'aEF6fyd5jjCPcbiEBZJ2g8583caUme8T7Y'; + +Currency _currency(String ticker) => Currency( + exchangeName: RosenExchange.exchangeName, + ticker: ticker, + name: ticker, + network: ticker.toLowerCase(), + image: '', + isFiat: false, + rateType: SupportedRateType.estimated, + isStackCoin: true, + tokenContract: null, +); + +class _FiroWallet extends FiroWallet { + _FiroWallet() : super(CryptoCurrencyNetwork.main); + + final address = Completer(); + + @override + Future getCurrentReceivingAddress() => address.future; +} + +class _Wallets extends Fake implements Wallets { + _Wallets(this.wallet); + + final FiroWallet wallet; + + @override + FiroWallet getWallet(String walletId) => wallet; +} diff --git a/test/services/exchange/rosen/rosen_api_test.dart b/test/services/exchange/rosen/rosen_api_test.dart new file mode 100644 index 0000000000..586fab7bf5 --- /dev/null +++ b/test/services/exchange/rosen/rosen_api_test.dart @@ -0,0 +1,238 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/services/exchange/rosen/rosen_api.dart'; + +import 'rosen_test_utils.dart'; + +void main() { + final amount = BigInt.from(10000000000); + const feePath = + '/api/v1/boxes/unspent/byTokenId/' + 'e2ed4d64393222db666f20e67803e9e6fbe6d64531e14ff52ddd95615b0cbf17'; + const txid = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + for (final suppliedHeight in [false, true]) { + test('quotes both routes with supplied height: $suppliedHeight', () async { + for (final fromFiro in [true, false]) { + final http = RosenTestHttp((url) { + if (url.path == '/api/v1/heights') { + expect(url.host, 'app.rosen.tech'); + return [ + {'network': 'ethereum', 'height': '25992101'}, + {'network': 'firo', 'height': 1378335}, + ]; + } + expect(url.host, 'api.ergoplatform.com'); + expect(url.path, feePath); + expect(url.queryParameters, {'offset': '0', 'limit': '100'}); + return { + 'items': [rosenFeeBox()], + 'total': 1, + }; + }); + await http.run(() async { + final quote = await RosenApi.instance.quote( + fromFiro: fromFiro, + amount: amount, + sourceHeight: suppliedHeight + ? (fromFiro ? 1378335 : 25992101) + : null, + ); + expect(quote.bridgeFee, BigInt.from(1129513169)); + expect(quote.networkFee, BigInt.from(fromFiro ? 1452401 : 500452)); + expect( + quote.receiveAmount, + amount - quote.bridgeFee - quote.networkFee, + ); + }); + expect(http.requests, hasLength(suppliedHeight ? 1 : 2)); + } + }); + } + + test('paginates past unrelated, spent and forked fee boxes', () async { + final ignored = [ + {...rosenFeeBox(), 'assets': >[]}, + { + ...rosenFeeBox(), + 'assets': [ + (rosenFeeBox()['assets'] as List).first, + {'tokenId': 'unrelated-token'}, + ], + }, + { + ...rosenFeeBox(), + 'assets': [ + (rosenFeeBox()['assets'] as List).last, + {'tokenId': 'unrelated-token'}, + ], + }, + {...rosenFeeBox(), 'spentTransactionId': txid}, + {...rosenFeeBox(), 'mainChain': false}, + ]; + final http = RosenTestHttp((url) { + expect(url.path, feePath); + expect(url.queryParameters['limit'], '100'); + return { + 'items': url.queryParameters['offset'] == '0' + ? ignored + : [rosenFeeBox()], + 'total': '${ignored.length + 1}', + }; + }); + await http.run(() async { + final quote = await RosenApi.instance.quote( + fromFiro: true, + amount: amount, + sourceHeight: 1378335, + ); + expect(quote.bridgeFee, BigInt.from(1129513169)); + }); + expect(http.requests.map((url) => url.queryParameters['offset']), [ + '0', + '${ignored.length}', + ]); + }); + + for (final count in [0, 2]) { + test('rejects $count active FIRO fee configurations', () async { + final http = RosenTestHttp( + (_) => { + 'items': List.generate(count, (_) => rosenFeeBox()), + 'total': count, + }, + ); + await http.run(() async { + await expectLater( + RosenApi.instance.quote( + fromFiro: true, + amount: amount, + sourceHeight: 1378335, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'Expected one active Rosen FIRO fee configuration', + ), + ), + ); + }); + }); + } + + test('rejects incomplete fee pagination', () async { + final http = RosenTestHttp( + (_) => {'items': >[], 'total': 1}, + ); + await http.run(() async { + await expectLater( + RosenApi.instance.quote( + fromFiro: true, + amount: amount, + sourceHeight: 1378335, + ), + throwsFormatException, + ); + }); + expect(http.requests, hasLength(1)); + }); + + test('refuses fees changing during the confirmation window', () async { + final http = RosenTestHttp( + (_) => { + 'items': [rosenFeeBox()], + 'total': 1, + }, + ); + await http.run(() async { + Future quote(int height) => RosenApi.instance.quote( + fromFiro: true, + amount: amount, + sourceHeight: height, + ); + expect((await quote(1373152)).bridgeFee, BigInt.from(1425897447)); + await expectLater( + quote(1373153), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'Rosen fees are changing. Please try again shortly.', + ), + ), + ); + expect((await quote(1373163)).bridgeFee, BigInt.from(1129513169)); + }); + }); + + test('surfaces HTTP failures instead of treating them as no event', () async { + final http = RosenTestHttp( + (_) => {'items': >[]}, + statusCode: 503, + ); + await http.run(() async { + await expectLater( + RosenApi.instance.getEvent(txid), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'Rosen API returned HTTP 503', + ), + ), + ); + }); + }); + + test('rejects malformed source IDs before requesting events', () async { + final http = RosenTestHttp((_) => fail('Unexpected network request')); + await http.run(() async { + for (final invalid in [ + '', + txid.substring(1), + '${txid}0', + 'g${txid.substring(1)}', + ]) { + await expectLater( + RosenApi.instance.getEvent(invalid), + throwsFormatException, + ); + } + }); + expect(http.requests, isEmpty); + }); + + test( + 'normalizes source IDs but requires a unique exact event match', + () async { + final event = { + 'sourceTxId': '0x${txid.toUpperCase()}', + 'status': 'pending', + }; + final unrelated = [ + {'sourceTxId': '${txid}00'}, + {'sourceTxId': txid.substring(1)}, + {'sourceTxId': null}, + ]; + for (final count in [0, 1, 2]) { + final http = RosenTestHttp((url) { + expect(url.host, 'app.rosen.tech'); + expect(url.path, '/api/v1/events'); + expect(url.queryParameters, {'sourceTxId*': txid, 'limit': '100'}); + return { + 'items': [...unrelated, ...List.filled(count, event)], + }; + }); + await http.run(() async { + final result = RosenApi.instance.getEvent('0x${txid.toUpperCase()}'); + if (count > 1) { + await expectLater(result, throwsStateError); + } else { + expect(await result, count == 0 ? null : event); + } + }); + } + }, + ); +} diff --git a/test/services/exchange/rosen/rosen_exchange_test.dart b/test/services/exchange/rosen/rosen_exchange_test.dart new file mode 100644 index 0000000000..42d2b05584 --- /dev/null +++ b/test/services/exchange/rosen/rosen_exchange_test.dart @@ -0,0 +1,555 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:stackwallet/db/hive/db.dart'; +import 'package:stackwallet/exceptions/exchange/exchange_exception.dart'; +import 'package:stackwallet/models/exchange/response_objects/estimate.dart'; +import 'package:stackwallet/models/exchange/response_objects/trade.dart'; +import 'package:stackwallet/models/trade_wallet_lookup.dart'; +import 'package:stackwallet/networking/http.dart'; +import 'package:stackwallet/services/ethereum/ethereum_api.dart'; +import 'package:stackwallet/services/exchange/rosen/rosen_api.dart'; +import 'package:stackwallet/services/exchange/rosen/rosen_exchange.dart'; +import 'package:stackwallet/services/exchange/rosen/rosen_protocol.dart'; +import 'package:stackwallet/utilities/default_eth_tokens.dart'; + +import 'rosen_test_utils.dart'; + +void main() { + final exchange = RosenExchange.instance; + final changed = isA().having( + (e) => e.type, + 'type', + ExchangeExceptionType.quoteChanged, + ); + + for (final fromFiro in [true, false]) { + final from = fromFiro ? 'FIRO' : 'rsFIRO'; + final to = fromFiro ? 'rsFIRO' : 'FIRO'; + final fromNetwork = fromFiro ? 'firo' : 'eth'; + final toNetwork = fromFiro ? 'eth' : 'firo'; + final destination = fromFiro + ? '0x00112233445566778899aabbccddeeff00112233' + : RosenApi.firoLockAddress; + + group('$from → $to exchange', () { + late Directory directory; + late Box trades; + late Box lookups; + late Map feeBox; + late List> events; + late RosenTestHttp http; + late HTTP previousEthereumClient; + late _ReceiptHttp receiptHttp; + + setUp(() async { + directory = await Directory.systemTemp.createTemp('rosen-exchange-'); + final hive = DB.instance.hive; + hive.init(directory.path); + if (!hive.isAdapterRegistered(Trade.typeId)) { + hive.registerAdapter(TradeAdapter()); + } + final lookupAdapter = TradeWalletLookupAdapter(); + if (!hive.isAdapterRegistered(lookupAdapter.typeId)) { + hive.registerAdapter(lookupAdapter); + } + trades = await hive.openBox(DB.boxNameTradesV2); + lookups = await hive.openBox(DB.boxNameTradeLookup); + feeBox = rosenFeeBox(); + events = []; + previousEthereumClient = EthereumAPI.client; + receiptHttp = _ReceiptHttp(); + EthereumAPI.client = receiptHttp; + http = RosenTestHttp((url) { + if (url.host == 'app.rosen.tech') { + switch (url.path) { + case '/api/v1/heights': + return [ + {'network': 'firo', 'height': 1378335}, + {'network': 'ethereum', 'height': 25992101}, + ]; + case '/api/v1/events': + return {'items': events}; + } + } + expect(url.host, 'api.ergoplatform.com'); + expect(url.path, contains('/boxes/unspent/byTokenId/')); + return { + 'items': [feeBox], + 'total': 1, + }; + }); + }); + tearDown(() async { + EthereumAPI.client = previousEthereumClient; + await lookups.close(); + await trades.close(); + await directory.delete(recursive: true); + }); + + Future estimate() async { + final response = await exchange.getEstimates( + from, + fromNetwork, + to, + toNetwork, + Decimal.fromInt(100), + false, + false, + ); + expect(response.exception, isNull); + return response.value!.single; + } + + Future create(Estimate quote) async { + final response = await exchange.createTrade( + from: from, + fromNetwork: fromNetwork, + to: to, + toNetwork: toNetwork, + amount: Decimal.fromInt(100), + fixedRate: false, + reversed: false, + addressTo: destination, + addressRefund: '', + refundExtraId: '', + estimate: quote, + ); + expect(response.exception, isNull); + return response.value!; + } + + void changeFees() { + // A valid replacement schedule: zero base fees, unchanged percentage. + feeBox['additionalRegisters'] = { + ...rosenFeeRegisters, + 'R6': {'serializedValue': '1d020600000000000006000000000000'}, + }; + } + + test( + 'reverse estimates are rejected only by Rosen before any request', + () async { + await http.run(() async { + final response = await exchange.getEstimates( + from, + fromNetwork, + to, + toNetwork, + Decimal.fromInt(100), + false, + true, + ); + expect(response.value, isNull); + expect( + response.exception?.message, + contains('using the send amount'), + ); + expect(http.requests, isEmpty); + }); + }, + ); + + test( + 'range, quote and trade preserve the route and exact amounts', + () async { + await http.run(() async { + final range = await exchange.getRange( + from, + fromNetwork, + to, + toNetwork, + false, + ); + expect(range.exception, isNull); + expect( + range.value!.min, + Decimal.parse(fromFiro ? '11.30965571' : '11.30013622'), + ); + expect(range.value!.max, Decimal.parse('184467440737.09551615')); + final quote = await estimate(); + expect( + quote.estimatedAmount, + Decimal.parse(fromFiro ? '88.6903443' : '88.69986379'), + ); + final trade = await create(quote); + expect(trade.payInAmount, '100'); + expect(trade.payOutAmount, quote.estimatedAmount.toString()); + expect(trade.payInCurrency, from); + expect(trade.payOutCurrency, to); + expect(trade.payInNetwork, fromNetwork); + expect(trade.payOutNetwork, toNetwork); + expect(trade.payOutAddress, destination); + expect( + trade.payInAddress, + fromFiro + ? RosenApi.firoLockAddress + : RosenApi.ethereumLockAddress, + ); + expect(trade.status, 'Waiting'); + expect(trade.payInTxid, isEmpty); + final data = jsonDecode(trade.other!) as Map; + expect(data['tokenContract'], DefaultTokens.rsFiro.address); + expect(data['bridgeFee'], '1129513169'); + expect(data['networkFee'], fromFiro ? '1452401' : '500452'); + expect(data.containsKey('metadata'), isFalse); + expect( + RosenExchange.validatedMetadata(trade), + RosenProtocol.metadata( + fromFiro: fromFiro, + destination: destination, + bridgeFee: BigInt.parse(data['bridgeFee'] as String), + networkFee: BigInt.parse(data['networkFee'] as String), + ), + ); + await trades.put(trade.uuid, trade); + expect( + (await exchange.getTrade(trade.tradeId)).value!.uuid, + trade.uuid, + ); + expect((await exchange.getTrades()).value!.single.uuid, trade.uuid); + expect( + (await exchange.getTrade('missing')).exception!.type, + ExchangeExceptionType.orderNotFound, + ); + }); + }, + ); + + test( + 'changed fees reject an old estimate before creating a trade', + () async { + await http.run(() async { + final quote = await estimate(); + changeFees(); + final response = await exchange.createTrade( + from: from, + fromNetwork: fromNetwork, + to: to, + toNetwork: toNetwork, + amount: Decimal.fromInt(100), + fixedRate: false, + reversed: false, + addressTo: destination, + addressRefund: '', + refundExtraId: '', + estimate: quote, + ); + expect(response.value, isNull); + expect(response.exception, changed); + expect(trades.values, isEmpty); + final refreshed = await estimate(); + expect(refreshed.rateId, isNot(quote.rateId)); + expect( + (await create(refreshed)).payOutAmount, + refreshed.estimatedAmount.toString(), + ); + }); + }, + ); + + test('funding rechecks both live fees and persisted state', () async { + await http.run(() async { + final trade = await create(await estimate()); + await trades.put(trade.uuid, trade); + final height = fromFiro ? 1378335 : 25992101; + await RosenExchange.validateFunding(trade, sourceHeight: height); + changeFees(); + await expectLater( + RosenExchange.validateFunding(trade, sourceHeight: height), + throwsA(changed), + ); + feeBox = rosenFeeBox(); + final concurrent = RosenTestHttp((url) async { + expect(url.host, 'api.ergoplatform.com'); + await trades.put( + trade.uuid, + trade.copyWith(payInTxid: 'ab' * 32, status: 'Confirming'), + ); + return { + 'items': [feeBox], + 'total': 1, + }; + }); + await concurrent.run(() async { + await expectLater( + RosenExchange.validateFunding(trade, sourceHeight: height), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Only an unfunded Rosen swap can be refreshed or sent.', + ), + ), + ); + }); + expect(concurrent.requests, hasLength(1)); + }); + }); + + test('polling rejects mismatched events and requires a payout', () async { + await http.run(() async { + final trade = (await create(await estimate())) + .copyWith(payInTxid: 'ab' * 32, status: 'Confirming'); + await trades.put(trade.uuid, trade); + expect( + (await exchange.updateTrade(trade)).value!.status, + 'Confirming', + ); + final fees = jsonDecode(trade.other!) as Map; + final event = { + 'sourceTxId': '0x${trade.payInTxid.toUpperCase()}', + 'fromChain': fromFiro ? 'firo' : 'ethereum', + 'toChain': fromFiro ? 'ethereum' : 'firo', + 'toAddress': fromFiro ? destination.toUpperCase() : destination, + 'sourceChainTokenId': fromFiro + ? 'FIRO' + : DefaultTokens.rsFiro.address.toUpperCase(), + 'bridgeFee': fees['bridgeFee'], + 'networkFee': fees['networkFee'], + 'amount': '10000000000', + 'status': 'completed', + 'paymentTxId': null, + }; + for (final mismatch in { + 'fromChain': 'ergo', + 'toChain': 'ergo', + 'toAddress': 'wrong-recipient', + 'sourceChainTokenId': 'wrong-token', + 'bridgeFee': '1', + 'networkFee': '1', + 'amount': '9999999999', + }.entries) { + events = [ + {...event, mismatch.key: mismatch.value}, + ]; + final result = await exchange.updateTrade(trade); + expect(result.value, isNull, reason: mismatch.key); + expect( + result.exception!.message, + contains('Rosen event does not match this swap.'), + reason: mismatch.key, + ); + } + for (final status in [ + 'completed', + { + 'status': 'COMPLETED', + 'reason': 'Override', + 'severity': 'success', + }, + 'MULTIPLE_FLOWS', + ]) { + for (final payout in [null, 'invalid', 'cd' * 32]) { + events = [ + { + ...event, + 'status': status, + 'statuses': ['fraud', 'successful'], + 'paymentTxId': payout, + }, + ]; + final result = await exchange.updateTrade(trade); + expect(result.exception, isNull); + expect( + result.value!.status, + payout == 'cd' * 32 ? 'Finished' : 'Sending', + ); + expect( + result.value!.payOutTxid, + payout == 'cd' * 32 ? payout : '', + ); + } + } + final finished = trade.copyWith( + status: 'Finished', + payOutTxid: 'cd' * 32, + ); + final concurrent = RosenTestHttp((url) async { + expect(url.path, '/api/v1/events'); + await trades.put(trade.uuid, finished); + return {'items': >[]}; + }); + await concurrent.run(() async { + final result = await exchange.updateTrade(trade); + expect(result.exception, isNull); + expect(result.value!.status, 'Finished'); + expect(result.value!.payOutTxid, finished.payOutTxid); + }); + expect(concurrent.requests, hasLength(1)); + expect(trades.get(trade.uuid)!.payInTxid, trade.payInTxid); + }); + }); + + test('polling follows successful overrides and later flows', () async { + await http.run(() async { + var trade = (await create(await estimate())) + .copyWith(payInTxid: 'ab' * 32, status: 'Confirming'); + await trades.put(trade.uuid, trade); + final fees = jsonDecode(trade.other!) as Map; + final event = { + 'sourceTxId': trade.payInTxid, + 'fromChain': fromFiro ? 'firo' : 'ethereum', + 'toChain': fromFiro ? 'ethereum' : 'firo', + 'toAddress': destination, + 'sourceChainTokenId': fromFiro + ? 'FIRO' + : DefaultTokens.rsFiro.address, + 'bridgeFee': fees['bridgeFee'], + 'networkFee': fees['networkFee'], + 'amount': '10000000000', + 'status': 'FRAUD', + 'paymentTxId': null, + }; + events = [event]; + trade = (await exchange.updateTrade(trade)).value!; + expect(trade.status, 'Exchanging'); + await trades.put(trade.uuid, trade); + + for (final status in [ + { + 'status': 'COMPLETED', + 'severity': 'success', + 'reason': 'Override', + }, + 'MULTIPLE_FLOWS', + ]) { + events = [ + { + ...event, + 'status': status, + 'statuses': ['fraud', 'successful'], + 'paymentTxId': 'cd' * 32, + }, + ]; + final result = await exchange.getTrade(trade.tradeId); + expect(result.exception, isNull); + expect(result.value!.status, 'Finished'); + expect(result.value!.payOutTxid, 'cd' * 32); + } + }); + }); + + if (!fromFiro) { + test('polling detects a failed Ethereum receipt', () async { + await http.run(() async { + var trade = (await create(await estimate())) + .copyWith(payInTxid: 'ab' * 32, status: 'Confirming'); + final data = jsonDecode(trade.other!) as Map; + trade = trade.copyWith( + other: jsonEncode({ + ...data, + 'funding': { + 'walletId': 'wallet', + 'txid': 'ab' * 32, + 'raw': '00', + 'state': 'submitting', + }, + }), + ); + await trades.put(trade.uuid, trade); + for (final (status, expected) in [ + (null, 'Confirming'), + ('0x1', 'Confirming'), + ]) { + receiptHttp.status = status; + final result = await exchange.updateTrade(trade); + expect(result.exception, isNull); + expect(result.value!.status, expected); + } + expect( + (jsonDecode(trades.get(trade.uuid)!.other!) + as Map)['funding']['state'], + 'broadcast', + ); + expect(lookups.get(trade.uuid)!.walletIds, ['wallet']); + receiptHttp.status = '0x0'; + receiptHttp.latestBlock = receiptHttp.receiptBlock; + var result = await exchange.updateTrade(trade); + expect(result.exception, isNull); + expect(result.value!.status, 'Confirming'); + receiptHttp.latestBlock = + receiptHttp.receiptBlock + + RosenApi.ethereumConfirmationWindow - + 1; + result = await exchange.updateTrade(trade); + expect(result.exception, isNull); + expect(result.value!.status, 'Failed'); + receiptHttp.status = '0x2'; + final malformed = await exchange.updateTrade(trade); + expect(malformed.value, isNull); + expect(malformed.exception, isNotNull); + + expect(receiptHttp.urls, hasLength(7)); + expect( + receiptHttp.bodies + .map((body) => (jsonDecode(body) as Map)['method']) + .where((method) => method == 'eth_blockNumber'), + hasLength(2), + ); + final request = jsonDecode(receiptHttp.bodies.last) as Map; + expect( + receiptHttp.urls.last, + Uri.parse(EthereumAPI.stackBaseServer), + ); + expect( + receiptHttp.headers.last['content-type'], + 'application/json', + ); + expect(request, { + 'jsonrpc': '2.0', + 'method': 'eth_getTransactionReceipt', + 'params': ['0x${'ab' * 32}'], + 'id': 1, + }); + }); + }); + } + }); + } +} + +class _ReceiptHttp extends HTTP { + String? status; + int receiptBlock = 100; + int latestBlock = 100; + final urls = []; + final headers = >[]; + final bodies = []; + + @override + Future post({ + required Uri url, + Map? headers, + Object? body, + Encoding? encoding, + required ({InternetAddress host, int port})? proxyInfo, + }) async { + urls.add(url); + this.headers.add(headers ?? const {}); + final requestBody = body! as String; + bodies.add(requestBody); + final request = jsonDecode(requestBody) as Map; + return Response( + utf8.encode( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': 1, + 'result': request['method'] == 'eth_blockNumber' + ? '0x${latestBlock.toRadixString(16)}' + : status == null + ? null + : { + 'transactionHash': '0x${'ab' * 32}', + 'status': status, + 'blockNumber': '0x${receiptBlock.toRadixString(16)}', + }, + }), + ), + 200, + ); + } +} diff --git a/test/services/exchange/rosen/rosen_fees_test.dart b/test/services/exchange/rosen/rosen_fees_test.dart new file mode 100644 index 0000000000..4243aa5e5a --- /dev/null +++ b/test/services/exchange/rosen/rosen_fees_test.dart @@ -0,0 +1,95 @@ +import 'package:stackwallet/services/exchange/rosen/rosen_fees.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'rosen_test_utils.dart'; + +void main() { + test('decodes fee schedules and rejects stale or malformed registers', () { + final registers = {...rosenFeeRegisters}; + final amount = BigInt.from(10000000000); + RosenQuote quote({bool fromFiro = true, int height = 1378335}) => + RosenQuote.fromRegisters( + registers, + fromFiro: fromFiro, + height: height, + amount: amount, + ); + final forward = quote(); + expect(forward.hasFees(forward.bridgeFee, forward.networkFee), isTrue); + for (final fees in [ + (forward.bridgeFee + BigInt.one, forward.networkFee), + (forward.bridgeFee - BigInt.one, forward.networkFee), + (forward.bridgeFee, forward.networkFee + BigInt.one), + (forward.bridgeFee, forward.networkFee - BigInt.one), + (forward.bridgeFee + BigInt.one, forward.networkFee - BigInt.one), + ]) { + expect(forward.hasFees(fees.$1, fees.$2), isFalse); + expect( + RosenQuote( + bridgeFee: fees.$1, + networkFee: fees.$2, + minimum: forward.minimum, + receiveAmount: amount - fees.$1 - fees.$2, + ).fingerprint, + isNot(forward.fingerprint), + ); + } + expect( + RosenQuote( + bridgeFee: forward.bridgeFee, + networkFee: forward.networkFee, + minimum: forward.minimum, + receiveAmount: forward.receiveAmount + BigInt.one, + ).fingerprint, + isNot(forward.fingerprint), + ); + expect(forward.bridgeFee, BigInt.from(1129513169)); + expect(forward.networkFee, BigInt.from(1452401)); + expect(forward.minimum, BigInt.from(1130965571)); + expect(forward.receiveAmount, BigInt.from(8869034430)); + expect( + quote(fromFiro: false, height: 25992101).networkFee, + BigInt.from(500452), + ); + expect(quote(height: 1373162).bridgeFee, BigInt.from(1425897447)); + expect(quote(height: 1373163).bridgeFee, BigInt.from(1129513169)); + expect(() => quote(height: 1363914), throwsFormatException); + final large = RosenQuote.fromRegisters( + registers, + fromFiro: true, + height: 1378335, + amount: BigInt.from(1000000000000), + ); + expect(large.bridgeFee, BigInt.from(5000000000)); + registers['R9'] = {'serializedValue': '1d02066464646464640664646464646400'}; + expect(quote, throwsFormatException); + }); + + test('preserves integer fee boundaries and rejects uint64 overflow', () { + final highRatio = { + 'R4': {'serializedValue': '1a02046669726f08657468657265756d'}, + 'R5': {'serializedValue': '1c01020000'}, + 'R6': {'serializedValue': '1d01020000'}, + 'R7': {'serializedValue': '1d0102c801c801'}, + 'R9': {'serializedValue': '1d01029e9c019e9c01'}, + }; + RosenQuote edge(BigInt value) => RosenQuote.fromRegisters( + highRatio, + fromFiro: true, + height: 1, + amount: value, + ); + final minimum = edge(BigInt.zero).minimum; + expect(minimum, BigInt.from(1000001)); + expect(edge(minimum).receiveAmount, BigInt.one); + expect(edge(minimum - BigInt.one).receiveAmount, BigInt.zero); + highRatio['R6'] = { + 'serializedValue': '1d0102feffffffffffffffff01feffffffffffffffff01', + }; + expect(edge(BigInt.zero).bridgeFee, BigInt.parse('9223372036854775807')); + highRatio['R6'] = { + 'serializedValue': '1d0102ffffffffffffffffff02ffffffffffffffffff02', + }; + expect(() => edge(BigInt.zero), throwsFormatException); + }); +} diff --git a/test/services/exchange/rosen/rosen_protocol_test.dart b/test/services/exchange/rosen/rosen_protocol_test.dart new file mode 100644 index 0000000000..b3ef8e105f --- /dev/null +++ b/test/services/exchange/rosen/rosen_protocol_test.dart @@ -0,0 +1,293 @@ +import 'dart:convert'; + +import 'package:coinlib/coinlib.dart' as coinlib; +import 'package:dart_bs58check/dart_bs58check.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:stackwallet/services/exchange/rosen/rosen_protocol.dart'; +import 'package:stackwallet/utilities/extensions/extensions.dart'; +import 'package:wallet/wallet.dart' as eth; +import 'package:web3dart/web3dart.dart' as web3; + +// Decode the signed wire envelope independently of web3dart's RLP encoder. +dynamic _decodeRlp(List bytes) { + var offset = 0; + dynamic read() { + final prefix = bytes[offset++]; + if (prefix < 0x80) return [prefix]; + final isList = prefix >= 0xc0; + var length = prefix - (isList ? 0xc0 : 0x80); + if (length > 55) { + final lengthBytes = length - 55; + length = 0; + for (var i = 0; i < lengthBytes; i++) { + length = length * 256 + bytes[offset++]; + } + } + final end = offset + length; + expect(end, lessThanOrEqualTo(bytes.length)); + if (!isList) { + final value = bytes.sublist(offset, end); + offset = end; + return value; + } + final values = []; + while (offset < end) { + values.add(read()); + } + expect(offset, end); + return values; + } + + final value = read(); + expect(offset, bytes.length); + return value; +} + +void main() { + const ethereum = '0x00112233445566778899aabbccddeeff00112233'; + final firo = bs58check.encode('52${'11' * 20}'.toUint8ListFromHex); + + test('Rosen FIRO metadata matches upstream uint64 BE layout', () { + expect( + RosenProtocol.metadata( + fromFiro: true, + destination: ethereum, + bridgeFee: BigInt.from(123), + networkFee: BigInt.from(456), + ), + '03000000000000007b00000000000001c81400112233445566778899aabbccddeeff00112233', + ); + expect( + RosenProtocol.metadata( + fromFiro: false, + destination: firo, + bridgeFee: BigInt.from(123), + networkFee: BigInt.from(456), + ), + '07000000000000007b00000000000001c81976a914${'11' * 20}88ac', + ); + final p2sh = bs58check.encode('07${'22' * 20}'.toUint8ListFromHex); + expect(RosenProtocol.firoScript(p2sh), 'a914${'22' * 20}87'); + }); + + for (final version in ['52', '07']) { + test('signed rsFIRO broadcast preserves FIRO $version metadata', () async { + const token = '0x2744ea5ac9b11cb5e3cd63d3a88e858336aeddc2'; + const lock = '0x451698faa07fc68301af622a3ad42205f13c6e4b'; + final destination = bs58check.encode( + '$version${'11' * 20}'.toUint8ListFromHex, + ); + final metadata = RosenProtocol.metadata( + fromFiro: false, + destination: destination, + bridgeFee: BigInt.from(123), + networkFee: BigInt.from(456), + ); + final expectedMetadata = + '07000000000000007b00000000000001c8' + '${version == '52' ? '1976a914${'11' * 20}88ac' : '17a914${'11' * 20}87'}'; + final amount = BigInt.parse('9007199254740993'); + final calldata = RosenProtocol.transferData( + lockAddress: lock, + amount: amount, + metadata: metadata, + ); + final expectedCalldata = + 'a9059cbb${lock.substring(2).padLeft(64, '0')}' + '${amount.toRadixString(16).padLeft(64, '0')}$expectedMetadata'; + final txid = '0x${'22' * 32}'; + final client = web3.Web3Client( + 'https://ethereum.invalid', + MockClient( + expectAsync1((request) async { + final rpc = jsonDecode(request.body) as Map; + expect(rpc['method'], 'eth_sendRawTransaction'); + final raw = + ((rpc['params'] as List).single as String).toUint8ListFromHex; + expect(raw.first, 2); // EIP-1559 transaction envelope. + final fields = _decodeRlp(raw.sublist(1)) as List; + expect(fields, hasLength(12)); + expect(fields[0], [1]); // Ethereum mainnet. + expect(fields[5], token.toUint8ListFromHex); + expect(fields[6], isEmpty); // No native ETH is sent. + expect(fields[7], expectedCalldata.toUint8ListFromHex); + expect(fields[8], isEmpty); // Access list. + for (final signature in fields.sublist(10)) { + expect((signature as List).any((byte) => byte != 0), isTrue); + } + return http.Response( + jsonEncode({'jsonrpc': '2.0', 'id': rpc['id'], 'result': txid}), + 200, + ); + }), + ), + ); + addTearDown(client.dispose); + expect( + await client.sendTransaction( + web3.EthPrivateKey.fromHex('01'.padLeft(64, '0')), + web3.Transaction( + to: eth.EthereumAddress.fromHex(token), + value: eth.EtherAmount.zero(), + data: calldata.toUint8ListFromHex, + nonce: 7, + maxGas: 100000, + maxFeePerGas: eth.EtherAmount.inWei(BigInt.from(3000000000)), + maxPriorityFeePerGas: eth.EtherAmount.inWei( + BigInt.from(1000000000), + ), + ), + chainId: 1, + ), + txid, + ); + }); + } + + test('bridge completion requires a payout transaction', () { + expect(RosenProtocol.swapStatus('processing', null), 'Exchanging'); + expect(RosenProtocol.swapStatus('COMPLETED', null), 'Sending'); + expect(RosenProtocol.swapStatus('COMPLETED', 'invalid'), 'Sending'); + expect(RosenProtocol.swapStatus('COMPLETED', '0x${'11' * 32}'), 'Finished'); + expect(RosenProtocol.swapStatus('successful', '11' * 32), 'Finished'); + expect(RosenProtocol.swapStatus('FRAUD', null), 'Exchanging'); + expect(RosenProtocol.swapStatus('unknown', null), 'Exchanging'); + }); + + test('override and multiple-flow status require confirmed completion', () { + final payout = '11' * 32; + expect( + RosenProtocol.swapStatus({'status': 'COMPLETED'}, payout), + 'Finished', + ); + expect( + RosenProtocol.swapStatus({'status': 'COMPLETED'}, 'invalid'), + 'Sending', + ); + expect( + RosenProtocol.swapStatus( + 'MULTIPLE_FLOWS', + payout, + flowStatuses: ['fraud', 'successful'], + ), + 'Finished', + ); + expect( + RosenProtocol.swapStatus( + 'MULTIPLE_FLOWS', + null, + flowStatuses: ['fraud', 'successful'], + ), + 'Sending', + ); + for (final statuses in [ + null, + ['fraud'], + ['processing'], + ['unknown'], + ]) { + expect( + RosenProtocol.swapStatus( + 'MULTIPLE_FLOWS', + payout, + flowStatuses: statuses, + ), + 'Exchanging', + ); + } + expect( + RosenProtocol.swapStatus({'status': 'unknown'}, payout), + 'Exchanging', + ); + expect(RosenProtocol.swapStatus({'status': null}, payout), 'Exchanging'); + }); + + test('amounts stay exact beyond floating-point precision', () { + expect( + RosenProtocol.parseAmount('90071992.54740993'), + BigInt.parse('9007199254740993'), + ); + expect( + RosenProtocol.formatAmount(BigInt.parse('9007199254740993')), + '90071992.54740993', + ); + expect(RosenProtocol.formatAmount(BigInt.one), '0.00000001'); + expect(RosenProtocol.formatAmount(BigInt.from(100000000)), '1'); + for (final amount in [ + '-1', + '1.000000001', + 'NaN', + '1e8', + '184467440737.09551616', + ]) { + expect(() => RosenProtocol.parseAmount(amount), throwsFormatException); + } + }); + + test('invalid network addresses, metadata and fee overflow fail closed', () { + final testnet = bs58check.encode('41${'11' * 20}'.toUint8ListFromHex); + expect(() => RosenProtocol.firoScript(testnet), throwsFormatException); + final witness = coinlib.P2WPKHAddress.fromHash( + ('11' * 20).toUint8ListFromHex, + hrp: 'bc', + ); + expect( + () => RosenProtocol.firoScript(witness.toString()), + throwsFormatException, + ); + expect( + () => RosenProtocol.firoScript('spark1notatransparentaddress'), + throwsA(anything), + ); + expect( + () => RosenProtocol.ethereumAddress('0x${'00' * 20}'), + throwsFormatException, + ); + for (final metadata in ['zz', 'a', 'aa bb', '0xaa', 'aa\n']) { + expect( + () => RosenProtocol.transferData( + lockAddress: ethereum, + amount: BigInt.one, + metadata: metadata, + ), + throwsFormatException, + ); + } + for (final amount in [BigInt.zero, -BigInt.one, BigInt.one << 64]) { + expect( + () => RosenProtocol.transferData( + lockAddress: ethereum, + amount: amount, + metadata: '00', + ), + throwsArgumentError, + ); + } + expect( + () => RosenProtocol.ethereumAddress( + '0x52908400098527886E0F7030069857D2E4169Ee7', + ), + throwsArgumentError, + ); + expect( + () => RosenProtocol.metadata( + fromFiro: true, + destination: ethereum, + bridgeFee: BigInt.one << 64, + networkFee: BigInt.one, + ), + throwsArgumentError, + ); + expect( + () => RosenProtocol.metadata( + fromFiro: true, + destination: ethereum, + bridgeFee: BigInt.one, + networkFee: -BigInt.one, + ), + throwsArgumentError, + ); + }); +} diff --git a/test/services/exchange/rosen/rosen_test_utils.dart b/test/services/exchange/rosen/rosen_test_utils.dart new file mode 100644 index 0000000000..3c78a69ae9 --- /dev/null +++ b/test/services/exchange/rosen/rosen_test_utils.dart @@ -0,0 +1,118 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +// FIRO fee box: +// 346493beb1be3f439c64f71f98c07f7c6707c1f02e8f9505c2366e1f57c67655 +const rosenFeeRegisters = { + 'R4': { + 'serializedValue': + '1a060762696e616e63650d626974636f696e2d72756e65730763617264616e6f' + '046572676f08657468657265756d046669726f', + }, + 'R5': { + 'serializedValue': + '1c02069eee8d70c4d175ce869a0db2d8e201f8abcf1894bfa60106d6f2fd72b' + 'af575fc9aa20dd687e401a08ddd18d4cfa701', + }, + 'R6': { + 'serializedValue': + '1d0206cedfebcf0acedfebcf0acedfebcf0acedfebcf0acedfebcf0acedfebcf0a' + '06a28398b508a28398b508a28398b508a28398b508a28398b508a28398b508', + }, + 'R7': { + 'serializedValue': + '1d0206b4faee01a4f1dc4ae8e2f976dc9db807ac80bb07c88b3d06d2e9cb01' + 'dae9e35fe8f6945fe0e1ce05e2a5b101c88b3d', + }, + 'R9': {'serializedValue': '1d020664646464646406646464646464'}, +}; + +Map rosenFeeBox() => { + 'mainChain': true, + 'spentTransactionId': null, + 'assets': [ + { + 'tokenId': + 'e2ed4d64393222db666f20e67803e9e6fbe6d64531e14ff52ddd95615b0cbf17', + }, + { + 'tokenId': + '581d7df25808881b2b8b9b4e03e2f637c46a94f74a69a5da36434125bacb4e08', + }, + ], + 'additionalRegisters': rosenFeeRegisters, +}; + +// Exercise the real HTTP wrapper without replacing production singletons or +// opening sockets. Unimplemented HTTP operations fail through Fake. +class RosenTestHttp extends HttpOverrides { + RosenTestHttp(this.respond, {this.statusCode = 200}); + + final FutureOr Function(Uri) respond; + final int statusCode; + final requests = []; + + Future run(Future Function() action) => + HttpOverrides.runZoned(action, createHttpClient: createHttpClient); + + @override + HttpClient createHttpClient(SecurityContext? context) => _Client(this); +} + +class _Client extends Fake implements HttpClient { + _Client(this.http); + final RosenTestHttp http; + + @override + set connectionTimeout(Duration? timeout) {} + @override + void close({bool force = false}) {} + @override + Future getUrl(Uri url) async { + http.requests.add(url); + return _Request(http, url); + } +} + +class _Request extends Fake implements HttpClientRequest { + _Request(this.http, this.url); + final RosenTestHttp http; + final Uri url; + + @override + final HttpHeaders headers = _Headers(); + @override + Future close() async => + _Response(jsonEncode(await http.respond(url)), http.statusCode); +} + +class _Response extends Fake implements HttpClientResponse { + _Response(this.body, this.statusCode); + final String body; + @override + final int statusCode; + @override + final HttpHeaders headers = _Headers(); + @override + StreamSubscription> listen( + void Function(List)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => Stream>.value(utf8.encode(body)).listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); +} + +class _Headers extends Fake implements HttpHeaders { + @override + void add(String name, Object value, {bool preserveHeaderCase = false}) {} + @override + void forEach(void Function(String, List) action) {} +} diff --git a/test/services/exchange/rosen_registration_test.dart b/test/services/exchange/rosen_registration_test.dart new file mode 100644 index 0000000000..d78c2627df --- /dev/null +++ b/test/services/exchange/rosen_registration_test.dart @@ -0,0 +1,2592 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:isar_community/isar.dart'; +// Electrum's transitive dependency is the production error type under test. +// ignore: depend_on_referenced_packages +import 'package:json_rpc_2/json_rpc_2.dart' show RpcException; +import 'package:stackwallet/db/hive/db.dart'; +import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; +import 'package:stackwallet/models/exchange/response_objects/trade.dart'; +import 'package:stackwallet/models/notification_model.dart'; +import 'package:stackwallet/models/trade_wallet_lookup.dart'; +import 'package:stackwallet/models/input.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/utxo.dart'; +import 'package:stackwallet/models/isar/models/ethereum/eth_contract.dart'; +import 'package:stackwallet/networking/http.dart'; +import 'package:stackwallet/services/ethereum/ethereum_api.dart'; +import 'package:stackwallet/services/trade_service.dart'; +import 'package:stackwallet/models/exchange/change_now/cn_exchange_transaction_status.dart'; +import 'package:stackwallet/services/exchange/exchange.dart'; +import 'package:stackwallet/services/exchange/change_now/change_now_exchange.dart'; +import 'package:stackwallet/services/exchange/rosen/rosen_api.dart'; +import 'package:stackwallet/services/exchange/rosen/rosen_exchange.dart'; +import 'package:stackwallet/services/exchange/rosen/rosen_funding.dart'; +import 'package:stackwallet/services/exchange/rosen/rosen_protocol.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/default_eth_tokens.dart'; +import 'package:stackwallet/utilities/extensions/extensions.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; +import 'package:stackwallet/services/node_service.dart'; +import 'package:stackwallet/services/notifications_service.dart'; +import 'package:stackwallet/services/wallets.dart'; +import 'package:stackwallet/exceptions/exchange/exchange_exception.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/impl/ethereum_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart'; +import 'package:wallet/wallet.dart' as eth; +import 'package:web3dart/json_rpc.dart' show RPCError; +import 'package:web3dart/web3dart.dart' as web3; + +import '../../support/isar_test_utils.dart'; +import 'rosen/rosen_test_utils.dart'; + +void main() { + group('Rosen funding rejects unsafe transactions before RPC or signing', () { + final trade = _rosenRequest(false); + late Directory directory; + late Box trades; + final recipient = TxRecipient( + address: trade.payInAddress, + amount: Amount(rawValue: BigInt.from(100000000), fractionDigits: 8), + isChange: false, + addressType: Ethereum(CryptoCurrencyNetwork.main).defaultAddressType, + ); + final maxFee = BigInt.from(30); + final priorityFee = BigInt.from(2); + const gasLimit = 120000; + const nonce = 7; + final transaction = web3.Transaction( + to: eth.EthereumAddress.fromHex(DefaultTokens.rsFiro.address), + value: eth.EtherAmount.zero(), + data: RosenProtocol.transferData( + lockAddress: trade.payInAddress, + amount: recipient.amount.raw, + metadata: RosenExchange.validatedMetadata(trade), + ).toUint8ListFromHex, + maxGas: gasLimit, + nonce: nonce, + maxFeePerGas: eth.EtherAmount.inWei(maxFee), + maxPriorityFeePerGas: eth.EtherAmount.inWei(priorityFee), + ); + final prepared = TxData( + recipients: [recipient], + fee: Amount(rawValue: BigInt.from(gasLimit) * maxFee, fractionDigits: 18), + chainId: BigInt.one, + nonce: nonce, + web3dartTransaction: transaction, + ); + Matcher rejected(String message) => + throwsA(isA().having((e) => e.message, 'message', message)); + + setUp(() async { + (directory, trades) = await _openTrades(); + await trades.put(trade.uuid, trade); + }); + + tearDown(() async { + await trades.close(); + await directory.delete(recursive: true); + }); + + test( + 'view-only and wrong-source wallets cannot prepare or confirm', + () async { + for (final (wallet, request) in [ + (_FundingWallet(viewOnly: true), trade), + (_FundingWallet(), _rosenRequest(true)), + ]) { + await expectLater( + RosenFunding.prepareSend(wallet: wallet, trade: request), + rejected('Choose a spendable wallet on the source network.'), + ); + await expectLater( + RosenFunding.confirmSend( + wallet: wallet, + trade: request, + txData: prepared, + ), + rejected('Invalid bridge source wallet.'), + ); + expect(wallet.rpcAttempts, 0); + } + }, + ); + + test( + 'recipient, amount and recipient-count changes are rejected', + () async { + final wallet = _FundingWallet(); + final half = Amount(rawValue: BigInt.from(50000000), fractionDigits: 8); + final cases = ?>{ + 'missing recipients': null, + 'empty recipients': [], + 'change only': [recipient.copyWith(isChange: true)], + 'wrong amount': [recipient.copyWith(amount: half)], + 'two recipients with the correct total': [ + recipient.copyWith(amount: half), + recipient.copyWith(amount: half), + ], + 'wrong recipient': [ + recipient.copyWith(address: DefaultTokens.rsFiro.address), + ], + }; + for (final entry in cases.entries) { + await expectLater( + RosenFunding.confirmSend( + wallet: wallet, + trade: trade, + txData: TxData( + recipients: entry.value, + chainId: prepared.chainId, + web3dartTransaction: transaction, + ), + ), + rejected('The transaction does not match this bridge swap.'), + reason: entry.key, + ); + } + expect(wallet.rpcAttempts, 0); + }, + ); + + test( + 'invalid rsFIRO envelopes are rejected and release the send lock', + () async { + final wallet = _FundingWallet(); + final cases = { + 'missing transaction': TxData( + recipients: [recipient], + chainId: BigInt.one, + ), + 'missing chain': TxData( + recipients: [recipient], + web3dartTransaction: transaction, + ), + 'wrong chain': prepared.copyWith(chainId: BigInt.from(11155111)), + 'wrong displayed fee': prepared.copyWith( + fee: Amount( + rawValue: prepared.fee!.raw + BigInt.one, + fractionDigits: 18, + ), + ), + 'wrong prepared nonce': prepared.copyWith(nonce: nonce + 1), + for (final entry in { + 'missing token': web3.Transaction( + value: transaction.value, + data: transaction.data, + ), + 'wrong token': transaction.copyWith( + to: eth.EthereumAddress.fromHex(trade.payInAddress), + ), + 'native ETH value': transaction.copyWith( + value: eth.EtherAmount.inWei(BigInt.one), + ), + 'missing calldata': web3.Transaction( + to: transaction.to, + value: transaction.value, + ), + 'altered calldata': transaction.copyWith( + data: ('${transaction.data!.toHex}00').toUint8ListFromHex, + ), + 'legacy gas price': transaction.copyWith( + gasPrice: eth.EtherAmount.inWei(BigInt.one), + ), + 'zero gas limit': transaction.copyWith(maxGas: 0), + 'wrong transaction nonce': transaction.copyWith(nonce: nonce + 1), + 'priority above max fee': transaction.copyWith( + maxPriorityFeePerGas: eth.EtherAmount.inWei(maxFee + BigInt.one), + ), + }.entries) + entry.key: prepared.copyWith(web3dartTransaction: entry.value), + }; + for (final entry in cases.entries) { + await expectLater( + RosenFunding.confirmSend( + wallet: wallet, + trade: trade, + txData: entry.value, + ), + rejected('Invalid rsFIRO bridge transaction.'), + reason: entry.key, + ); + } + expect(wallet.rpcAttempts, 0); + + // The valid control passes these guards after every failed attempt, but + // stops at the fake client's boundary without node access or signing. + await expectLater( + RosenFunding.confirmSend( + wallet: wallet, + trade: trade, + txData: prepared, + ), + throwsA(same(_FundingWallet.rpcBoundary)), + ); + expect(wallet.rpcAttempts, 1); + }, + ); + + test('an unresolved journal blocks another swap from the wallet', () async { + final wallet = _FundingWallet(); + final pending = _rosenRequest(false, suffix: '-pending').copyWith( + payInTxid: '11' * 32, + status: 'Confirming', + other: jsonEncode({ + ...jsonDecode(_rosenRequest(false).other!) as Map, + RosenFunding.fundingKey: { + 'walletId': wallet.walletId, + 'txid': '11' * 32, + 'raw': '0200', + 'state': RosenFunding.submittingState, + }, + }), + ); + await trades.put(pending.uuid, pending); + + await expectLater( + RosenFunding.confirmSend( + wallet: wallet, + trade: trade, + txData: prepared, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'A previous Rosen deposit from this wallet is still being ' + 'submitted.', + ), + ), + ); + expect(wallet.rpcAttempts, 1); + }); + }); + + test( + 'Rosen is a swap provider with distinct native and Ethereum assets', + () async { + final exchange = Exchange.fromName('Rosen Bridge'); + expect(exchange, same(RosenExchange.instance)); + expect(exchange.supportsRefundAddress, isFalse); + + final currencies = (await exchange.getAllCurrencies(false)).value!; + expect(currencies, hasLength(2)); + final firo = currencies.singleWhere((coin) => coin.ticker == 'FIRO'); + final token = currencies.singleWhere((coin) => coin.ticker == 'rsFIRO'); + expect(firo.getFuzzyNet(), 'firo'); + expect(firo.tokenContract, isNull); + expect(token.getFuzzyNet(), 'eth'); + expect(token.tokenContract, DefaultTokens.rsFiro.address); + expect(token.supportsEstimatedRate, isTrue); + expect(token.supportsFixedRate, isFalse); + expect((await exchange.getAllCurrencies(true)).value, isEmpty); + + // These statuses drive existing history icons and notification polling. + for (final status in [ + 'Waiting', + 'Confirming', + 'Exchanging', + 'Finished', + ]) { + expect( + changeNowTransactionStatusFromStringIgnoreCase(status).name, + status, + ); + } + }, + ); + test( + 'a late swap poll cannot erase a bridge deposit or completion', + () async { + final (directory, box) = await _openTrades(); + final service = TradesService(); + final waiting = Trade.fromMap({ + 'uuid': 'rosen-test', + 'tradeId': 'rosen-test', + 'rateType': 'estimated', + 'direction': 'direct', + 'timestamp': '2026-09-17T00:00:00Z', + 'updatedAt': '2026-09-17T00:00:00Z', + 'payInCurrency': 'FIRO', + 'payInAmount': '1', + 'payInAddress': 'lock', + 'payInNetwork': 'firo', + 'payInExtraId': '', + 'payInTxid': '', + 'payOutCurrency': 'rsFIRO', + 'payOutAmount': '0.9', + 'payOutAddress': 'recipient', + 'payOutNetwork': 'eth', + 'payOutExtraId': '', + 'payOutTxid': '', + 'refundAddress': '', + 'refundExtraId': '', + 'status': 'Waiting', + 'exchangeName': RosenExchange.exchangeName, + }); + try { + await service.add(trade: waiting, shouldNotifyListeners: false); + final funded = waiting.copyWith( + payInTxid: 'deposit', + status: 'Confirming', + ); + await Future.wait([ + service.edit(trade: funded, shouldNotifyListeners: false), + service.edit(trade: waiting, shouldNotifyListeners: false), + ]); + expect(service.get(waiting.tradeId)!.payInTxid, 'deposit'); + expect(service.get(waiting.tradeId)!.status, 'Confirming'); + await service.edit( + trade: funded.copyWith(status: 'Verifying'), + shouldNotifyListeners: false, + ); + await service.edit(trade: funded, shouldNotifyListeners: false); + expect(service.get(waiting.tradeId)!.status, 'Verifying'); + final finished = funded.copyWith( + status: 'Finished', + payOutTxid: 'payout', + ); + await Future.wait([ + service.edit(trade: finished, shouldNotifyListeners: false), + service.edit(trade: funded, shouldNotifyListeners: false), + ]); + expect(service.get(waiting.tradeId)!.status, 'Finished'); + expect(service.get(waiting.tradeId)!.payOutTxid, 'payout'); + } finally { + service.dispose(); + await box.close(); + await directory.delete(recursive: true); + } + }, + ); + + test('Rosen records signed funding durably and idempotently', () async { + final (directory, trades) = await _openTrades(); + final lookups = await DB.instance.hive.openBox( + DB.boxNameTradeLookup, + ); + final trade = _rosenRequest(false); + final txid = '11' * 32; + const raw = '0200'; + try { + await trades.put(trade.uuid, trade); + await expectLater( + RosenFunding.recordFundingIntent( + trade: trade.copyWith( + updatedAt: trade.updatedAt.add(const Duration(seconds: 1)), + ), + walletId: 'wallet', + txid: txid, + raw: raw, + ), + throwsA( + isA().having( + (e) => e.type, + 'type', + ExchangeExceptionType.quoteChanged, + ), + ), + ); + await RosenFunding.recordFundingIntent( + trade: trade, + walletId: 'wallet', + txid: txid, + raw: raw, + ); + await RosenFunding.recordFundingIntent( + trade: trade, + walletId: 'wallet', + txid: txid, + raw: raw, + ); + + final stored = trades.get(trade.uuid)!; + expect(stored.payInTxid, txid); + expect(stored.status, 'Verifying'); + expect((jsonDecode(stored.other!) as Map)[RosenFunding.fundingKey], { + 'walletId': 'wallet', + 'txid': txid, + 'raw': raw, + 'state': RosenFunding.submittingState, + }); + expect(lookups.get(trade.uuid)!.txid, txid); + await expectLater( + RosenFunding.recordFundingIntent( + trade: trade, + walletId: 'wallet', + txid: '22' * 32, + raw: raw, + ), + throwsStateError, + ); + await expectLater( + RosenFunding.recordFundingIntent( + trade: trade, + walletId: 'wallet', + txid: txid, + raw: '0300', + ), + throwsStateError, + ); + + final repairable = _rosenRequest(false, suffix: '-lookup-repair'); + await trades.put(repairable.uuid, repairable); + await lookups.close(); + await RosenFunding.recordFundingIntent( + trade: repairable, + walletId: 'wallet', + txid: '33' * 32, + raw: raw, + ); + expect( + _fundingState(trades.get(repairable.uuid)!), + RosenFunding.submittingState, + ); + } finally { + if (lookups.isOpen) await lookups.close(); + await trades.close(); + await directory.delete(recursive: true); + } + }); + + test('restored bridge journals change only their cloned wallet ID', () { + for (final fromFiro in [true, false]) { + final request = _recoveryTrade(fromFiro, suffix: '-restored'); + final funding = { + 'walletId': 'original-wallet', + 'txid': '11' * 32, + 'raw': '0200', + 'state': RosenFunding.submittingState, + 'height': fromFiro ? 1378335 : 25992101, + if (fromFiro) + 'inputs': [ + {'txid': '22' * 32, 'vout': 1, 'scripthash': 'input-script'}, + ] + else + 'nonce': 7, + }; + final data = { + ...jsonDecode(request.other!) as Map, + RosenFunding.fundingKey: funding, + }; + final signed = request.copyWith( + payInTxid: funding['txid']! as String, + status: 'Verifying', + other: jsonEncode(data), + ); + final restored = RosenFunding.remapFundingWallet(signed, { + 'original-wallet': 'cloned-wallet', + }); + expect(RosenFunding.fundingWalletId(restored), 'cloned-wallet'); + expect(restored.toMap(), {...signed.toMap(), 'other': restored.other}); + expect(jsonDecode(restored.other!), { + ...data, + RosenFunding.fundingKey: {...funding, 'walletId': 'cloned-wallet'}, + }); + expect(RosenFunding.remapFundingWallet(signed, {}), same(signed)); + expect( + RosenFunding.remapFundingWallet(request, { + 'original-wallet': 'cloned-wallet', + }), + same(request), + ); + final otherProvider = signed.copyWith( + exchangeName: ChangeNowExchange.exchangeName, + ); + expect( + RosenFunding.remapFundingWallet(otherProvider, { + 'original-wallet': 'cloned-wallet', + }), + same(otherProvider), + ); + expect( + () => RosenFunding.remapFundingWallet(signed, {'original-wallet': ''}), + throwsFormatException, + ); + } + }); + + test('Rosen replays only the journaled chain transaction', () async { + final (directory, trades) = await _openTrades(); + final lookups = await DB.instance.hive.openBox( + DB.boxNameTradeLookup, + ); + const raw = '0x0200'; + final txid = web3.bytesToHex( + web3.keccak256(raw.toUint8ListFromHex), + include0x: true, + ); + + Future journal( + String suffix, + _RecoveryClient client, { + int? nonce, + }) async { + final trade = _recoveryTrade(false, suffix: suffix); + final wallet = _FundingWallet( + client: client, + walletId: 'recovery-wallet-$suffix', + ); + await trades.put(trade.uuid, trade); + await RosenFunding.recordFundingIntent( + trade: trade, + walletId: wallet.walletId, + txid: txid, + raw: raw, + nonce: nonce, + sourceHeight: nonce == null ? null : 25992000, + ); + return RosenFunding.recoverFundingIntent(wallet: wallet, trade: trade); + } + + final http = RosenTestHttp((url) { + expect(url.host, 'api.ergoplatform.com'); + return { + 'items': [rosenFeeBox()], + 'total': 1, + }; + }); + try { + await http.run(() async { + final rejected = _RecoveryClient(txid: txid, failures: 1); + await expectLater(journal('rejected', rejected), throwsStateError); + var stored = trades.get( + _recoveryTrade(false, suffix: 'rejected').uuid, + )!; + expect(_fundingState(stored), RosenFunding.submittingState); + await lookups.delete(stored.uuid); + final wallet = _FundingWallet( + client: rejected, + walletId: 'recovery-wallet-rejected', + ); + await RosenFunding.recoverFundingIntent(wallet: wallet, trade: stored); + stored = trades.get(stored.uuid)!; + expect(_fundingState(stored), RosenFunding.broadcastState); + expect(rejected.sentRaw, [raw, raw]); + expect(lookups.get(stored.uuid)!.walletIds, [ + 'recovery-wallet-rejected', + ]); + + final lost = _RecoveryClient(txid: txid, loseFirstResponse: true); + final recovered = await journal('lost', lost); + expect(_fundingState(recovered), RosenFunding.broadcastState); + expect(lost.sentRaw, [raw]); + expect(lost.lookups, 2); + expect(RosenFunding.fundingWalletId(recovered), isNull); + expect( + RosenFunding.recoveryWalletId(recovered), + 'recovery-wallet-lost', + ); + lost.known = false; + final restored = await RosenFunding.recoverFundingIntent( + wallet: _FundingWallet( + client: lost, + walletId: 'recovery-wallet-lost', + ), + trade: recovered, + ); + expect(_fundingState(restored), RosenFunding.broadcastState); + expect(lost.sentRaw, [raw, raw]); + lost.known = false; + final progressed = restored.copyWith(status: 'Exchanging'); + lost.onLookup = () => trades.put(progressed.uuid, progressed); + final progressedWallet = _FundingWallet( + client: lost, + walletId: 'recovery-wallet-lost', + ); + expect(RosenFunding.recoveryWalletId(progressed), isNull); + expect( + (await RosenFunding.recoverFundingIntent( + wallet: progressedWallet, + trade: progressed, + )).status, + 'Exchanging', + ); + expect(trades.get(progressed.uuid)!.status, 'Exchanging'); + expect(lost.sentRaw, [raw, raw]); + + final receiptOnly = _RecoveryClient(txid: txid, receiptKnown: true); + final receiptRecovered = await journal('receipt', receiptOnly); + expect(_fundingState(receiptRecovered), RosenFunding.broadcastState); + expect(receiptOnly.sentRaw, isEmpty); + + final blockIndexed = _RecoveryClient( + txid: txid, + confirmedNonce: 8, + nonceTransitionHeight: 25992025, + blockTransactionHash: txid, + ); + final blockRecovered = await journal( + 'block-indexed', + blockIndexed, + nonce: 7, + ); + expect(_fundingState(blockRecovered), RosenFunding.broadcastState); + expect(blockRecovered.status, 'Confirming'); + expect(blockIndexed.sentRaw, isEmpty); + + final delegatedNonce = _RecoveryClient( + txid: txid, + confirmedNonce: 8, + nonceTransitionHeight: 25992025, + includeNonceTransaction: false, + ); + final delegated = await journal( + 'delegated-nonce', + delegatedNonce, + nonce: 7, + ); + expect(delegated.status, 'Failed'); + expect(RosenFunding.fundingWalletId(delegated), isNull); + expect(delegatedNonce.sentRaw, isEmpty); + + final pendingNonce = _RecoveryClient( + txid: txid, + confirmedNonce: 8, + nonceTransitionHeight: 25992200, + ); + await expectLater( + journal('pending-nonce', pendingNonce, nonce: 7), + throwsStateError, + ); + final pendingTrade = trades.get( + _recoveryTrade(false, suffix: 'pending-nonce').uuid, + )!; + expect(_fundingState(pendingTrade), RosenFunding.needsAttentionState); + expect(RosenFunding.fundingWalletId(pendingTrade), isNotNull); + expect(pendingNonce.sentRaw, isEmpty); + + final malformedBlock = _RecoveryClient( + txid: txid, + confirmedNonce: 8, + nonceTransitionHeight: 25992025, + malformedBlock: true, + ); + await expectLater( + journal('malformed-block', malformedBlock, nonce: 7), + throwsFormatException, + ); + final malformedTrade = trades.get( + _recoveryTrade(false, suffix: 'malformed-block').uuid, + )!; + expect(RosenFunding.fundingWalletId(malformedTrade), isNotNull); + expect(malformedBlock.sentRaw, isEmpty); + + for (final history in ['state', 'block']) { + for (final exactKnown in [false, true]) { + final suffix = 'pruned-$history${exactKnown ? '-exact' : ''}'; + final client = _RecoveryClient( + txid: txid, + confirmedNonce: 8, + historicalStateUnavailableBefore: history == 'state' + ? 25992052 + : null, + nonceTransitionHeight: history == 'block' ? 25992025 : null, + blockRpcError: history == 'block' ? 'block history pruned' : null, + knownAfterLookups: exactKnown ? 2 : null, + ); + final recovery = journal(suffix, client, nonce: 7); + if (exactKnown) { + final recovered = await recovery; + expect(_fundingState(recovered), RosenFunding.broadcastState); + } else { + await expectLater(recovery, throwsStateError); + final stored = trades.get( + _recoveryTrade(false, suffix: suffix).uuid, + )!; + expect(_fundingState(stored), RosenFunding.needsAttentionState); + expect(RosenFunding.fundingWalletId(stored), isNotNull); + } + expect(client.sentRaw, isEmpty); + } + } + + final unavailable = _RecoveryClient( + txid: txid, + confirmedNonce: 8, + historicalStateUnavailableBefore: 25992052, + historicalStateError: 'historical state query rate limited', + ); + await expectLater( + journal('history-rpc-error', unavailable, nonce: 7), + throwsA(isA()), + ); + final unavailableTrade = trades.get( + _recoveryTrade(false, suffix: 'history-rpc-error').uuid, + )!; + expect(RosenFunding.fundingWalletId(unavailableTrade), isNotNull); + expect(unavailable.sentRaw, isEmpty); + + const firoRaw = + '0100000001000000000000000000000000000000000000000000000000000000' + '00000000000000000000ffffffff0100000000000000000000000000'; + const conflictRaw = + '0100000001000000000000000000000000000000000000000000000000000000' + '00000000000000000000ffffffff0101000000000000000000000000'; + final firoTxid = firoTransactionFromHex(firoRaw).txid; + final conflictTxid = decodeFiroTransaction(conflictRaw).txid; + final firoTrade = _recoveryTrade(true, suffix: 'firo'); + final electrum = _RecoveryElectrumClient( + txid: firoTxid, + raw: firoRaw, + loseFirstResponse: true, + ); + final firoWallet = _FiroRecoveryWallet(electrum); + await trades.put(firoTrade.uuid, firoTrade); + await RosenFunding.recordFundingIntent( + trade: firoTrade, + walletId: firoWallet.walletId, + txid: firoTxid, + raw: firoRaw, + ); + final firoRecovered = await RosenFunding.recoverFundingIntent( + wallet: firoWallet, + trade: firoTrade, + ); + expect(_fundingState(firoRecovered), RosenFunding.broadcastState); + expect(electrum.sentRaw, [firoRaw]); + expect(electrum.lookups, 2); + electrum.known = false; + expect(RosenFunding.fundingWalletId(firoRecovered), isNull); + expect( + RosenFunding.recoveryWalletId(firoRecovered), + firoWallet.walletId, + ); + final firoRestored = await RosenFunding.recoverFundingIntent( + wallet: firoWallet, + trade: firoRecovered, + ); + expect(_fundingState(firoRestored), RosenFunding.broadcastState); + expect(electrum.sentRaw, [firoRaw, firoRaw]); + + final historyFiro = _recoveryTrade(true, suffix: 'firo-history'); + final historyElectrum = _RecoveryElectrumClient( + txid: firoTxid, + raw: firoRaw, + conflictTxid: firoTxid, + conflictRaw: firoRaw, + )..conflictVisible = true; + final historyFiroWallet = _FiroRecoveryWallet(historyElectrum); + await trades.put(historyFiro.uuid, historyFiro); + await RosenFunding.recordFundingIntent( + trade: historyFiro, + walletId: historyFiroWallet.walletId, + txid: firoTxid, + raw: firoRaw, + sourceHeight: 1378335, + firoInputs: [ + {'txid': '00' * 32, 'vout': 0, 'scripthash': 'input-script'}, + ], + ); + final historyRecovered = await RosenFunding.recoverFundingIntent( + wallet: historyFiroWallet, + trade: historyFiro, + ); + expect(_fundingState(historyRecovered), RosenFunding.broadcastState); + expect(historyElectrum.candidateLookups, 1); + expect(historyElectrum.sentRaw, isEmpty); + + final mismatchedFiro = _recoveryTrade(true, suffix: 'firo-mismatch'); + final mismatchedElectrum = _RecoveryElectrumClient( + txid: firoTxid, + raw: firoRaw, + conflictTxid: conflictTxid, + conflictRaw: firoRaw, + )..conflictVisible = true; + final mismatchedWallet = _FiroRecoveryWallet(mismatchedElectrum); + await trades.put(mismatchedFiro.uuid, mismatchedFiro); + await RosenFunding.recordFundingIntent( + trade: mismatchedFiro, + walletId: mismatchedWallet.walletId, + txid: firoTxid, + raw: firoRaw, + sourceHeight: 1378335, + firoInputs: [ + {'txid': '00' * 32, 'vout': 0, 'scripthash': 'input-script'}, + ], + ); + await expectLater( + RosenFunding.recoverFundingIntent( + wallet: mismatchedWallet, + trade: mismatchedFiro, + ), + throwsFormatException, + ); + expect( + RosenFunding.fundingWalletId(trades.get(mismatchedFiro.uuid)!), + mismatchedWallet.walletId, + ); + expect(mismatchedElectrum.sentRaw, isEmpty); + + final rejectedFiro = _recoveryTrade(true, suffix: 'firo-rejected'); + final rejectedElectrum = _RecoveryElectrumClient( + txid: firoTxid, + raw: firoRaw, + rpcFailure: true, + conflictTxid: conflictTxid, + conflictRaw: conflictRaw, + ); + final rejectedFiroWallet = _FiroRecoveryWallet(rejectedElectrum); + await trades.put(rejectedFiro.uuid, rejectedFiro); + await RosenFunding.recordFundingIntent( + trade: rejectedFiro, + walletId: rejectedFiroWallet.walletId, + txid: firoTxid, + raw: firoRaw, + sourceHeight: 1378335, + firoInputs: [ + {'txid': '11' * 32, 'vout': 0, 'scripthash': 'input-script'}, + {'txid': '00' * 32, 'vout': 0, 'scripthash': 'input-script'}, + ], + ); + await expectLater( + RosenFunding.recoverFundingIntent( + wallet: rejectedFiroWallet, + trade: rejectedFiro, + ), + throwsStateError, + ); + final rejectedFiroStored = trades.get(rejectedFiro.uuid)!; + expect( + _fundingState(rejectedFiroStored), + RosenFunding.needsAttentionState, + ); + expect(rejectedFiroStored.status, 'Verifying'); + expect( + RosenFunding.fundingWalletId(rejectedFiroStored), + rejectedFiroWallet.walletId, + ); + await expectLater( + RosenFunding.recoverFundingIntent( + wallet: rejectedFiroWallet, + trade: rejectedFiroStored, + ), + throwsStateError, + ); + expect( + RosenFunding.fundingWalletId(trades.get(rejectedFiro.uuid)!), + rejectedFiroWallet.walletId, + ); + rejectedElectrum.conflictHeight = 1378326; + final superseded = await RosenFunding.recoverFundingIntent( + wallet: rejectedFiroWallet, + trade: trades.get(rejectedFiro.uuid)!, + ); + expect(superseded.status, 'Failed'); + expect(_fundingState(superseded), RosenFunding.failedState); + expect(RosenFunding.fundingWalletId(superseded), isNull); + expect(rejectedElectrum.sentRaw, [firoRaw]); + + final rateLimited = _RecoveryClient( + txid: txid, + failures: 1, + rpcFailure: true, + ); + await expectLater( + journal('rate-limited', rateLimited), + throwsA(isA()), + ); + var retrying = trades.get( + _recoveryTrade(false, suffix: 'rate-limited').uuid, + )!; + expect(_fundingState(retrying), RosenFunding.submittingState); + retrying = await RosenFunding.recoverFundingIntent( + wallet: _FundingWallet( + client: rateLimited, + walletId: 'recovery-wallet-rate-limited', + ), + trade: retrying, + ); + expect(_fundingState(retrying), RosenFunding.broadcastState); + expect(rateLimited.sentRaw, [raw, raw]); + + final wrongChain = _RecoveryClient( + txid: txid, + chainId: BigInt.from(11155111), + ); + await expectLater( + journal('wrong-chain', wrongChain), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'The Ethereum node must use mainnet.', + ), + ), + ); + expect(wrongChain.sentRaw, isEmpty); + + final nonceTooLow = _RecoveryClient( + txid: txid, + failures: 1, + rpcFailure: true, + rpcMessage: 'nonce too low', + confirmedNonce: 7, + ); + await expectLater( + journal('nonce', nonceTooLow, nonce: 7), + throwsStateError, + ); + var nonceTrade = trades.get( + _recoveryTrade(false, suffix: 'nonce').uuid, + )!; + expect(_fundingState(nonceTrade), RosenFunding.needsAttentionState); + nonceTooLow.confirmedNonce = 8; + nonceTrade = await RosenFunding.recoverFundingIntent( + wallet: _FundingWallet( + client: nonceTooLow, + walletId: 'recovery-wallet-nonce', + ), + trade: nonceTrade, + ); + expect(nonceTrade.status, 'Failed'); + expect(RosenFunding.fundingWalletId(nonceTrade), isNull); + expect(nonceTooLow.sentRaw, [raw]); + + final stale = _rosenRequest(false, suffix: 'stale'); + final staleClient = _RecoveryClient(txid: txid); + final staleWallet = _FundingWallet( + client: staleClient, + walletId: 'recovery-wallet-stale', + ); + await trades.put(stale.uuid, stale); + await RosenFunding.recordFundingIntent( + trade: stale, + walletId: staleWallet.walletId, + txid: txid, + raw: raw, + ); + await expectLater( + RosenFunding.recoverFundingIntent(wallet: staleWallet, trade: stale), + throwsStateError, + ); + final staleStored = trades.get(stale.uuid)!; + expect(staleStored.status, 'Verifying'); + expect(_fundingState(staleStored), RosenFunding.needsAttentionState); + expect(staleClient.sentRaw, isEmpty); + await trades.put( + staleStored.uuid, + staleStored.copyWith(status: 'Confirming'), + ); + await expectLater( + RosenFunding.recoverFundingIntent( + wallet: staleWallet, + trade: staleStored, + ), + throwsStateError, + ); + expect(trades.get(stale.uuid)!.status, 'Verifying'); + final droppedAtOldFees = _rosenRequest( + false, + suffix: 'dropped-at-old-fees', + ); + final droppedClient = _RecoveryClient(txid: txid); + final droppedWallet = _FundingWallet( + client: droppedClient, + walletId: 'dropped-old-fees-wallet', + ); + await trades.put(droppedAtOldFees.uuid, droppedAtOldFees); + await RosenFunding.recordFundingIntent( + trade: droppedAtOldFees, + walletId: droppedWallet.walletId, + txid: txid, + raw: raw, + ); + final dropped = trades.get(droppedAtOldFees.uuid)!; + await trades.put( + dropped.uuid, + dropped.copyWith( + status: 'Confirming', + other: jsonEncode({ + ...jsonDecode(dropped.other!) as Map, + RosenFunding.fundingKey: { + ...(jsonDecode(dropped.other!) as Map)[RosenFunding.fundingKey] + as Map, + 'state': RosenFunding.broadcastState, + }, + }), + ), + ); + await expectLater( + RosenFunding.recoverFundingIntent( + wallet: droppedWallet, + trade: dropped, + ), + throwsStateError, + ); + expect( + _fundingState(trades.get(dropped.uuid)!), + RosenFunding.needsAttentionState, + ); + expect(droppedClient.sentRaw, isEmpty); + }); + } finally { + await lookups.close(); + await trades.close(); + await directory.delete(recursive: true); + } + }); + + test( + 'bridge recovery cannot delay existing provider notifications', + () async { + final (directory, trades) = await _openTrades(); + final hive = DB.instance.hive; + final watched = await hive.openBox( + DB.boxNameWatchedTrades, + ); + final watchedTransactions = await hive.openBox( + DB.boxNameWatchedTransactions, + ); + final client = _PausedRecoveryClient(); + final wallet = _FundingWallet( + client: client, + walletId: 'notification-recovery-wallet', + ); + Wallets.sharedInstance.addWallet(wallet); + final bridge = _rosenRequest(false, suffix: '-notification'); + const txid = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + await trades.put( + bridge.uuid, + bridge.copyWith( + payInTxid: txid, + other: jsonEncode({ + ...jsonDecode(bridge.other!) as Map, + RosenFunding.fundingKey: { + 'state': RosenFunding.submittingState, + 'walletId': wallet.walletId, + 'txid': txid, + 'raw': '0x02', + }, + }), + ), + ); + final legacy = bridge.copyWith( + tradeId: 'legacy-swap', + exchangeName: ChangeNowExchange.exchangeName, + ); + await trades.put('legacy-swap', legacy); + await watched.put( + 1, + NotificationModel( + id: 1, + title: 'Waiting', + description: '', + iconAssetName: '', + date: DateTime.now(), + walletId: '', + read: false, + shouldWatchForUpdates: true, + coinName: '', + changeNowId: legacy.tradeId, + ), + ); + final service = NotificationsService.instance; + final tradeService = TradesService(); + final previousInterval = NotificationsService.notificationRefreshInterval; + NotificationsService.notificationRefreshInterval = const Duration( + milliseconds: 1, + ); + await service.init( + nodeService: _NotificationNodeService(), + tradesService: tradeService, + prefs: _NotificationPrefs(), + ); + final http = RosenTestHttp((url) { + expect(url.host, 'api.changenow.io'); + expect(url.queryParameters['id'], legacy.tradeId); + return { + 'id': legacy.tradeId, + 'status': 'waiting', + 'actionsAvailable': false, + }; + }); + try { + await http.run(() async { + service.startCheckingWatchedNotifications(); + await client.entered.future.timeout(const Duration(seconds: 1)); + service.stopCheckingWatchedTransactions(); + expect(http.requests, hasLength(1)); + expect(client.release.isCompleted, isFalse); + }); + } finally { + service.stopCheckingWatchedTransactions(); + NotificationsService.notificationRefreshInterval = previousInterval; + client.release.complete(BigInt.two); + await client.disposed.future.timeout(const Duration(seconds: 1)); + tradeService.dispose(); + await watched.close(); + await watchedTransactions.close(); + await trades.close(); + await directory.delete(recursive: true); + } + }, + ); + + for (final method in ['eth_getTransactionReceipt', 'eth_blockNumber']) { + testWidgets('a stalled Rosen $method cannot block later providers', ( + tester, + ) async { + late Directory directory; + late Box trades; + late Box watched; + late Box watchedTransactions; + final bridge = _rosenRequest( + false, + suffix: '-receipt', + ).copyWith(payInTxid: '0x${'aa' * 32}', status: 'Confirming'); + final legacy = _rosenRequest( + false, + suffix: '-legacy', + ).copyWith(exchangeName: ChangeNowExchange.exchangeName); + await tester.runAsync(() async { + (directory, trades) = await _openTrades(); + final hive = DB.instance.hive; + watched = await hive.openBox( + DB.boxNameWatchedTrades, + ); + watchedTransactions = await hive.openBox( + DB.boxNameWatchedTransactions, + ); + for (final trade in [bridge, legacy]) { + await trades.put(trade.uuid, trade); + final id = watched.length + 1; + await watched.put( + id, + NotificationModel( + id: id, + title: trade.status, + description: '', + iconAssetName: '', + date: DateTime.now(), + walletId: '', + read: false, + shouldWatchForUpdates: true, + coinName: '', + changeNowId: trade.tradeId, + ), + ); + } + }); + final receipt = _PausedReceiptHttp(method); + final previousClient = EthereumAPI.client; + EthereumAPI.client = receipt; + final service = NotificationsService.instance; + final tradeService = TradesService(); + await service.init( + nodeService: _NotificationNodeService(), + tradesService: tradeService, + prefs: _NotificationPrefs(), + ); + final http = RosenTestHttp((url) { + if (url.host == 'app.rosen.tech') return {'items': []}; + expectSync(url.host, 'api.changenow.io'); + expectSync(url.queryParameters['id'], legacy.tradeId); + return { + 'id': legacy.tradeId, + 'status': 'waiting', + 'actionsAvailable': false, + }; + }); + try { + await http.run(() async { + service.startCheckingWatchedNotifications(); + await tester.pump(const Duration(seconds: 60)); + service.stopCheckingWatchedTransactions(); + expect(receipt.paused, isTrue); + expect( + http.requests.where((url) => url.host == 'api.changenow.io'), + isEmpty, + ); + await tester.pump(const Duration(seconds: 29)); + expect( + http.requests.where((url) => url.host == 'api.changenow.io'), + isEmpty, + ); + await tester.pump(const Duration(seconds: 1)); + expect( + http.requests.where((url) => url.host == 'api.changenow.io'), + hasLength(1), + ); + expect(receipt.release.isCompleted, isFalse); + expect(trades.get(bridge.uuid)!.toMap(), bridge.toMap()); + expect(watched.get(1)!.shouldWatchForUpdates, isTrue); + }); + } finally { + service.stopCheckingWatchedTransactions(); + receipt.release.complete( + Response( + utf8.encode( + jsonEncode({'jsonrpc': '2.0', 'id': 1, 'result': null}), + ), + 200, + ), + ); + await tester.pump(); + EthereumAPI.client = previousClient; + tradeService.dispose(); + await tester.runAsync(() async { + await watched.close(); + await watchedTransactions.close(); + await trades.close(); + await directory.delete(recursive: true); + }); + } + }); + } + + test( + 'Rosen refresh retains the request and rejects funded or stale saves', + () async { + final (directory, box) = await _openTrades(); + final service = TradesService(); + final changed = isA().having( + (e) => e.type, + 'type', + ExchangeExceptionType.quoteChanged, + ); + try { + for (final fromFiro in [true, false]) { + final initial = _rosenRequest(fromFiro); + await service.add(trade: initial, shouldNotifyListeners: false); + final quote = RosenQuote( + bridgeFee: BigInt.from(456), + networkFee: BigInt.from(123), + minimum: BigInt.from(580), + receiveAmount: BigInt.from(100000000 - 579), + ); + var refreshed = await RosenExchange.saveRefreshedTrade( + initial, + quote, + ); + expect(refreshed.uuid, initial.uuid); + expect(refreshed.payInAmount, initial.payInAmount); + expect(refreshed.payOutAddress, initial.payOutAddress); + expect( + refreshed.payOutAmount, + initial.payOutAmount, + ); // Same total, new components. + expect(refreshed.other, isNot(initial.other)); + expect( + (jsonDecode(refreshed.other!) as Map).containsKey('metadata'), + isFalse, + ); + expect( + RosenExchange.validatedMetadata(refreshed), + RosenProtocol.metadata( + fromFiro: fromFiro, + destination: initial.payOutAddress, + bridgeFee: quote.bridgeFee, + networkFee: quote.networkFee, + ), + ); + expect( + () => RosenExchange.currentUnfunded(initial), + throwsA(changed), + ); + await expectLater( + RosenExchange.saveRefreshedTrade(initial, quote), + throwsA(changed), + ); + + // A poll captured before refresh must not restore old quote data. + await service.edit(trade: initial, shouldNotifyListeners: false); + expect(box.get(initial.uuid)!.other, refreshed.other); + expect(box.get(initial.uuid)!.updatedAt, refreshed.updatedAt); + for (final fees in [(700, 200), (50, 20)]) { + final nextQuote = RosenQuote( + bridgeFee: BigInt.from(fees.$1), + networkFee: BigInt.from(fees.$2), + minimum: BigInt.from(fees.$1 + fees.$2 + 1), + receiveAmount: BigInt.from(100000000 - fees.$1 - fees.$2), + ); + refreshed = await RosenExchange.saveRefreshedTrade( + refreshed, + nextQuote, + ); + expect( + refreshed.payOutAmount, + RosenProtocol.formatAmount(nextQuote.receiveAmount), + ); + expect( + RosenExchange.validatedMetadata(refreshed), + RosenProtocol.metadata( + fromFiro: fromFiro, + destination: initial.payOutAddress, + bridgeFee: nextQuote.bridgeFee, + networkFee: nextQuote.networkFee, + ), + ); + } + await expectLater( + RosenExchange.saveRefreshedTrade( + refreshed, + RosenQuote( + bridgeFee: BigInt.one, + networkFee: BigInt.one, + minimum: BigInt.from(100000001), + receiveAmount: BigInt.from(99999998), + ), + ), + throwsFormatException, + ); + expect( + RosenExchange.sameVersion(box.get(initial.uuid)!, refreshed), + isTrue, + ); + final funded = refreshed.copyWith( + payInTxid: 'deposit', + status: 'Confirming', + ); + await service.edit(trade: funded, shouldNotifyListeners: false); + await expectLater( + RosenExchange.saveRefreshedTrade(refreshed, quote), + throwsStateError, + ); + expect( + () => RosenExchange.refreshCandidate(funded, quote), + throwsStateError, + ); + expect( + () => RosenExchange.refreshCandidate( + refreshed.copyWith(status: 'Finished'), + quote, + ), + throwsStateError, + ); + await service.edit(trade: initial, shouldNotifyListeners: false); + final stored = box.get(initial.uuid)!; + expect(stored.payInTxid, 'deposit'); + expect(stored.status, 'Confirming'); + expect(stored.other, refreshed.other); + expect(stored.payOutAmount, refreshed.payOutAmount); + } + } finally { + service.dispose(); + await box.close(); + await directory.delete(recursive: true); + } + }, + ); + + test( + 'Rosen refresh reloads a stale screen and rejects a concurrent send', + () async { + final (directory, trades) = await _openTrades(); + final stale = _rosenRequest(false); + final current = _recoveryTrade(false, suffix: ''); + final wallet = _FundingWallet(client: _RecoveryClient(txid: '11' * 32)); + final quoteRequested = Completer(); + final releaseQuote = Completer(); + var pause = false; + final http = RosenTestHttp((url) async { + expect(url.host, 'api.ergoplatform.com'); + if (pause) { + quoteRequested.complete(); + await releaseQuote.future; + } + return { + 'items': [rosenFeeBox()], + 'total': 1, + }; + }); + try { + await trades.put(current.uuid, current); + await http.run(() async { + final refreshed = await RosenFunding.refreshTrade( + wallet: wallet, + trade: stale, + ); + expect(refreshed.payInAmount, current.payInAmount); + expect(refreshed.payOutAddress, current.payOutAddress); + expect(refreshed.payInTxid, isEmpty); + pause = true; + final refreshing = RosenFunding.refreshTrade( + wallet: wallet, + trade: stale, + ); + await quoteRequested.future; + final funded = refreshed.copyWith( + payInTxid: '22' * 32, + status: 'Confirming', + ); + await trades.put(funded.uuid, funded); + releaseQuote.complete(); + await expectLater(refreshing, throwsStateError); + expect(trades.get(funded.uuid)!.toMap(), funded.toMap()); + }); + } finally { + if (!releaseQuote.isCompleted) releaseQuote.complete(); + await trades.close(); + await directory.delete(recursive: true); + } + }, + ); + + test('Ethereum recovery does not bypass Tor', () async { + final (directory, trades) = await _openTrades(); + final trade = _recoveryTrade(false, suffix: '-tor'); + final wallet = _FundingWallet(useTor: true); + try { + await trades.put( + trade.uuid, + trade.copyWith( + payInTxid: '11' * 32, + other: jsonEncode({ + ...jsonDecode(trade.other!) as Map, + RosenFunding.fundingKey: { + 'walletId': wallet.walletId, + 'txid': '11' * 32, + 'raw': '0200', + 'state': RosenFunding.submittingState, + }, + }), + ), + ); + await expectLater( + RosenFunding.recoverFundingIntent(wallet: wallet, trade: trade), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Ethereum bridge funding is unavailable over Tor.', + ), + ), + ); + expect(wallet.rpcAttempts, 0); + expect( + _fundingState(trades.get(trade.uuid)!), + RosenFunding.submittingState, + ); + } finally { + await trades.close(); + await directory.delete(recursive: true); + } + }); + + testWidgets( + 'a stalled recovery RPC releases the wallet lock without submitting later', + (tester) async { + late Directory directory; + late Box trades; + late Box lookups; + final trade = _recoveryTrade(false, suffix: '-timeout'); + final txid = '11' * 32; + final paused = _PausedRecoveryClient(); + final wallet = _FundingWallet( + client: paused, + walletId: 'timed-out-wallet', + ); + await tester.runAsync(() async { + (directory, trades) = await _openTrades(); + final hive = DB.instance.hive; + lookups = await hive.openBox(DB.boxNameTradeLookup); + await trades.put(trade.uuid, trade); + await RosenFunding.recordFundingIntent( + trade: trade, + walletId: wallet.walletId, + txid: txid, + raw: '0200', + ); + }); + Object? result; + final recovering = + RosenFunding.recoverFundingIntent( + wallet: wallet, + trade: trade, + ).then( + (_) { + result = true; + }, + onError: (Object e) { + result = e; + }, + ); + try { + await tester.pump(); + expect(paused.entered.isCompleted, isTrue); + await tester.pump(const Duration(seconds: 30)); + expect(result, isA()); + expect(paused.release.isCompleted, isFalse); + expect( + _fundingState(trades.get(trade.uuid)!), + RosenFunding.submittingState, + ); + final exact = _RecoveryClient(txid: txid)..known = true; + final recovered = await tester.runAsync( + () => RosenFunding.recoverFundingIntent( + wallet: _FundingWallet(client: exact, walletId: wallet.walletId), + trade: trade, + ), + ); + expect(_fundingState(recovered!), RosenFunding.broadcastState); + expect(exact.sentRaw, isEmpty); + } finally { + if (!paused.release.isCompleted) paused.release.complete(BigInt.two); + await tester.pump(); + await recovering; + await tester.runAsync(() async { + await lookups.close(); + await trades.close(); + await directory.delete(recursive: true); + }); + } + }, + ); + + for (final method in ['chain', 'height', 'nonce', 'balance', 'transfer']) { + testWidgets( + 'a stalled $method check releases the Rosen confirmation lock', + (tester) async { + late Directory directory; + late Box trades; + final trade = _recoveryTrade(false, suffix: '-confirm-$method'); + final client = _ConfirmPausedClient(method); + final wallet = _FundingWallet( + client: client, + walletId: 'confirm-$method', + ); + final amount = RosenProtocol.parseAmount(trade.payInAmount); + final prepared = TxData( + recipients: [ + TxRecipient( + address: trade.payInAddress, + amount: Amount(rawValue: amount, fractionDigits: 8), + isChange: false, + addressType: Ethereum(CryptoCurrencyNetwork.main) + .defaultAddressType, + ), + ], + fee: Amount(rawValue: BigInt.from(3600000), fractionDigits: 18), + chainId: BigInt.one, + nonce: 7, + web3dartTransaction: web3.Transaction( + to: eth.EthereumAddress.fromHex(DefaultTokens.rsFiro.address), + value: eth.EtherAmount.zero(), + data: RosenProtocol.transferData( + lockAddress: trade.payInAddress, + amount: amount, + metadata: RosenExchange.validatedMetadata(trade), + ).toUint8ListFromHex, + maxGas: 120000, + nonce: 7, + maxFeePerGas: eth.EtherAmount.inWei(BigInt.from(30)), + maxPriorityFeePerGas: eth.EtherAmount.inWei(BigInt.from(2)), + ), + ); + await tester.runAsync(() async { + (directory, trades) = await _openTrades(); + await trades.put(trade.uuid, trade); + }); + Object? result; + final http = RosenTestHttp( + (url) => { + 'items': [rosenFeeBox()], + 'total': 1, + }, + ); + Future? confirming; + try { + await http.run(() async { + confirming = + RosenFunding.confirmSend( + wallet: wallet, + trade: trade, + txData: prepared, + ).then( + (_) { + result = true; + }, + onError: (Object error) { + result = error; + }, + ); + await tester.pump(); + expect(client.entered, isTrue); + await tester.pump(const Duration(seconds: 30)); + expect(result, isA()); + expect(client.release.isCompleted, isFalse); + expect(client.sentRaw, isEmpty); + expect(trades.get(trade.uuid)!.toMap(), trade.toMap()); + await tester.runAsync( + () => expectLater( + RosenFunding.confirmSend( + wallet: wallet, + trade: trade, + txData: prepared.copyWith(chainId: BigInt.two), + ), + throwsStateError, + ), + ); + }); + } finally { + client.release.complete(); + await tester.pump(); + await confirming; + expect(client.sentRaw, isEmpty); + await tester.runAsync(() async { + await trades.close(); + await directory.delete(recursive: true); + }); + } + }, + ); + } + + group('Rosen prepares, journals and submits the exact deposit', () { + late Directory directory; + late Box trades; + late Box lookups; + late Isar isar; + final http = RosenTestHttp((url) { + expect(url.host, 'api.ergoplatform.com'); + return { + 'items': [rosenFeeBox()], + 'total': 1, + }; + }); + setUp(() async { + (directory, trades) = await _openTrades(); + final hive = DB.instance.hive; + lookups = await hive.openBox(DB.boxNameTradeLookup); + await initializeTestIsar(); + isar = await Isar.open( + [EthContractSchema], + directory: directory.path, + inspector: false, + ); + await MainDB.instance.initMainDB(mock: isar); + }); + tearDown(() async { + await isar.close(deleteFromDisk: true); + await lookups.close(); + if (trades.isOpen) await trades.close(); + await directory.delete(recursive: true); + }); + + for (final fromFiro in [true, false]) { + for (final outcome in [ + 'success', + 'response lost', + 'local update failed', + 'rejected', + 'unknown', + ]) { + test( + '${fromFiro ? 'FIRO' : 'rsFIRO'} journals $outcome accurately', + () async { + final trade = _recoveryTrade(fromFiro, suffix: '-$outcome'); + await trades.put(trade.uuid, trade); + final Wallet wallet; + final List sentRaw; + final String txid; + if (fromFiro) { + final raw = _firoPrepared(trade).raw!; + final client = _RecoveryElectrumClient( + txid: firoTransactionFromHex(raw).txid, + raw: raw, + loseFirstResponse: outcome == 'response lost', + rpcFailure: outcome == 'rejected', + lookupFailure: outcome == 'unknown', + expectedScripthash: Firo(CryptoCurrencyNetwork.main) + .addressToScriptHash(address: trade.payInAddress), + ); + wallet = _FiroFundingWallet( + client, + trade, + afterBroadcastFailure: outcome == 'local update failed', + beforeBroadcastFailure: outcome == 'unknown', + ); + txid = client.txid; + sentRaw = client.sentRaw; + } else { + const raw = '0x0200'; + txid = web3.bytesToHex( + web3.keccak256(raw.toUint8ListFromHex), + include0x: true, + ); + final client = _RecoveryClient( + txid: txid, + confirmedNonce: 7, + failures: outcome == 'rejected' ? 2 : 0, + loseFirstResponse: outcome == 'response lost', + rpcFailure: outcome == 'rejected', + rpcMessage: 'invalid sender', + lookupFailure: outcome == 'unknown', + ); + wallet = _FundingWallet( + client: client, + signedRaw: raw, + afterBroadcastFailure: outcome == 'local update failed', + beforeBroadcastFailure: outcome == 'unknown', + ); + sentRaw = client.sentRaw; + } + await http.run(() async { + final prepared = await RosenFunding.prepareSend( + wallet: wallet, + trade: trade, + ); + expect( + prepared.amountWithoutChange!.raw, + RosenProtocol.parseAmount(trade.payInAmount), + ); + if (fromFiro) { + expect( + prepared.opReturnData, + RosenExchange.validatedMetadata(trade), + ); + } else { + expect(prepared.chainId, BigInt.one); + expect(prepared.nonce, 7); + expect(prepared.web3dartTransaction!.maxGas, 120000); + expect(prepared.fee!.raw, BigInt.from(3600000)); + expect( + (await MainDB.instance.getEthContracts().findAll()) + .single + .address, + DefaultTokens.rsFiro.address, + ); + } + final sending = RosenFunding.confirmSend( + wallet: wallet, + trade: trade, + txData: prepared, + ); + if (outcome == 'rejected') { + await expectLater(sending, throwsStateError); + } else { + final sent = await sending; + expect(sent.txid, txid); + } + final saved = trades.get(trade.uuid)!; + expect(saved.payInTxid, txid); + expect(lookups.get(saved.uuid)!.txid, txid); + if (outcome == 'unknown') { + expect(sentRaw, isEmpty); + expect(_fundingState(saved), RosenFunding.submittingState); + expect(saved.status, 'Verifying'); + } else if (outcome == 'rejected') { + expect(sentRaw, isNotEmpty); + expect(_fundingState(saved), RosenFunding.needsAttentionState); + expect(saved.status, 'Verifying'); + } else { + expect( + sentRaw, + everyElement(fromFiro ? prepared.raw : '0x0200'), + ); + expect(_fundingState(saved), RosenFunding.broadcastState); + expect(saved.status, 'Confirming'); + } + }); + }, + ); + } + } + + test('journal write failure prevents FIRO broadcast', () async { + final trade = _recoveryTrade(true, suffix: '-save-failure'); + await trades.put(trade.uuid, trade); + final raw = _firoPrepared(trade).raw!; + final client = _RecoveryElectrumClient( + txid: firoTransactionFromHex(raw).txid, + raw: raw, + expectedScripthash: Firo(CryptoCurrencyNetwork.main) + .addressToScriptHash(address: trade.payInAddress), + ); + final wallet = _FiroFundingWallet( + client, + trade, + beforeIntent: () => trades.close(), + ); + await http.run(() async { + final prepared = await RosenFunding.prepareSend( + wallet: wallet, + trade: trade, + ); + await expectLater( + RosenFunding.confirmSend( + wallet: wallet, + trade: trade, + txData: prepared, + ), + throwsA(isA()), + ); + expect(client.sentRaw, isEmpty); + }); + }); + + test( + 'rsFIRO preflight adds balance guidance without dropping node details', + () async { + final trade = _recoveryTrade(false, suffix: '-token-revert'); + await trades.put(trade.uuid, trade); + final client = _RecoveryClient( + txid: '11' * 32, + pendingTransferFailure: const RPCError(3, 'execution reverted', null), + ); + await http.run(() async { + await expectLater( + RosenFunding.prepareSend( + wallet: _FundingWallet(client: client), + trade: trade, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf( + contains('Check your rsFIRO balance'), + contains('execution reverted'), + ), + ), + ), + ); + expect(client.sentRaw, isEmpty); + expect(trades.get(trade.uuid)!.payInTxid, isEmpty); + }); + }, + ); + }); +} + +Future<(Directory, Box)> _openTrades() async { + final directory = await Directory.systemTemp.createTemp('rosen-trades-'); + final hive = DB.instance.hive; + hive.init(directory.path); + if (!hive.isAdapterRegistered(Trade.typeId)) { + hive.registerAdapter(TradeAdapter()); + } + final lookupAdapter = TradeWalletLookupAdapter(); + if (!hive.isAdapterRegistered(lookupAdapter.typeId)) { + hive.registerAdapter(lookupAdapter); + } + final notificationAdapter = NotificationModelAdapter(); + if (!hive.isAdapterRegistered(notificationAdapter.typeId)) { + hive.registerAdapter(notificationAdapter); + } + return (directory, await hive.openBox(DB.boxNameTradesV2)); +} + +class _FundingWallet extends Fake implements EthereumWallet { + _FundingWallet({ + bool viewOnly = false, + bool useTor = false, + this.client, + this.walletId = 'funding-wallet', + this.signedRaw, + this.afterBroadcastFailure = false, + this.beforeBroadcastFailure = false, + }) : info = _FundingWalletInfo(viewOnly, walletId: walletId), + prefs = _FundingPrefs(useTor: useTor); + + @override + final WalletInfo info; + @override + final Ethereum cryptoCurrency = Ethereum(CryptoCurrencyNetwork.main); + @override + final Prefs prefs; + @override + final String walletId; + final web3.Web3Client? client; + final String? signedRaw; + final bool afterBroadcastFailure; + final bool beforeBroadcastFailure; + + @override + MainDB get mainDB => MainDB.instance; + @override + NodeService get nodeService => _NotificationNodeService(); + @override + SecureStorageInterface get secureStorageInterface => _FundingStorage(); + + @override + Future updateTokenContracts(List addresses) async { + (info as _FundingWalletInfo).tokenContractAddresses = addresses; + } + + @override + Future< + ({ + int nonce, + BigInt chainId, + BigInt maxFeePerGas, + BigInt maxPriorityFeePerGas, + }) + > + internalSharedPrepareSend({ + required TxData txData, + required eth.EthereumAddress myWeb3Address, + }) async => ( + nonce: 7, + chainId: BigInt.one, + maxFeePerGas: BigInt.from(30), + maxPriorityFeePerGas: BigInt.from(2), + ); + + @override + Future confirmSend({ + required TxData txData, + TxData Function(TxData, String)? prepareTempTx, + Future Function(String)? beforeBroadcast, + }) async { + await beforeBroadcast!(signedRaw!); + final trade = DB.instance + .values(boxName: DB.boxNameTradesV2) + .singleWhere((trade) => trade.payInTxid.isNotEmpty); + expect(_fundingState(trade), RosenFunding.submittingState); + expect(trade.status, 'Verifying'); + if (beforeBroadcastFailure) throw StateError('Network unavailable'); + final txid = await client!.sendRawTransaction( + signedRaw!.toUint8ListFromHex, + ); + if (afterBroadcastFailure) throw StateError('Local history update failed'); + return txData.copyWith(txid: txid, txHash: txid); + } + + static final rpcBoundary = UnsupportedError('Funding test RPC boundary'); + int rpcAttempts = 0; + + @override + web3.Web3Client getEthClient() { + rpcAttempts++; + if (client != null) return client!; + throw rpcBoundary; + } + + @override + Future getMyWeb3Address() async => + eth.EthereumAddress.fromHex('0x00112233445566778899aabbccddeeff00112233'); +} + +class _RecoveryClient extends Fake implements web3.Web3Client { + _RecoveryClient({ + required this.txid, + this.failures = 0, + this.loseFirstResponse = false, + this.rpcFailure = false, + this.rpcMessage = 'rejected', + this.confirmedNonce = 0, + this.nonceTransitionHeight, + this.blockTransactionHash, + this.includeNonceTransaction = true, + this.malformedBlock = false, + this.blockRpcError, + this.historicalStateUnavailableBefore, + this.historicalStateError = 'missing trie node', + this.knownAfterLookups, + BigInt? chainId, + this.receiptKnown = false, + this.lookupFailure = false, + this.pendingTransferFailure, + }) : chainId = chainId ?? BigInt.one; + + final String txid; + int failures; + final bool loseFirstResponse; + final bool rpcFailure; + final String rpcMessage; + int confirmedNonce; + final int? nonceTransitionHeight; + final String? blockTransactionHash; + final bool includeNonceTransaction; + final bool malformedBlock; + final String? blockRpcError; + final int? historicalStateUnavailableBefore; + final String historicalStateError; + final int? knownAfterLookups; + final BigInt chainId; + final bool receiptKnown; + final bool lookupFailure; + final RPCError? pendingTransferFailure; + bool known = false; + Future Function()? onLookup; + int lookups = 0; + final sentRaw = []; + + @override + Future getTransactionByHash(String hash) async { + lookups++; + expect(hash, txid); + await onLookup?.call(); + if (lookupFailure) throw StateError('Node unavailable'); + if (!known && (knownAfterLookups == null || lookups < knownAfterLookups!)) { + return null; + } + return web3.TransactionInformation.fromMap({ + 'blockHash': null, + 'blockNumber': null, + 'from': '0x00112233445566778899aabbccddeeff00112233', + 'gas': '21000', + 'gasPrice': '1', + 'hash': txid, + 'input': '0x00', + 'nonce': '0', + 'to': '0x00112233445566778899aabbccddeeff00112233', + 'transactionIndex': null, + 'value': '0', + 'v': '1', + 'r': '0x1', + 's': '0x1', + }); + } + + @override + Future sendRawTransaction(Uint8List transaction) async { + sentRaw.add(web3.bytesToHex(transaction, include0x: true)); + if (failures > 0) { + failures--; + if (rpcFailure) throw RPCError(-32000, rpcMessage, null); + throw StateError('rejected'); + } + if (loseFirstResponse && !known) { + known = true; + throw StateError('response lost'); + } + known = true; + return txid; + } + + @override + Future dispose() async {} + + @override + Future getChainId() async => chainId; + + @override + Future getBlockNumber() async => 25992101; + + @override + Future> call({ + eth.EthereumAddress? sender, + required web3.DeployedContract contract, + required web3.ContractFunction function, + required List params, + web3.BlockNum? atBlock, + }) async { + expect(function.name, 'decimals'); + return [BigInt.from(8)]; + } + + @override + Future callRaw({ + eth.EthereumAddress? sender, + required eth.EthereumAddress contract, + required Uint8List data, + web3.BlockNum? atBlock, + }) async { + expect(atBlock?.toBlockParam(), 'pending'); + expect(contract.with0x.toLowerCase(), DefaultTokens.rsFiro.address); + if (pendingTransferFailure case final error?) throw error; + return '0x${'00' * 31}01'; + } + + @override + Future estimateGas({ + eth.EthereumAddress? sender, + eth.EthereumAddress? to, + eth.EtherAmount? value, + BigInt? amountOfGas, + eth.EtherAmount? gasPrice, + eth.EtherAmount? maxPriorityFeePerGas, + eth.EtherAmount? maxFeePerGas, + Uint8List? data, + web3.BlockNum? atBlock, + }) async => BigInt.from(100000); + + @override + Future getBalance( + eth.EthereumAddress address, { + web3.BlockNum? atBlock, + }) async => eth.EtherAmount.inWei(BigInt.from(10000000)); + + @override + Future getTransactionCount( + eth.EthereumAddress address, { + web3.BlockNum? atBlock, + }) async { + if (atBlock?.useAbsolute == true && + historicalStateUnavailableBefore != null && + atBlock!.blockNum < historicalStateUnavailableBefore!) { + throw RPCError(-32000, historicalStateError, null); + } + final transition = nonceTransitionHeight; + if (transition != null && atBlock?.useAbsolute == true) { + return atBlock!.blockNum < transition + ? confirmedNonce - 1 + : confirmedNonce; + } + return confirmedNonce; + } + + @override + Future getTransactionReceipt(String hash) async { + expect(hash, txid); + if (!receiptKnown) return null; + return web3.TransactionReceipt( + transactionHash: txid.toUint8ListFromHex, + transactionIndex: 0, + blockHash: Uint8List(32), + cumulativeGasUsed: BigInt.zero, + ); + } + + @override + Future makeRPCCall(String function, [List? params]) async { + expect(function, 'eth_getBlockByNumber'); + expect(params, hasLength(2)); + expect(params![1], isTrue); + if (nonceTransitionHeight != null) { + expect(params[0], '0x${nonceTransitionHeight!.toRadixString(16)}'); + } + if (blockRpcError case final error?) { + throw RPCError(-32000, error, null); + } + return { + 'number': params[0], + 'transactions': [ + if (malformedBlock) + txid + else if (includeNonceTransaction) + { + 'from': '0x00112233445566778899aabbccddeeff00112233', + 'nonce': '0x7', + 'hash': blockTransactionHash ?? '0x${'aa' * 32}', + } + else + { + 'from': '0x112233445566778899aabbccddeeff0011223344', + 'nonce': '0x1', + 'hash': '0x${'bb' * 32}', + }, + ], + } as T; + } +} + +class _FiroRecoveryWallet extends Fake implements FiroWallet { + _FiroRecoveryWallet(this.electrumXClient); + + @override + final ElectrumXClient electrumXClient; + @override + final Firo cryptoCurrency = Firo(CryptoCurrencyNetwork.main); + @override + final WalletInfo info = _FundingWalletInfo(false); + @override + final String walletId = 'firo-recovery-wallet'; + + @override + Future fetchChainHeight({int retries = 1}) async => 1378335; +} + +class _RecoveryElectrumClient extends Fake implements ElectrumXClient { + _RecoveryElectrumClient({ + required this.txid, + required this.raw, + this.loseFirstResponse = false, + this.rpcFailure = false, + this.conflictTxid, + this.conflictRaw, + this.lookupFailure = false, + this.expectedScripthash = 'input-script', + }); + + final String txid; + final String raw; + final bool loseFirstResponse; + final bool rpcFailure; + final String? conflictTxid; + final String? conflictRaw; + final bool lookupFailure; + final String expectedScripthash; + bool conflictVisible = false; + int conflictHeight = 1378335; + bool known = false; + int lookups = 0; + int candidateLookups = 0; + final sentRaw = []; + + @override + Future request({ + required String command, + List args = const [], + String? requestID, + int retries = 2, + Duration requestTimeout = const Duration(seconds: 60), + }) async { + expect(command, 'blockchain.transaction.get'); + if (args.length == 2 && + args[1] == false && + args.first == conflictTxid && + (conflictTxid != txid || lookups > 0)) { + candidateLookups++; + if (conflictRaw == null) { + throw StateError('Missing conflict raw transaction.'); + } + return conflictRaw; + } + lookups++; + expect(args, [txid, false]); + if (lookupFailure) throw StateError('Node unavailable'); + if (!known) { + throw RpcException(1, 'No such mempool or blockchain transaction'); + } + return raw; + } + + @override + Future>> getHistory({ + required String scripthash, + String? requestID, + }) async { + expect(scripthash, expectedScripthash); + return [ + if (conflictVisible && conflictTxid != null) + {'tx_hash': conflictTxid, 'height': conflictHeight}, + ]; + } + + @override + Future>> getUTXOs({ + required String scripthash, + String? requestID, + }) async { + expect(scripthash, expectedScripthash); + return [ + {'tx_hash': '00' * 32, 'tx_pos': 0, 'value': 10000001000}, + ]; + } + + @override + Future broadcastTransaction({ + required String rawTx, + String? requestID, + }) async { + sentRaw.add(rawTx); + if (rpcFailure) { + conflictVisible = true; + throw RpcException(1, 'transaction was rejected by network rules'); + } + if (loseFirstResponse && !known) { + known = true; + throw StateError('response lost'); + } + known = true; + return txid; + } +} + +class _FundingWalletInfo extends Fake implements WalletInfo { + _FundingWalletInfo(this.isViewOnly, {this.walletId = 'funding-wallet'}); + + @override + final bool isViewOnly; + @override + final String walletId; + @override + List tokenContractAddresses = []; +} + +class _FundingStorage extends Fake implements SecureStorageInterface {} + +class _FiroFundingWallet extends _FiroRecoveryWallet { + _FiroFundingWallet( + super.electrumXClient, + this.trade, { + this.afterBroadcastFailure = false, + this.beforeBroadcastFailure = false, + this.beforeIntent, + }); + final Trade trade; + final bool afterBroadcastFailure; + final bool beforeBroadcastFailure; + final Future Function()? beforeIntent; + + @override + Future prepareSend({required TxData txData}) async { + expect(txData.opReturnData, RosenExchange.validatedMetadata(trade)); + expect(txData.recipients!.single.address, trade.payInAddress); + return _firoPrepared(trade); + } + + @override + Future confirmSend({ + required TxData txData, + Future Function(String)? beforeBroadcast, + }) async { + await beforeIntent?.call(); + await beforeBroadcast!(txData.raw!); + final saved = DB.instance.get( + boxName: DB.boxNameTradesV2, + key: trade.uuid, + )!; + expect(saved.payInTxid, firoTransactionFromHex(txData.raw!).txid); + expect(_fundingState(saved), RosenFunding.submittingState); + expect(saved.status, 'Verifying'); + if (beforeBroadcastFailure) throw StateError('Network unavailable'); + final txid = await electrumXClient.broadcastTransaction(rawTx: txData.raw!); + if (afterBroadcastFailure) throw StateError('Local history update failed'); + return txData.copyWith(txid: txid, txHash: txid); + } +} + +TxData _firoPrepared(Trade trade) { + final amount = RosenProtocol.parseAmount(trade.payInAmount); + final metadata = RosenExchange.validatedMetadata(trade); + const signature = + '304402206687c87c5f80c4e2a4e63fed02b0de65bcd38b77255b1de3f375f5d7' + 'a1b124c902202d5997a82c65d254e08ecc2ef7ba15995df3adfefa509b6ce0fdd' + '65642a4062f'; + const publicKey = + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + final raw = coinlib.Transaction( + version: 1, + inputs: [ + coinlib.RawInput( + prevOut: coinlib.OutPoint(Uint8List(32), 0), + scriptSig: '47${signature}0121$publicKey'.toUint8ListFromHex, + ), + ], + outputs: [ + coinlib.Output.fromScriptBytes( + amount, + RosenProtocol.firoScript(trade.payInAddress).toUint8ListFromHex, + ), + firoOpReturnOutput(metadata), + ], + ).toHex(); + return TxData( + recipients: [ + TxRecipient( + address: trade.payInAddress, + amount: Amount(rawValue: amount, fractionDigits: 8), + isChange: false, + addressType: Firo(CryptoCurrencyNetwork.main).defaultAddressType, + ), + ], + fee: Amount(rawValue: BigInt.from(1000), fractionDigits: 8), + raw: raw, + opReturnData: metadata, + usedUTXOs: [ + StandardInput( + UTXO( + walletId: 'firo-recovery-wallet', + txid: '00' * 32, + vout: 0, + value: (amount + BigInt.from(1000)).toInt(), + name: '', + isBlocked: false, + blockedReason: null, + isCoinbase: false, + blockHash: null, + blockHeight: null, + blockTime: null, + address: trade.payInAddress, + ), + ), + ], + ); +} + +class _FundingPrefs extends Fake implements Prefs { + _FundingPrefs({this.useTor = false}); + @override + final bool useTor; +} + +class _NotificationPrefs extends _FundingPrefs { + @override + bool get externalCalls => true; +} + +class _NotificationNodeService extends Fake implements NodeService {} + +class _PausedReceiptHttp extends HTTP { + _PausedReceiptHttp(this.method); + + final String method; + final release = Completer(); + bool paused = false; + + @override + Future post({ + required Uri url, + Map? headers, + Object? body, + Encoding? encoding, + required ({InternetAddress host, int port})? proxyInfo, + }) async { + final rpc = jsonDecode(body! as String) as Map; + if (rpc['method'] == method) { + paused = true; + return release.future; + } + expectSync(rpc['method'], 'eth_getTransactionReceipt'); + return Response( + utf8.encode( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': rpc['id'], + 'result': { + 'transactionHash': (rpc['params'] as List).single, + 'status': '0x0', + 'blockNumber': '0x1', + }, + }), + ), + 200, + ); + } +} + +class _PausedRecoveryClient extends Fake implements web3.Web3Client { + final entered = Completer(); + final release = Completer(); + final disposed = Completer(); + + @override + Future getChainId() { + entered.complete(); + return release.future; + } + + @override + Future dispose() async => disposed.complete(); +} + +class _ConfirmPausedClient extends _RecoveryClient { + _ConfirmPausedClient(this.method) : super(txid: '11' * 32, confirmedNonce: 7); + final String method; + final release = Completer(); + bool entered = false; + Future pause(String operation) async { + if (operation == method) { + entered = true; + await release.future; + } + } + + @override + Future getChainId() async { + await pause('chain'); + return super.getChainId(); + } + + @override + Future getBlockNumber() async { + await pause('height'); + return super.getBlockNumber(); + } + + @override + Future getTransactionCount( + eth.EthereumAddress address, { + web3.BlockNum? atBlock, + }) async { + await pause('nonce'); + return super.getTransactionCount(address, atBlock: atBlock); + } + + @override + Future getBalance( + eth.EthereumAddress address, { + web3.BlockNum? atBlock, + }) async { + await pause('balance'); + return super.getBalance(address, atBlock: atBlock); + } + + @override + Future callRaw({ + eth.EthereumAddress? sender, + required eth.EthereumAddress contract, + required Uint8List data, + web3.BlockNum? atBlock, + }) async { + await pause('transfer'); + return super.callRaw( + sender: sender, + contract: contract, + data: data, + atBlock: atBlock, + ); + } +} + +String _fundingState(Trade trade) => + (jsonDecode(trade.other!) as Map)[RosenFunding.fundingKey]['state'] + as String; + +Trade _recoveryTrade(bool fromFiro, {required String suffix}) { + final trade = _rosenRequest(fromFiro, suffix: suffix); + final amount = BigInt.from(10000000000); + final quote = RosenQuote.fromRegisters( + rosenFeeRegisters, + fromFiro: fromFiro, + height: fromFiro ? 1378335 : 25992101, + amount: amount, + ); + final data = jsonDecode(trade.other!) as Map; + return trade.copyWith( + payInAmount: RosenProtocol.formatAmount(amount), + payOutAmount: RosenProtocol.formatAmount(quote.receiveAmount), + other: jsonEncode({ + ...data, + 'bridgeFee': quote.bridgeFee.toString(), + 'networkFee': quote.networkFee.toString(), + }), + ); +} + +Trade _rosenRequest(bool fromFiro, {String suffix = ''}) { + final destination = fromFiro + ? '0x00112233445566778899aabbccddeeff00112233' + : RosenApi.firoLockAddress; + final now = DateTime.utc(2020); + return Trade( + uuid: 'rosen-refresh-$fromFiro$suffix', + tradeId: 'rosen-refresh-$fromFiro$suffix', + rateType: 'estimated', + direction: 'direct', + timestamp: now, + updatedAt: now, + payInCurrency: fromFiro ? 'FIRO' : 'rsFIRO', + payInAmount: '1', + payInAddress: fromFiro + ? RosenApi.firoLockAddress + : RosenApi.ethereumLockAddress, + payInNetwork: fromFiro ? 'firo' : 'eth', + payInExtraId: '', + payInTxid: '', + payOutCurrency: fromFiro ? 'rsFIRO' : 'FIRO', + payOutAmount: '0.99999421', + payOutAddress: destination, + payOutNetwork: fromFiro ? 'eth' : 'firo', + payOutExtraId: '', + payOutTxid: '', + refundAddress: '', + refundExtraId: '', + status: 'Waiting', + exchangeName: RosenExchange.exchangeName, + other: jsonEncode({ + 'version': 1, + 'bridgeFee': '123', + 'networkFee': '456', + 'tokenContract': DefaultTokens.rsFiro.address, + }), + ); +} diff --git a/test/services/exchange/rosen_token_registration_test.dart b/test/services/exchange/rosen_token_registration_test.dart new file mode 100644 index 0000000000..a5381fa76c --- /dev/null +++ b/test/services/exchange/rosen_token_registration_test.dart @@ -0,0 +1,87 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:isar_community/isar.dart'; +import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/models/isar/models/ethereum/eth_contract.dart'; +import 'package:stackwallet/services/exchange/rosen/rosen_funding.dart'; +import 'package:stackwallet/utilities/default_eth_tokens.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/isar/models/wallet_info.dart'; +import 'package:stackwallet/wallets/wallet/impl/ethereum_wallet.dart'; + +import '../../support/isar_test_utils.dart'; + +void main() { + test( + 'rsFIRO registration preserves catalog metadata and address casing', + () async { + final directory = await Directory.systemTemp.createTemp('rosen-token-'); + addTearDown(() => directory.delete(recursive: true)); + await initializeTestIsar(); + final isar = await Isar.open( + [EthContractSchema], + directory: directory.path, + inspector: false, + ); + addTearDown(() => isar.close(deleteFromDisk: true)); + final db = MainDB.instance; + await db.initMainDB(mock: isar); + final existing = DefaultTokens.rsFiro.copyWith( + id: Isar.autoIncrement, + address: '0x${DefaultTokens.rsFiro.address.substring(2).toUpperCase()}', + name: 'Existing rsFIRO', + abi: 'preserve custom ABI', + ); + await db.putEthContract(existing); + + final wallet = _Wallet(db, [DefaultTokens.usdc.address]); + await RosenFunding.registerToken(wallet); + await RosenFunding.registerToken(wallet); + expect(wallet.info.tokenContractAddresses, [ + DefaultTokens.usdc.address, + existing.address, + ]); + expect(wallet.updates, 1); + + final selected = _Wallet(db, [existing.address]); + await RosenFunding.registerToken(selected); + expect(selected.info.tokenContractAddresses, [existing.address]); + expect(selected.updates, 0); + + final stored = (await db.getEthContracts().findAll()).single; + expect(stored.id, existing.id); + expect(stored.address, existing.address); + expect(stored.name, existing.name); + expect(stored.symbol, existing.symbol); + expect(stored.decimals, existing.decimals); + expect(stored.type, existing.type); + expect(stored.abi, existing.abi); + }, + ); +} + +class _Wallet extends Fake implements EthereumWallet { + _Wallet(this.mainDB, List addresses) : info = _WalletInfo(addresses); + + @override + final MainDB mainDB; + @override + final _WalletInfo info; + @override + final Ethereum cryptoCurrency = Ethereum(CryptoCurrencyNetwork.main); + int updates = 0; + + @override + Future updateTokenContracts(List addresses) async { + updates++; + info.tokenContractAddresses = addresses; + } +} + +class _WalletInfo extends Fake implements WalletInfo { + _WalletInfo(this.tokenContractAddresses); + + @override + List tokenContractAddresses; +} diff --git a/test/services/trade_service_test.dart b/test/services/trade_service_test.dart new file mode 100644 index 0000000000..fe2c6d5f02 --- /dev/null +++ b/test/services/trade_service_test.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/db/hive/db.dart'; +import 'package:stackwallet/models/exchange/response_objects/trade.dart'; +import 'package:stackwallet/services/exchange/rosen/rosen_exchange.dart'; +import 'package:stackwallet/services/trade_service.dart'; + +void main() { + test('restoring an older bridge swap preserves its signed deposit', () async { + final directory = await Directory.systemTemp.createTemp('rosen-restore-'); + final db = DB.instance; + db.hive.init(directory.path); + if (!db.hive.isAdapterRegistered(Trade.typeId)) { + db.hive.registerAdapter(TradeAdapter()); + } + final box = await db.hive.openBox(DB.boxNameTradesV2); + final service = TradesService(); + final backup = Trade( + uuid: 'restore-test', + tradeId: 'restore-test', + rateType: 'estimated', + direction: 'direct', + timestamp: DateTime.utc(2026, 10, 9), + updatedAt: DateTime.utc(2026, 10, 9), + payInCurrency: 'FIRO', + payInAmount: '1', + payInAddress: 'lock', + payInNetwork: 'firo', + payInExtraId: '', + payInTxid: '', + payOutCurrency: 'rsFIRO', + payOutAmount: '0.9', + payOutAddress: 'recipient', + payOutNetwork: 'eth', + payOutExtraId: '', + payOutTxid: '', + refundAddress: '', + refundExtraId: '', + status: 'Waiting', + exchangeName: RosenExchange.exchangeName, + other: '{"request":"old quote"}', + ); + final funded = backup.copyWith( + payInTxid: 'signed-deposit', + status: 'Verifying', + payOutAmount: '0.8', + other: '{"request":"reviewed quote","funding":{"raw":"signed bytes"}}', + ); + try { + await service.add(trade: funded, shouldNotifyListeners: false); + await service.add(trade: backup, shouldNotifyListeners: false); + final restored = service.get(backup.tradeId)!; + expect(restored.payInTxid, funded.payInTxid); + expect(restored.status, funded.status); + expect(restored.payOutAmount, funded.payOutAmount); + expect(restored.other, funded.other); + + // A newer backup may be the only surviving copy of a signed deposit. + await box.clear(); + await service.add(trade: backup, shouldNotifyListeners: false); + await service.add(trade: funded, shouldNotifyListeners: false); + expect(service.get(backup.tradeId)!.toMap(), funded.toMap()); + } finally { + service.dispose(); + await box.close(); + await directory.delete(recursive: true); + } + }); +} diff --git a/test/wallets/broadcast_callback_test.dart b/test/wallets/broadcast_callback_test.dart new file mode 100644 index 0000000000..7246011137 --- /dev/null +++ b/test/wallets/broadcast_callback_test.dart @@ -0,0 +1,349 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/electrumx_rpc/electrumx_client.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/utilities/extensions/extensions.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/impl/ethereum_wallet.dart'; +import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart'; +import 'package:wallet/wallet.dart' as eth; +import 'package:web3dart/web3dart.dart' as web3; + +void main() { + test( + 'Ethereum awaits the journal before broadcasting its signed bytes', + () async { + final entered = Completer(); + final release = Completer(); + var journaled = false; + var requests = 0; + late String raw; + late String localTxid; + final client = web3.Web3Client( + 'https://ethereum.invalid', + MockClient((request) async { + requests++; + expect(journaled, isTrue); + final rpc = jsonDecode(request.body) as Map; + expect(rpc['method'], 'eth_sendRawTransaction'); + expect((rpc['params'] as List).single, raw); + return http.Response( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': rpc['id'], + 'result': localTxid, + }), + 200, + ); + }), + ); + addTearDown(client.dispose); + + final send = _EthereumWallet(client).confirmSend( + txData: _ethereumTxData(), + prepareTempTx: (txData, _) => txData, + beforeBroadcast: (signed) async { + raw = signed; + expect(raw, startsWith('0x02')); + localTxid = web3.bytesToHex( + web3.keccak256(raw.toUint8ListFromHex), + include0x: true, + ); + entered.complete(); + await release.future; + journaled = true; + }, + ); + + await entered.future; + expect(requests, 0); + release.complete(); + final sent = await send; + expect(requests, 1); + expect(sent.txid, localTxid); + expect(sent.txHash, localTxid); + }, + ); + + test( + 'Rosen Ethereum rejects a node transaction ID that differs from its own', + () async { + var journaled = false; + final client = web3.Web3Client( + 'https://ethereum.invalid', + MockClient((request) async { + expect(journaled, isTrue); + final rpc = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': rpc['id'], + 'result': '0x${'00' * 32}', + }), + 200, + ); + }), + ); + addTearDown(client.dispose); + + await expectLater( + _EthereumWallet(client).confirmSend( + txData: _ethereumTxData(), + prepareTempTx: (txData, _) => txData, + beforeBroadcast: (_) async => journaled = true, + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Ethereum node returned an unexpected transaction ID.', + ), + ), + ); + }, + ); + + test('Ordinary Ethereum sends preserve the existing node response', () async { + final nodeTxid = '0x${'AB' * 32}'; + var requests = 0; + var tempTxPrepared = false; + final client = web3.Web3Client( + 'https://ethereum.invalid', + MockClient((request) async { + requests++; + final rpc = jsonDecode(request.body) as Map; + expect(rpc['method'], 'eth_sendRawTransaction'); + expect((rpc['params'] as List).single, startsWith('0x02')); + return http.Response( + jsonEncode({'jsonrpc': '2.0', 'id': rpc['id'], 'result': nodeTxid}), + 200, + ); + }), + ); + addTearDown(client.dispose); + + final sent = await _EthereumWallet(client).confirmSend( + txData: _ethereumTxData().copyWith(note: 'existing send note'), + prepareTempTx: (txData, address) { + expect(txData.txid, nodeTxid); + expect(txData.txHash, nodeTxid); + expect(address, '0x9858effd232b4033e47d90003d41ec34ecaeda94'); + tempTxPrepared = true; + return txData; + }, + ); + expect(requests, 1); + expect(tempTxPrepared, isTrue); + expect(sent.txid, nodeTxid); + expect(sent.txHash, nodeTxid); + expect(sent.note, 'existing send note'); + }); + + test('Electrum awaits the journal before broadcasting', () async { + final entered = Completer(); + final release = Completer(); + var journaled = false; + final client = _ElectrumClient(() => expect(journaled, isTrue)); + final wallet = _ElectrumWallet() + ..mainDB = MainDB.instance + ..electrumXClient = client; + + final send = wallet.confirmSend( + txData: TxData(raw: '010203', usedUTXOs: []), + beforeBroadcast: (raw) async { + expect(raw, '010203'); + entered.complete(); + await release.future; + journaled = true; + }, + ); + + await entered.future; + expect(client.calls, 0); + release.complete(); + final sent = await send; + expect(client.calls, 1); + expect(sent.txid, 'electrum-txid'); + }); + + for (final ethereum in [true, false]) { + for (final journal in [true, false]) { + testWidgets('${ethereum ? 'Ethereum' : 'Electrum'} ' + '${journal ? 'bridge' : 'ordinary'} broadcast timeout scope', ( + tester, + ) async { + final journalEntered = Completer(); + final journalRelease = Completer(); + final response = Completer(); + var journaled = false; + var requests = 0; + var txid = 'unreached'; + final client = web3.Web3Client( + 'https://ethereum.invalid', + MockClient((request) async { + requests++; + expectSync(journaled, journal); + final rpc = jsonDecode(request.body) as Map; + expectSync(rpc['method'], 'eth_sendRawTransaction'); + final raw = (rpc['params'] as List).single as String; + txid = web3.bytesToHex( + web3.keccak256(raw.toUint8ListFromHex), + include0x: true, + ); + return http.Response( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': rpc['id'], + 'result': await response.future, + }), + 200, + ); + }), + ); + addTearDown(client.dispose); + final ethereumWallet = _EthereumWallet(client); + final electrumWallet = _ElectrumWallet() + ..mainDB = MainDB.instance + ..electrumXClient = _ElectrumClient(() { + requests++; + expectSync(journaled, journal); + txid = 'electrum-txid'; + }, response: response); + Future beforeBroadcast(String raw) async { + journalEntered.complete(); + await journalRelease.future; + journaled = true; + } + + final sending = ethereum + ? ethereumWallet.confirmSend( + txData: _ethereumTxData(), + prepareTempTx: (txData, _) => txData, + beforeBroadcast: journal ? beforeBroadcast : null, + ) + : electrumWallet.confirmSend( + txData: TxData(raw: '010203', usedUTXOs: []), + beforeBroadcast: journal ? beforeBroadcast : null, + ); + Object? result; + final completed = sending.then( + (_) => result = true, + onError: (Object error) => result = error, + ); + try { + await tester.pump(); + if (journal) { + expect(journalEntered.isCompleted, isTrue); + await tester.pump(const Duration(seconds: 31)); + expect(result, isNull); + expect(requests, 0); + journalRelease.complete(); + await tester.pump(); + } + expect(requests, 1); + await tester.pump(const Duration(seconds: 29)); + expect(result, isNull); + await tester.pump(const Duration(seconds: 1)); + expect(response.isCompleted, isFalse); + expect(result, journal ? isA() : isNull); + } finally { + if (!journalRelease.isCompleted) journalRelease.complete(); + response.complete(txid); + await tester.pump(); + await completed; + } + final cacheUpdates = ethereum + ? ethereumWallet.cacheUpdates + : electrumWallet.cacheUpdates; + expect(cacheUpdates, journal ? 0 : 1); + expect(result, journal ? isA() : isTrue); + expect(requests, 1); + }); + } + } +} + +TxData _ethereumTxData() => TxData( + chainId: BigInt.one, + web3dartTransaction: web3.Transaction( + to: eth.EthereumAddress.fromHex( + '0x00112233445566778899aabbccddeeff00112233', + ), + value: eth.EtherAmount.zero(), + nonce: 7, + maxGas: 21000, + maxFeePerGas: eth.EtherAmount.inWei(BigInt.from(30)), + maxPriorityFeePerGas: eth.EtherAmount.inWei(BigInt.from(2)), + ), +); + +class _EthereumWallet extends EthereumWallet { + _EthereumWallet(this.client) : super(CryptoCurrencyNetwork.main); + + final web3.Web3Client client; + int cacheUpdates = 0; + + @override + web3.Web3Client getEthClient() => client; + + @override + Future getMnemonic() async => + 'abandon abandon abandon abandon abandon abandon abandon abandon ' + 'abandon abandon abandon about'; + + @override + Future getMnemonicPassphrase() async => ''; + + @override + Future getCurrentReceivingAddress() async => Address( + walletId: 'test', + value: '0x9858effd232b4033e47d90003d41ec34ecaeda94', + publicKey: [], + derivationIndex: 0, + derivationPath: null, + type: cryptoCurrency.defaultAddressType, + subType: AddressSubType.receiving, + ); + + @override + Future updateSentCachedTxData({required TxData txData}) async { + cacheUpdates++; + return txData; + } +} + +class _ElectrumWallet extends FiroWallet { + _ElectrumWallet() : super(CryptoCurrencyNetwork.main); + + int cacheUpdates = 0; + + @override + Future updateSentCachedTxData({required TxData txData}) async { + cacheUpdates++; + return txData; + } +} + +class _ElectrumClient extends Fake implements ElectrumXClient { + _ElectrumClient(this.beforeSend, {this.response}); + + final void Function() beforeSend; + final Completer? response; + int calls = 0; + + @override + Future broadcastTransaction({ + required String rawTx, + String? requestID, + }) async { + beforeSend(); + calls++; + return response == null ? 'electrum-txid' : await response!.future; + } +} diff --git a/test/wallets/firo_coin_selection_test.dart b/test/wallets/firo_coin_selection_test.dart new file mode 100644 index 0000000000..9ad25a6a58 --- /dev/null +++ b/test/wallets/firo_coin_selection_test.dart @@ -0,0 +1,162 @@ +import 'dart:typed_data'; + +import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/input.dart'; +import 'package:stackwallet/models/isar/models/isar_models.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart'; + +void main() { + test('optimal FIRO selection includes OP_RETURN before signing', () async { + final plain = _Wallet(); + final plainTx = await plain.select(10191); + expect(plainTx.fee!.raw, BigInt.from(191)); + expect(plain.builds, isNotEmpty); + + final bridge = _Wallet(); + await expectLater(bridge.select(10191, data: _data), throwsException); + expect(bridge.builds, isEmpty); + }); + + test('funded FIRO selection pays the serialized data output fee', () async { + final plain = await _Wallet().select(20000); + final bridgeWallet = _Wallet(); + final bridge = await bridgeWallet.select(20000, data: _data); + expect(bridge.usedUTXOs, hasLength(1)); + expect( + bridge.fee!.raw - plain.fee!.raw, + BigInt.from(firoOpReturnOutput(_data).size), + ); + expect(bridge.opReturnData, _data); + expect(bridge.fee!.raw, BigInt.from(bridge.vSize!)); + expect(bridgeWallet.builds.every((tx) => tx.opReturnData == _data), isTrue); + }); +} + +const _address = 'aEF6fyd5jjCPcbiEBZJ2g8583caUme8T7Y'; +final _data = 'aa' * 80; + +class _Wallet extends FiroWallet { + _Wallet() : super(CryptoCurrencyNetwork.main); + + final builds = []; + + Future select(int inputValue, {String? data}) => coinSelection( + txData: TxData( + recipients: [ + TxRecipient( + address: _address, + amount: Amount(rawValue: BigInt.from(10000), fractionDigits: 8), + isChange: false, + addressType: AddressType.p2pkh, + ), + ], + feeRateAmount: BigInt.from(1000), + opReturnData: data, + ), + coinControl: false, + isSendAll: false, + isSendAllCoinControlUtxos: false, + utxos: [ + StandardInput( + UTXO( + walletId: 'test', + txid: '01' * 32, + vout: 0, + value: inputValue, + name: '', + isBlocked: false, + blockedReason: null, + isCoinbase: false, + blockHash: 'confirmed', + blockHeight: 90, + blockTime: 1, + address: _address, + ), + ), + ], + ); + + @override + Future get chainHeight async => 100; + + @override + Future> addSigningKeys(List utxosToUse) async => + utxosToUse; + + @override + coinlib.Input standardInputToCoinlibInput( + StandardInput input, { + int sequence = 0xffffffff, + }) => _SizedInput(input.utxo.txid, input.utxo.vout); + + @override + Future getCurrentChangeAddress() async => Address( + walletId: 'test', + value: _address, + publicKey: [], + derivationIndex: 0, + derivationPath: null, + type: AddressType.p2pkh, + subType: AddressSubType.change, + ); + + @override + Future checkChangeAddressForTransactions() async {} + + @override + Future> helperRecipientsConvert( + List addrs, + List satValues, + ) async => [ + for (var i = 0; i < addrs.length; i++) + TxRecipient( + address: addrs[i], + amount: Amount(rawValue: satValues[i], fractionDigits: 8), + isChange: i > 0, + addressType: AddressType.p2pkh, + ), + ]; + + @override + Future buildTransaction({ + required TxData txData, + required List inputsWithKeys, + }) async { + builds.add(txData); + final transaction = coinlib.Transaction( + inputs: inputsWithKeys + .cast() + .map(standardInputToCoinlibInput) + .toList(), + outputs: [ + for (final recipient in txData.recipients!) + coinlib.Output.fromAddress( + recipient.amount.raw, + coinlib.Address.fromString( + recipient.address, + cryptoCurrency.networkParams, + ), + ), + if (txData.opReturnData != null) + firoOpReturnOutput(txData.opReturnData!), + ], + ); + return txData.copyWith(vSize: transaction.size); + } +} + +class _SizedInput extends coinlib.RawInput { + _SizedInput(String txid, int vout) + : super( + prevOut: coinlib.OutPoint.fromHex(txid, vout), + scriptSig: Uint8List(106), + ); + + @override + int get signedSize => size; +} diff --git a/test/wallets/firo_op_return_test.dart b/test/wallets/firo_op_return_test.dart new file mode 100644 index 0000000000..9de2a87dc6 --- /dev/null +++ b/test/wallets/firo_op_return_test.dart @@ -0,0 +1,249 @@ +import 'dart:typed_data'; + +import 'package:coinlib/coinlib.dart' as coinlib; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart'; + +class _SizedInput extends coinlib.RawInput { + _SizedInput() + : super( + prevOut: coinlib.OutPoint(Uint8List(32), 0), + scriptSig: Uint8List(106), + ); + + @override + int get signedSize => size; +} + +void main() { + test('FIRO data output preserves payload and handles PUSHDATA1 boundary', () { + for (final length in [1, 75, 76, 80]) { + final hex = List.filled(length, 'aa').join(); + final output = firoOpReturnOutput(hex); + final prefix = length <= 75 ? [0x6a, length] : [0x6a, 0x4c, length]; + + expect(output.value, BigInt.zero); + expect(output.scriptPubKey, [...prefix, ...List.filled(length, 0xaa)]); + expect(output.size, 9 + prefix.length + length); + expect(firoOpReturnOutput('0x$hex').scriptPubKey, output.scriptPubKey); + } + for (final hex in ['00', '01', '10', '81', 'ff']) { + expect(firoOpReturnOutput(hex).scriptPubKey, [ + 0x6a, + 1, + int.parse(hex, radix: 16), + ]); + } + }); + + test('FIRO data output rejects malformed or oversized payloads', () { + for (final hex in [ + '', + '0', + 'zz', + '0x', + 'aa bb', + 'aa\n', + List.filled(81, 'aa').join(), + ]) { + expect(() => firoOpReturnOutput(hex), throwsFormatException); + } + }); + + test('coin selection funds the serialized data output before signing', () { + final program = coinlib.P2PKH.fromHash(Uint8List(20)); + final payment = coinlib.Output.fromProgram(BigInt.from(10000), program); + final data = firoOpReturnOutput(List.filled(80, 'aa').join()); + + coinlib.CoinSelection select(int inputValue, {required bool withData}) => + coinlib.CoinSelection( + selected: [ + coinlib.InputCandidate( + input: _SizedInput(), + value: BigInt.from(inputValue), + ), + ], + recipients: [payment, if (withData) data], + changeProgram: program, + feePerKb: BigInt.from(1000), + minFee: BigInt.zero, + minChange: BigInt.from(546), + ); + + final plain = select(20000, withData: false); + final bridge = select(20000, withData: true); + expect(bridge.ready, isTrue); + expect(bridge.fee - plain.fee, BigInt.from(data.size)); + expect(plain.changeValue - bridge.changeValue, BigInt.from(data.size)); + expect(bridge.transaction.outputs.where((o) => o.value == BigInt.zero), [ + data, + ]); + expect(bridge.transaction.size, bridge.signedSize); + + // A UTXO that covers the payment alone must not pass bridge fee selection. + final exactPlainValue = 10000 + plain.signedSize - payment.size; + expect(select(exactPlainValue, withData: false).ready, isTrue); + expect(select(exactPlainValue, withData: true).ready, isFalse); + }); + + test('decodes complete v3 special transactions and derives their txid', () { + const inputTxid = + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + const raw = + '0300090001' + '1f1e1d1c1b1a191817161514131211100f0e0d0c0b0a09080706050403020100' + '0700000000ffffffff' + '01010000000000000000' + '00000000' + '03aabbcc'; + const txid = + '41630f29d16f8e4c246cba40d58dac6c69b3821ec319c5140e58fde5b2c356bc'; + + final decoded = decodeFiroTransaction(raw); + expect(decoded.txid, txid); + expect(decoded.prevouts.single, (txid: inputTxid, vout: 7)); + expect( + decodeFiroTransaction(raw.replaceFirst('aabbcc', 'aabbcd')).txid, + isNot(txid), + ); + expect( + () => decodeFiroTransaction(raw.substring(0, raw.length - 8)), + throwsFormatException, + ); + expect(() => decodeFiroTransaction('${raw}00'), throwsFormatException); + }); + + test('structurally signed FIRO bytes match the prepared envelope', () { + const metadata = + '03000000000000007b00000000000001c81400112233445566778899aabbccdd' + 'eeff00112233'; + const inputTxid = + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + const serializedInputHash = + '1f1e1d1c1b1a191817161514131211100f0e0d0c0b0a09080706050403020100'; + const otherTxid = + '202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f'; + const otherSerializedInputHash = + '3f3e3d3c3b3a393837363534333231302f2e2d2c2b2a29282726252423222120'; + const paymentScript = '76a914111111111111111111111111111111111111111188ac'; + const changeScript = '76a914222222222222222222222222222222222222222288ac'; + // Fixed low-R/low-S SIGHASH_ALL signature for private key 1 and its P2PKH + // previous output; no platform-native signing is needed at test time. + const derSignature = + '304402206687c87c5f80c4e2a4e63fed02b0de65bcd38b77255b1de3f375f5d7' + 'a1b124c9' + '02202d5997a82c65d254e08ecc2ef7ba15995df3adfefa509b6ce0fdd65642a4062f'; + const publicKey = + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + const invalidPublicKey = + '02ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; + const paymentOutput = + '00e1f505000000001976a914111111111111111111111111111111111111111188ac'; + const changeOutput = + '80f0fa02000000001976a914222222222222222222222222222222222222222288ac'; + const dataOutput = '0000000000000000286a26$metadata'; + const transactionPrefix = + '0100000001${serializedInputHash}000000006a47${derSignature}0121' + '${publicKey}feffffff'; + const signedRaw = + '${transactionPrefix}03$paymentOutput$changeOutput$dataOutput' + '00000000'; + const witnessRaw = + '01000000000101$serializedInputHash' + '000000006a47$derSignature' + '0121${publicKey}feffffff' + '03$paymentOutput$changeOutput$dataOutput' + '0000000000'; + const signedTxid = + '3bd009801405a65f11f0e1c635025d115e93b175433a99958ac3cd5cd16ef64c'; + final inputValue = BigInt.from(150001000); + final amount = BigInt.from(100000000); + final change = BigInt.from(50000000); + final fee = BigInt.from(1000); + final inputs = [ + (txid: inputTxid, vout: 0, value: inputValue), + ]; + final outputs = [ + (script: paymentScript, value: amount), + (script: changeScript, value: change), + ]; + String verify({ + String? raw, + String? opReturn, + Iterable? expectedInputs, + Iterable? expectedOutputs, + BigInt? expectedFee, + }) => verifyFiroOpReturnTransaction( + raw: raw ?? signedRaw, + data: opReturn ?? metadata, + expectedInputs: expectedInputs ?? inputs, + expectedOutputs: expectedOutputs ?? outputs, + expectedFee: expectedFee ?? fee, + ); + + expect(verify(), signedTxid); + expect(decodeFiroTransaction(witnessRaw).txid, signedTxid); + expect( + verify( + expectedInputs: inputs.reversed, + expectedOutputs: outputs.reversed, + ), + signedTxid, + ); + + for (final raw in [ + '${transactionPrefix}02$paymentOutput$dataOutput' + '00000000', + '${transactionPrefix}04$paymentOutput$changeOutput$dataOutput$dataOutput' + '00000000', + signedRaw.replaceFirst( + paymentOutput, + paymentOutput.replaceFirst('00e1f505', 'ffe0f505'), + ), + signedRaw.replaceFirst(dataOutput, '0100000000000000286a26$metadata'), + signedRaw.replaceFirst(metadata, '${metadata.substring(0, 74)}ff'), + signedRaw.replaceFirst(serializedInputHash, otherSerializedInputHash), + signedRaw.replaceFirst('3044', '3144'), + signedRaw.replaceFirst('30440220', '30440320'), + signedRaw.replaceFirst('022066', '0220e6'), + signedRaw.replaceFirst(publicKey, invalidPublicKey), + '${signedRaw}00', + '0100000001${serializedInputHash}0000000000feffffff' + '03$paymentOutput$changeOutput$dataOutput' + '00000000', + '0100000001${serializedInputHash}0000000001d3feffffff' + '03$paymentOutput$changeOutput$dataOutput' + '00000000', + witnessRaw, + ]) { + expect(() => verify(raw: raw), throwsStateError); + } + + for (final expectedInputs in [ + [], + [(txid: inputTxid, vout: 1, value: inputValue)], + [(txid: otherTxid, vout: 0, value: inputValue)], + [(txid: inputTxid, vout: 0, value: inputValue + BigInt.one)], + [...inputs, ...inputs], + ]) { + expect(() => verify(expectedInputs: expectedInputs), throwsStateError); + } + for (final expectedOutputs in [ + [], + [(script: paymentScript, value: amount)], + [(script: paymentScript, value: amount - BigInt.one), outputs[1]], + [ + (script: paymentScript.replaceFirst('11', '12'), value: amount), + outputs[1], + ], + [...outputs, outputs.first], + ]) { + expect(() => verify(expectedOutputs: expectedOutputs), throwsStateError); + } + expect(() => verify(expectedFee: fee + BigInt.one), throwsStateError); + expect( + () => verify(opReturn: '${metadata.substring(0, 74)}ff'), + throwsStateError, + ); + }); +}