From 59001252baebb24e80a934a6fb49c71aabc055d5 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 17 Sep 2026 03:15:47 +0800 Subject: [PATCH 01/25] feat: add rsFIRO Ethereum token --- lib/utilities/default_eth_tokens.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/utilities/default_eth_tokens.dart b/lib/utilities/default_eth_tokens.dart index 63cd7f62b3..ca6591ed8f 100644 --- a/lib/utilities/default_eth_tokens.dart +++ b/lib/utilities/default_eth_tokens.dart @@ -47,5 +47,12 @@ abstract class DefaultTokens { decimals: 18, type: EthContractType.erc20, ), + EthContract( + address: "0x2744ea5ac9b11cb5e3cd63d3a88e858336aeddc2", + name: "rsFIRO", + symbol: "rsFIRO", + decimals: 8, + type: EthContractType.erc20, + ), ]; } From 276f879fd3ed10e863083ae15a80ff8e5dc0727e Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 17 Sep 2026 03:34:37 +0800 Subject: [PATCH 02/25] fix: remove invalid final parameter modifiers --- tool/process_pubspec_deps.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tool/process_pubspec_deps.dart b/tool/process_pubspec_deps.dart index 80110b748b..d6c2b09cd0 100644 --- a/tool/process_pubspec_deps.dart +++ b/tool/process_pubspec_deps.dart @@ -16,7 +16,7 @@ void main(List args) { _process(args[0], args.sublist(1)); } -void _process(final String filePath, final List enableCoinMarkers) { +void _process(String filePath, List enableCoinMarkers) { final lines = File(filePath).readAsLinesSync(); String? activeMarker; From 69f4682306251e312c769b6a6df2478eb7314329 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 17 Sep 2026 12:51:58 +0800 Subject: [PATCH 03/25] feat: add Rosen Bridge FIRO swaps with quote refresh --- docs/rosen_bridge.md | 67 +++ .../exchange/exchange_exception.dart | 7 +- lib/models/isar/exchange_cache/currency.dart | 7 +- .../choose_address_from_stack_view.dart | 179 +++---- .../confirm_change_now_send.dart | 278 +++++++---- lib/pages/exchange_view/exchange_form.dart | 70 +-- .../exchange_step_views/step_2_view.dart | 134 ++++-- .../exchange_step_views/step_3_view.dart | 308 +++++++----- .../exchange_step_views/step_4_view.dart | 151 +++--- .../exchange_view/rosen_quote_dialog.dart | 69 +++ lib/pages/exchange_view/send_from_view.dart | 202 +++++--- .../exchange_provider_options.dart | 7 + .../exchange_view/trade_details_view.dart | 197 ++++---- .../exchange_steps/step_scaffold.dart | 351 ++++++++------ .../subwidgets/desktop_step_2.dart | 67 ++- .../subwidgets/desktop_step_4.dart | 86 +++- .../desktop_choose_address_from_stack.dart | 178 +++---- lib/services/exchange/exchange.dart | 3 + .../exchange_data_loading_service.dart | 29 +- lib/services/exchange/rosen/rosen_api.dart | 151 ++++++ .../exchange/rosen/rosen_exchange.dart | 437 ++++++++++++++++++ lib/services/exchange/rosen/rosen_fees.dart | 196 ++++++++ .../exchange/rosen/rosen_funding.dart | 350 ++++++++++++++ .../exchange/rosen/rosen_protocol.dart | 118 +++++ lib/services/trade_service.dart | 50 ++ lib/wallets/wallet/impl/ethereum_wallet.dart | 2 + .../impl/sub_wallets/eth_token_wallet.dart | 6 +- .../electrumx_interface.dart | 96 ++-- .../firo_op_return.dart | 79 ++++ .../mweb_interface.dart | 20 +- .../spark_interface.dart | 31 +- lib/widgets/icon_widgets/exchange_icon.dart | 4 + test/rosen_fees_check.dart | 117 +++++ .../exchange/rosen/rosen_protocol_test.dart | 246 ++++++++++ .../exchange/rosen_registration_test.dart | 285 ++++++++++++ test/wallets/firo_op_return_test.dart | 169 +++++++ 36 files changed, 3809 insertions(+), 938 deletions(-) create mode 100644 docs/rosen_bridge.md create mode 100644 lib/pages/exchange_view/rosen_quote_dialog.dart create mode 100644 lib/services/exchange/rosen/rosen_api.dart create mode 100644 lib/services/exchange/rosen/rosen_exchange.dart create mode 100644 lib/services/exchange/rosen/rosen_fees.dart create mode 100644 lib/services/exchange/rosen/rosen_funding.dart create mode 100644 lib/services/exchange/rosen/rosen_protocol.dart create mode 100644 lib/wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart create mode 100644 test/rosen_fees_check.dart create mode 100644 test/services/exchange/rosen/rosen_protocol_test.dart create mode 100644 test/services/exchange/rosen_registration_test.dart create mode 100644 test/wallets/firo_op_return_test.dart diff --git a/docs/rosen_bridge.md b/docs/rosen_bridge.md new file mode 100644 index 0000000000..c39ca3f239 --- /dev/null +++ b/docs/rosen_bridge.md @@ -0,0 +1,67 @@ +# 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. + +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 for complete transparent inputs, the exact bridge lock payment and exactly one zero-value OP_RETURN containing the stored metadata. Missing, changed or duplicate metadata 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, token balance, gas estimate, destination, amount, metadata and current source-chain height. 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. Broadcast transaction IDs are captured before local wallet-history updates; saved pending deposits remain ongoing swaps after restart. 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. + +## Protocol and production configuration + +Verified on 2026-09-17 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–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. + +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. + +Other Stack providers use the same estimate/confirmation machinery. ChangeNOW's expired `rateId` response is recognized, but previously produced an ordinary trade-creation error; there is no existing automatic stale-quote recovery or expiry timer shared by providers. Rosen uses the existing dialog and confirmation components with an explicit **Refresh quote** action. + +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 + +The standalone fee check requires only Dart: + +```sh +dart --enable-asserts test/rosen_fees_check.dart +``` + +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/rosen_protocol_test.dart test/wallets/firo_op_return_test.dart test/services/exchange/rosen_registration_test.dart +``` + +The fee check, 11 isolated protocol/OP_RETURN tests and read-only live fee/status endpoint checks were exercised during implementation and review. The tests include real native FIRO signing and validation of serialized outputs, rejection of altered or missing bridge outputs, and real web3dart signing through a mock `eth_sendRawTransaction` endpoint for both P2PKH and P2SH FIRO destinations. The Ethereum tests inspect the signed envelope for mainnet, the token contract, zero ETH and the complete metadata-bearing calldata. The `uint8` result from `decimals()` was verified to decode as `BigInt` with the pinned web3dart 3.0.1 dependency. + +Review traced fresh mobile/desktop swaps, wallet-initiated swaps and resumed swaps from history through the shared Rosen prepare/confirm functions. Plain-address recipient QR scans now enable Next for Rosen, which does not use a refund-address field. No full Flutter build, complete application test run, or live bridge transfer was performed. Mobile and desktop funding, persistence, and completion still need validation in the configured application with real wallets before release. + +Quote-refresh checks additionally cover fee increases/decreases, equal-total fee-component changes, preserving source amounts and destinations, regenerating metadata in both directions, stale/funded-save rejection and concurrent refresh protection. The standalone fee check ran directly; isolated harnesses exercised the actual refresh methods with fake network/wallet/database dependencies. Regression tests using real Hive are included in `rosen_registration_test.dart` but require the configured Flutter test environment and were not run here. 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 3a5c759262..d16d9cba76 100644 --- a/lib/pages/exchange_view/confirm_change_now_send.dart +++ b/lib/pages/exchange_view/confirm_change_now_send.dart @@ -14,6 +14,7 @@ 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'; @@ -21,6 +22,8 @@ import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.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'; @@ -44,6 +47,7 @@ import '../../widgets/stack_dialog.dart'; import '../pinpad_views/lock_screen_view.dart'; import '../send_view/sub_widgets/sending_transaction_dialog.dart'; import '../wallet_view/wallet_view.dart'; +import 'rosen_quote_dialog.dart'; class ConfirmChangeNowSendView extends ConsumerStatefulWidget { const ConfirmChangeNowSendView({ @@ -77,8 +81,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; @@ -114,13 +148,21 @@ class _ConfirmChangeNowSendViewState final time = Future.delayed(const Duration(milliseconds: 2500)); + String? broadcastTxid; late String txid; 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 +170,26 @@ class _ConfirmChangeNowSendViewState unawaited(wallet.refresh()); - final results = await Future.wait([txidFuture, time]); + final sent = await txidFuture; + txid = sent.txid!; + broadcastTxid = txid; + if (_isRosen) { + await ref + .read(tradesServiceProvider) + .edit( + trade: trade.copyWith( + payInTxid: txid, + status: "Confirming", + updatedAt: DateTime.now(), + ), + shouldNotifyListeners: true, + ); + } + await time; sendProgressController.triggerSuccess?.call(); await Future.delayed(const Duration(seconds: 5)); - txid = (results.first as TxData).txid!; - // save note await ref .read(mainDBProvider) @@ -167,6 +222,7 @@ class _ConfirmChangeNowSendViewState Navigator.of(context).popUntil(ModalRoute.withName(routeOnSuccessName)); } } catch (e, s) { + if (broadcastTxid == null) _sending = false; Logging.instance.e( "Broadcast transaction failed: ", error: e, @@ -176,14 +232,27 @@ class _ConfirmChangeNowSendViewState // pop sending dialog closeSendingDialog(); + if (broadcastTxid == null && + e is ExchangeException && + e.type == ExchangeExceptionType.quoteChanged) { + if (!mounted) return; + 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(), + title: broadcastTxid == null + ? "Broadcast transaction failed" + : "Transaction sent", + message: broadcastTxid == null + ? e.toString() + : "Transaction $broadcastTxid was sent, but saving its details failed: $e. Do not send again.", rightButton: TextButton( style: Theme.of(context) .extension()! @@ -191,9 +260,9 @@ class _ConfirmChangeNowSendViewState child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: Theme.of( - context, - ).extension()!.buttonTextSecondary, + color: Theme.of(context) + .extension()! + .buttonTextSecondary, ), ), onPressed: () { @@ -206,7 +275,18 @@ class _ConfirmChangeNowSendViewState } } + 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 (_quoteChanged) { + await _refreshQuote(); + return; + } final dynamic unlocked; final coin = ref.read(pWalletCoin(walletId)); @@ -281,13 +361,13 @@ class _ConfirmChangeNowSendViewState builder: (child) { return Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( - backgroundColor: Theme.of( - context, - ).extension()!.backgroundAppBar, + backgroundColor: Theme.of(context) + .extension()! + .backgroundAppBar, leading: AppBarBackButton( onPressed: () async { // if (FocusScope.of(context).hasFocus) { @@ -344,7 +424,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), ), ], @@ -355,9 +435,9 @@ class _ConfirmChangeNowSendViewState children: [ RoundedWhiteContainer( padding: const EdgeInsets.all(0), - borderColor: Theme.of( - context, - ).extension()!.background, + borderColor: Theme.of(context) + .extension()! + .background, child: child, ), const SizedBox(height: 16), @@ -373,9 +453,9 @@ class _ConfirmChangeNowSendViewState ), const SizedBox(height: 10), RoundedContainer( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -388,22 +468,21 @@ class _ConfirmChangeNowSendViewState ) .format(widget.txData.fee!), style: - STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, - ), + STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), ), ], ), ), const SizedBox(height: 16), RoundedContainer( - color: Theme.of( - context, - ).extension()!.snackBarBackSuccess, + color: Theme.of(context) + .extension()! + .snackBarBackSuccess, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -417,13 +496,8 @@ class _ConfirmChangeNowSendViewState ), 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), + _totalAmount(), style: STextStyles.itemSubtitle12(context) .copyWith( color: Theme.of(context) @@ -450,7 +524,7 @@ class _ConfirmChangeNowSendViewState const SizedBox(width: 16), Expanded( child: PrimaryButton( - label: "Send", + label: _quoteChanged ? "Refresh quote" : "Send", buttonHeight: isDesktop ? ButtonHeight.l : null, onPressed: _confirmSend, ), @@ -481,7 +555,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), @@ -489,12 +563,43 @@ class _ConfirmChangeNowSendViewState ), isDesktop ? Container( - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, 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, @@ -510,9 +615,9 @@ class _ConfirmChangeNowSendViewState ), isDesktop ? Container( - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, height: 1, ) : const SizedBox(height: 12), @@ -535,9 +640,9 @@ class _ConfirmChangeNowSendViewState ), isDesktop ? Container( - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, height: 1, ) : const SizedBox(height: 12), @@ -560,7 +665,7 @@ class _ConfirmChangeNowSendViewState ), ); final String extra; - if (price == null) { + if (price == null || _isRosenToken) { extra = ""; } else { final amountWithoutChange = @@ -589,9 +694,9 @@ class _ConfirmChangeNowSendViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle2, + color: Theme.of(context) + .extension()! + .textSubtitle2, ), ); }, @@ -599,14 +704,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, ), @@ -616,9 +725,9 @@ class _ConfirmChangeNowSendViewState ), isDesktop ? Container( - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, height: 1, ) : const SizedBox(height: 12), @@ -644,9 +753,9 @@ class _ConfirmChangeNowSendViewState ), isDesktop ? Container( - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, height: 1, ) : const SizedBox(height: 12), @@ -665,9 +774,9 @@ class _ConfirmChangeNowSendViewState ), isDesktop ? Container( - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, height: 1, ) : const SizedBox(height: 12), @@ -687,35 +796,28 @@ class _ConfirmChangeNowSendViewState if (!isDesktop) const SizedBox(height: 12), if (!isDesktop) RoundedContainer( - color: Theme.of( - context, - ).extension()!.snackBarBackSuccess, + color: Theme.of(context) + .extension()! + .snackBarBackSuccess, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "Total amount", style: STextStyles.titleBold12(context).copyWith( - color: Theme.of( - context, - ).extension()!.textConfirmTotalAmount, + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, ), ), 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()!.textConfirmTotalAmount, + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, ), textAlign: TextAlign.right, ); @@ -728,7 +830,7 @@ class _ConfirmChangeNowSendViewState if (!isDesktop) const Spacer(), if (!isDesktop) PrimaryButton( - label: "Send", + label: _quoteChanged ? "Refresh quote" : "Send", 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 c328e5e4f7..b08364a60e 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'; @@ -90,6 +91,7 @@ class _ExchangeFormState extends ConsumerState { NanswapExchange.instance, WizardSwapExchange.instance, CypherGoatExchange.instance, + RosenExchange.instance, ]; } } @@ -115,9 +117,10 @@ class _ExchangeFormState extends ConsumerState { builder: (_) => WillPopScope( onWillPop: () async => false, child: Container( - color: Theme.of( - context, - ).extension()!.overlay.withOpacity(0.6), + color: Theme.of(context) + .extension()! + .overlay + .withOpacity(0.6), child: const CustomLoadingOverlay( message: "Updating exchange rate", eventBus: null, @@ -303,9 +306,9 @@ class _ExchangeFormState extends ConsumerState { Expanded( child: RoundedWhiteContainer( padding: const EdgeInsets.all(16), - borderColor: Theme.of( - context, - ).extension()!.background, + borderColor: Theme.of(context) + .extension()! + .background, child: ExchangeCurrencySelectionView( pairedCurrency: paired, isFixedRate: isFixedRate, @@ -344,6 +347,9 @@ class _ExchangeFormState extends ConsumerState { _sendFocusNode.unfocus(); ref.read(efRateTypeProvider.notifier).state = newType; + if (newType == ExchangeRateType.estimated) { + ref.read(efReversedProvider.notifier).state = false; + } update(); } @@ -431,8 +437,7 @@ class _ExchangeFormState extends ConsumerState { context: context, builder: (context) => const StackOkDialog( title: "WOW error", - message: - "Wownero is temporarily disabled as a receiving currency for fixed rate trades due to network issues", + message: "Wownero is temporarily disabled as a receiving currency for fixed rate trades due to network issues", ), ); } @@ -594,9 +599,8 @@ class _ExchangeFormState extends ConsumerState { ); } else { unawaited( - Navigator.of( - context, - ).pushNamed(Step2View.routeName, arguments: model), + Navigator.of(context) + .pushNamed(Step2View.routeName, arguments: model), ); } } else { @@ -617,9 +621,8 @@ class _ExchangeFormState extends ConsumerState { ); } else { unawaited( - Navigator.of( - context, - ).pushNamed(Step1View.routeName, arguments: model), + Navigator.of(context) + .pushNamed(Step1View.routeName, arguments: model), ); } } @@ -761,7 +764,8 @@ class _ExchangeFormState extends ConsumerState { } }); _receiveFocusNode.addListener(() { - if (_receiveFocusNode.hasFocus) { + if (_receiveFocusNode.hasFocus && + ref.read(efRateTypeProvider) == ExchangeRateType.fixed) { final reversed = ref.read(efReversedProvider); WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(efReversedProvider.notifier).state = true; @@ -893,13 +897,13 @@ class _ExchangeFormState extends ConsumerState { textStyle: STextStyles.smallMed14(context).copyWith( color: Theme.of(context).extension()!.textDark, ), - buttonColor: Theme.of( - context, - ).extension()!.buttonBackSecondary, + buttonColor: Theme.of(context) + .extension()! + .buttonBackSecondary, borderRadius: Constants.size.circularBorderRadius, - background: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + background: Theme.of(context) + .extension()! + .textFieldDefaultBG, onTap: () { if (_sendController.text == "-") { _sendController.text = ""; @@ -935,9 +939,9 @@ class _ExchangeFormState extends ConsumerState { padding: isDesktop ? const EdgeInsets.all(6) : const EdgeInsets.all(2), - color: Theme.of( - context, - ).extension()!.buttonBackSecondary, + color: Theme.of(context) + .extension()! + .buttonBackSecondary, radiusMultiplier: 0.75, child: GestureDetector( onTap: () async { @@ -949,9 +953,9 @@ class _ExchangeFormState extends ConsumerState { Assets.svg.swap, width: 20, height: 20, - color: Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), ), @@ -970,13 +974,13 @@ class _ExchangeFormState extends ConsumerState { textStyle: STextStyles.smallMed14(context).copyWith( color: Theme.of(context).extension()!.textDark, ), - buttonColor: Theme.of( - context, - ).extension()!.buttonBackSecondary, + buttonColor: Theme.of(context) + .extension()! + .buttonBackSecondary, borderRadius: Constants.size.circularBorderRadius, - background: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + background: Theme.of(context) + .extension()! + .textFieldDefaultBG, onTap: rateType == ExchangeRateType.estimated ? null : () { 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 1b2fa42c44..70fe6dbb33 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,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app_config.dart'; import '../../../models/exchange/incomplete_exchange.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/barcode_scanner_interface.dart'; @@ -22,6 +24,7 @@ import '../../../utilities/clipboard_interface.dart'; import '../../../utilities/constants.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'; @@ -150,7 +153,7 @@ class _Step2ViewState extends ConsumerState { enableNext = _toController.text.isNotEmpty && (_refundController.text.isNotEmpty || - !!ref.read(efExchangeProvider).supportsRefundAddress); + !ref.read(efExchangeProvider).supportsRefundAddress); }); } } on PlatformException catch (e, s) { @@ -231,7 +234,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( @@ -283,25 +288,48 @@ 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: () { 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, + .push( + MaterialPageRoute( + settings: const RouteSettings( + name: + ChooseAddressFromStackView + .routeName, + ), + builder: (_) => + ChooseAddressFromStackView( + coin: coin, + transparentOnly: isRosen, + ), + ), ) .then((value) async { if (value @@ -310,6 +338,19 @@ class _Step2ViewState extends ConsumerState { String address, String walletName, })) { + if (isRosen && + model.receiveTicker + .toLowerCase() == + "rsfiro") { + await RosenFunding.registerToken( + ref + .read(pWallets) + .getWallet( + value.walletId, + ), + ); + if (!mounted) return; + } _toController.text = value.walletName; model.recipientAddress = @@ -748,44 +789,43 @@ class _Step2ViewState extends ConsumerState { ) .state = true; - Navigator.of(context) - .pushNamed( - AddressBookView - .routeName, - ) - .then((_) { - ref - .read( - exchangeFlowIsActiveStateProvider - .state, - ) - .state = - false; - final address = ref + Navigator.of( + context, + ).pushNamed(AddressBookView.routeName).then(( + _, + ) { + ref .read( - exchangeFromAddressBookAddressStateProvider + exchangeFlowIsActiveStateProvider .state, ) - .state; - if (address - .isNotEmpty) { + .state = + false; + final address = ref + .read( + exchangeFromAddressBookAddressStateProvider + .state, + ) + .state; + if (address + .isNotEmpty) { + _refundController + .text = + address; + model.refundAddress = _refundController - .text = - address; - model.refundAddress = - _refundController - .text; - } - setState(() { - enableNext = - _toController - .text - .isNotEmpty && - _refundController - .text - .isNotEmpty; - }); - }); + .text; + } + setState(() { + enableNext = + _toController + .text + .isNotEmpty && + _refundController + .text + .isNotEmpty; + }); + }); }, child: const AddressBookIcon(), 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 4f0b352c3c..4e4f6068c9 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() { @@ -205,150 +209,204 @@ class _Step3ViewState extends ConsumerState { ), child: Text( "Back", - style: STextStyles.button( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.button(context) + .copyWith( + color: Theme.of(context) .extension()! .buttonTextSecondary, - ), + ), ), ), ), 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: null, - addressRefund: - supportsRefund - ? model.refundAddress! - : "", - 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: null, + addressRefund: + supportsRefund + ? model.refundAddress! + : "", + 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 896feb74be..30876fe7e4 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,6 +19,8 @@ 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'; @@ -72,6 +74,7 @@ class _Step4ViewState extends ConsumerState { late final ClipboardInterface clipboard; String _statusString = "New"; + bool get _isRosen => model.trade!.exchangeName == RosenExchange.exchangeName; Timer? _statusTimer; @@ -133,9 +136,9 @@ class _Step4ViewState extends ConsumerState { child: Text( "Cancel", style: STextStyles.button(context).copyWith( - color: Theme.of( - context, - ).extension()!.buttonTextSecondary, + color: Theme.of(context) + .extension()! + .buttonTextSecondary, ), ), ), @@ -153,12 +156,19 @@ class _Step4ViewState extends ConsumerState { void initState() { model = widget.model; clipboard = widget.clipboard; - - isWalletCoinAndCanSend = - Util.isWalletCoinAndCanSendWithoutWalletOpenedIgnoringXMR( - model.trade!.payInCurrency, - ref.read(pWallets).wallets, - ); + _statusString = model.trade!.status == "Waiting" + ? "Waiting for deposit" + : model.trade!.status; + + 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(); @@ -195,9 +205,9 @@ class _Step4ViewState extends ConsumerState { return await showModalBottomSheet( context: context, - backgroundColor: Theme.of( - context, - ).extension()!.backgroundAppBar, + backgroundColor: Theme.of(context) + .extension()! + .backgroundAppBar, shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(Constants.size.circularBorderRadius * 3), @@ -368,9 +378,9 @@ class _Step4ViewState extends ConsumerState { child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: Theme.of( - context, - ).extension()!.buttonTextSecondary, + color: Theme.of(context) + .extension()! + .buttonTextSecondary, ), ), onPressed: () { @@ -394,9 +404,9 @@ class _Step4ViewState extends ConsumerState { }, child: Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: Padding( padding: const EdgeInsets.all(10), @@ -409,9 +419,9 @@ class _Step4ViewState extends ConsumerState { width: 24, height: 24, colorFilter: ColorFilter.mode( - Theme.of( - context, - ).extension()!.topNavIconPrimary, + Theme.of(context) + .extension()! + .topNavIconPrimary, .srcIn, ), ), @@ -440,19 +450,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), @@ -468,16 +482,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( @@ -520,10 +535,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) @@ -553,6 +574,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( @@ -561,9 +593,9 @@ class _WarningInfo extends StatelessWidget { "You must send at least " "${model.sendAmount.toString()} ${model.sendTicker}. ", style: STextStyles.label700(context).copyWith( - color: Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of(context) + .extension()! + .warningForeground, ), children: [ TextSpan( @@ -573,9 +605,9 @@ class _WarningInfo extends StatelessWidget { " your transaction may not be converted and it may not be" " refunded.", style: STextStyles.label(context).copyWith( - color: Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of(context) + .extension()! + .warningForeground, ), ), ], @@ -599,7 +631,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; @@ -618,16 +653,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: model.sendAmount.toAmount( - fractionDigits: coin.fractionDigits, + fractionDigits: isRosen + ? RosenFunding.fractionDigits(model.trade!) + : 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..bf73428fac --- /dev/null +++ b/lib/pages/exchange_view/rosen_quote_dialog.dart @@ -0,0 +1,69 @@ +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 9fe4121ee0..505de9b110 100644 --- a/lib/pages/exchange_view/send_from_view.dart +++ b/lib/pages/exchange_view/send_from_view.dart @@ -16,10 +16,13 @@ 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/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 +51,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 +99,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(); @@ -106,9 +114,9 @@ class _SendFromViewState extends ConsumerState { builder: (child) { return Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -160,7 +168,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 +178,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,16 +245,30 @@ 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; + bool _preparing = false; + + Future _send({ + bool? shouldSendPublicFiroFunds, + bool refreshQuote = false, + }) async { + if (_preparing) return; + _preparing = true; 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 { - bool wasCancelled = false; - final wallet = ref.read(pWallets).getWallet(walletId); + isBuildingDialogOpen = true; unawaited( showDialog( context: context, @@ -246,18 +285,31 @@ 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(); + // The mobile building dialog closes itself before this callback. + if (Util.isDesktop) { + closeBuildingDialog(); + } else { + isBuildingDialogOpen = false; + } }, ), ); }, - ), + ).whenComplete(() => isBuildingDialogOpen = false), ); + if (refreshQuote) { + trade = await RosenFunding.refreshTrade(wallet: wallet, trade: trade); + if (!mounted) return; + 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 @@ -279,7 +331,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 @@ -329,9 +383,7 @@ class _SendFromCardState extends ConsumerState { if (!wasCancelled) { // pop building dialog - if (mounted) { - Navigator.of(context, rootNavigator: Util.isDesktop).pop(); - } + closeBuildingDialog(); txData = txData.copyWith( note: @@ -340,7 +392,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( @@ -358,42 +410,57 @@ class _SendFromCardState extends ConsumerState { ), ), ); + refreshRequested = result == true; } } } catch (e, s) { Logging.instance.e("$e\n$s", error: e, stackTrace: s); - if (mounted) { - // 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, + if (mounted && !wasCancelled) { + 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 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(); + }, ), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ); - }, - ); + ); + }, + ); + } } + } finally { + closeBuildingDialog(); + _preparing = false; + } + if (mounted && refreshRequested) { + unawaited( + _send( + shouldSendPublicFiroFunds: shouldSendPublicFiroFunds, + refreshQuote: true, + ), + ); } } @@ -425,11 +492,14 @@ 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()!.highlight, + splashColor: Theme.of(context) + .extension()! + .highlight, key: Key("walletsSheetItemButtonFiroPrivateKey_$walletId"), padding: const EdgeInsets.all(0), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, @@ -481,9 +551,9 @@ class _SendFromCardState extends ConsumerState { Assets.svg.chevronRight, height: 14, width: 7, - color: Theme.of( - context, - ).extension()!.infoItemLabel, + color: Theme.of(context) + .extension()! + .infoItemLabel, ), ], ), @@ -491,9 +561,9 @@ class _SendFromCardState extends ConsumerState { ), ), MaterialButton( - splashColor: Theme.of( - context, - ).extension()!.highlight, + splashColor: Theme.of(context) + .extension()! + .highlight, key: Key("walletsSheetItemButtonFiroPublicKey_$walletId"), padding: const EdgeInsets.all(0), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, @@ -524,7 +594,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( @@ -543,9 +615,9 @@ class _SendFromCardState extends ConsumerState { Assets.svg.chevronRight, height: 14, width: 7, - color: Theme.of( - context, - ).extension()!.infoItemLabel, + color: Theme.of(context) + .extension()! + .infoItemLabel, ), ], ), @@ -606,11 +678,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..fe5cce830d 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'; @@ -171,8 +173,10 @@ class _TradeDetailsViewState extends ConsumerState { ), ); + final isRosen = trade.exchangeName == RosenExchange.exchangeName; final bool hasTx = sentFromStack || + (isRosen && trade.payInTxid.isNotEmpty) || !(trade.status == "New" || trade.status == "new" || trade.status == "wait" || @@ -203,11 +207,16 @@ class _TradeDetailsViewState extends ConsumerState { final showSendFromStackButton = !hasTx && - AppConfig.isStackCoin(trade.payInCurrency) && - Util.isWalletCoinAndCanSendWithoutWalletOpenedIgnoringXMR( - trade.payInCurrency, - ref.read(pWallets).wallets, - ) && + (isRosen + ? ref + .read(pWallets) + .wallets + .any((wallet) => RosenFunding.canFund(wallet, trade)) + : AppConfig.isStackCoin(trade.payInCurrency) && + Util.isWalletCoinAndCanSendWithoutWalletOpenedIgnoringXMR( + trade.payInCurrency, + ref.read(pWallets).wallets, + )) && (trade.status == "New" || trade.status == "new" || trade.status == "waiting" || @@ -218,13 +227,13 @@ class _TradeDetailsViewState extends ConsumerState { 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 +268,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, @@ -277,9 +286,11 @@ class _TradeDetailsViewState extends ConsumerState { onPressed: () { CryptoCurrency coin; try { - coin = AppConfig.getCryptoCurrencyForTicker( - trade.payInCurrency, - )!; + coin = isRosen + ? RosenFunding.sourceCoin(trade) + : AppConfig.getCryptoCurrencyForTicker( + trade.payInCurrency, + )!; } catch (_) { coin = AppConfig.getCryptoCurrencyByPrettyName( trade.payInCurrency, @@ -287,7 +298,9 @@ class _TradeDetailsViewState extends ConsumerState { } final amount = Amount.fromDecimal( sendAmount, - fractionDigits: coin.fractionDigits, + fractionDigits: isRosen + ? RosenFunding.fractionDigits(trade) + : coin.fractionDigits, ); final address = trade.payInAddress; @@ -315,9 +328,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 +384,9 @@ class _TradeDetailsViewState extends ConsumerState { trade.payInCurrency, )!; final amount = sendAmount.toAmount( - fractionDigits: coin.fractionDigits, + fractionDigits: isRosen + ? RosenFunding.fractionDigits(trade) + : coin.fractionDigits, ); text = ref .watch(pAmountFormatter(coin)) @@ -436,26 +451,26 @@ 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 (!isRosen && !sentFromStack && !hasTx) isDesktop ? const _Divider() : const SizedBox(height: 12), - if (!sentFromStack && !hasTx) + 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 +496,9 @@ class _TradeDetailsViewState extends ConsumerState { STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -500,34 +515,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 +548,15 @@ class _TradeDetailsViewState extends ConsumerState { ), ), ), + if (isRosen && !hasTx) + RoundedWhiteContainer( + child: Text( + trade.payInCurrency.toLowerCase() == "firo" + ? "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.", + style: STextStyles.itemSubtitle(context), + ), + ), if (sentFromStack) isDesktop ? const _Divider() : const SizedBox(height: 12), if (sentFromStack) @@ -555,9 +577,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 +653,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 +694,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 +790,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 +842,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 +918,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 +996,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 +1048,9 @@ class _TradeDetailsViewState extends ConsumerState { ), style: STextStyles.desktopTextExtraExtraSmall(context) .copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), ), ], @@ -1129,9 +1153,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 +1180,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}"; @@ -1220,9 +1247,11 @@ class _TradeDetailsViewState extends ConsumerState { onPressed: () { CryptoCurrency coin; try { - coin = AppConfig.getCryptoCurrencyForTicker( - trade.payInCurrency, - )!; + coin = isRosen + ? RosenFunding.sourceCoin(trade) + : AppConfig.getCryptoCurrencyForTicker( + trade.payInCurrency, + )!; } catch (_) { coin = AppConfig.getCryptoCurrencyByPrettyName( trade.payInCurrency, @@ -1230,7 +1259,9 @@ class _TradeDetailsViewState extends ConsumerState { } final amount = Amount.fromDecimal( sendAmount, - fractionDigits: coin.fractionDigits, + fractionDigits: isRosen + ? RosenFunding.fractionDigits(trade) + : coin.fractionDigits, ); final address = trade.payInAddress; 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 27d0b68a64..a38aa9d166 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 @@ -15,19 +15,24 @@ 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/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'; @@ -64,6 +69,7 @@ class StepScaffold extends ConsumerStatefulWidget { class _StepScaffoldState extends ConsumerState { int currentStep = 1; bool enableNext = false; + bool _creating = false; late final Duration duration; @@ -74,113 +80,143 @@ 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: null, - addressRefund: ref.read(desktopExchangeModelProvider)!.refundAddress!, - 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: null, + addressRefund: ref + .read(desktopExchangeModelProvider)! + .refundAddress!, + 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( @@ -212,12 +248,16 @@ class _StepScaffoldState extends ConsumerState { void sendFromStack() { final trade = ref.read(desktopExchangeModelProvider)!.trade!; final address = trade.payInAddress; - final coin = - AppConfig.getCryptoCurrencyForTicker(trade.payInCurrency) ?? - AppConfig.getCryptoCurrencyByPrettyName(trade.payInCurrency); - final amount = Decimal.parse( - trade.payInAmount, - ).toAmount(fractionDigits: coin.fractionDigits); + final isRosen = trade.exchangeName == RosenExchange.exchangeName; + final coin = isRosen + ? RosenFunding.sourceCoin(trade) + : AppConfig.getCryptoCurrencyForTicker(trade.payInCurrency) ?? + AppConfig.getCryptoCurrencyByPrettyName(trade.payInCurrency); + final amount = Decimal.parse(trade.payInAmount).toAmount( + fractionDigits: isRosen + ? RosenFunding.fractionDigits(trade) + : coin.fractionDigits, + ); showDialog( context: context, @@ -254,16 +294,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( @@ -364,7 +410,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) { @@ -377,54 +423,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!.sendAmount.toStringAsFixed(8)))} ${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!.sendAmount.toStringAsFixed(8)))} ${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 46038a58d6..d5346d1b26 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 @@ -16,11 +16,14 @@ 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/clipboard_interface.dart'; import '../../../../utilities/constants.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'; @@ -57,11 +60,15 @@ class _DesktopStep2State extends ConsumerState { late final FocusNode _toFocusNode; late final FocusNode _refundFocusNode; + bool get _isRosen => + ref.read(efExchangeProvider).name == RosenExchange.exchangeName; + void selectRecipientAddressFromStack() async { try { - final coin = AppConfig.getCryptoCurrencyForTicker( - ref.read(desktopExchangeModelProvider)!.receiveTicker, - )!; + final ticker = ref.read(desktopExchangeModelProvider)!.receiveTicker; + final coin = _isRosen && ticker.toLowerCase() == "rsfiro" + ? Ethereum(CryptoCurrencyNetwork.main) + : AppConfig.getCryptoCurrencyForTicker(ticker)!; final info = await showDialog?>( context: context, @@ -71,12 +78,24 @@ class _DesktopStep2State extends ConsumerState { maxHeight: 670, child: Padding( padding: const EdgeInsets.all(32), - child: DesktopChooseAddressFromStack(coin: coin), + child: DesktopChooseAddressFromStack( + coin: coin, + transparentOnly: _isRosen, + ), ), ), ); if (info is Tuple2) { + if (_isRosen && ticker.toLowerCase() == "rsfiro") { + for (final wallet in ref.read(pWallets).wallets) { + if (wallet.info.coin == coin && + wallet.info.cachedReceivingAddress.toLowerCase() == + info.item2.toLowerCase()) { + await RosenFunding.registerToken(wallet); + } + } + } _toController.text = info.item1; ref.read(desktopExchangeModelProvider)!.recipientAddress = info.item2; } @@ -281,7 +300,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, ), @@ -290,20 +311,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, + 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, @@ -432,9 +461,9 @@ class _DesktopStep2State extends ConsumerState { Text( "Refund Wallet (required)", style: STextStyles.desktopTextExtraExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, ), ), if (AppConfig.isStackCoin( 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 6e18c5086b..612e461a1d 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'; @@ -92,6 +94,61 @@ 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; + if (model.trade?.exchangeName == RosenExchange.exchangeName) { + final canFund = ref + .watch(pWallets) + .wallets + .any((wallet) => RosenFunding.canFund(wallet, model.trade!)); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Send with Rosen Bridge", + style: STextStyles.desktopTextMedium(context), + ), + const SizedBox(height: 8), + Text( + "Send from ${AppConfig.appName} to include the required bridge data. Your pending bridge appears in your swaps.", + style: STextStyles.desktopTextExtraExtraSmall(context), + ), + const SizedBox(height: 20), + 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), + ), + ), + const SizedBox(height: 20), + RoundedWhiteContainer( + child: Column( + children: [ + DesktopStepItem( + label: "Amount", + value: "${model.sendAmount} ${model.sendTicker}", + ), + DesktopStepItem(label: "Trade ID", value: model.trade!.tradeId), + DesktopStepItem(label: "Status", value: statusString), + ], + ), + ), + if (!canFund) + Text( + "Add a ${model.sendTicker.toLowerCase() == "firo" ? "FIRO" : "Ethereum"} wallet to fund this swap.", + style: STextStyles.label(context), + ), + ], + ); + } return Column( children: [ Text( @@ -111,10 +168,9 @@ class _DesktopStep4State extends ConsumerState { text: "You must send at least ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendAmount.toString()))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))}. ", style: STextStyles.label700(context).copyWith( - color: - Theme.of( - context, - ).extension()!.warningForeground, + color: Theme.of(context) + .extension()! + .warningForeground, fontSize: 14, ), children: [ @@ -122,10 +178,9 @@ class _DesktopStep4State extends ConsumerState { text: "If you send less than ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendAmount.toString()))} ${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, + color: Theme.of(context) + .extension()! + .warningForeground, fontSize: 14, ), ), @@ -216,14 +271,13 @@ class _DesktopStep4State extends ConsumerState { style: STextStyles.desktopTextExtraExtraSmall(context), ), Text( - _statusString, - style: STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of(context) - .extension()! - .colorForStatus(_statusString), - ), + statusString, + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .colorForStatus(statusString), + ), ), ], ), 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..f45529de8b 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 @@ -36,9 +36,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 +113,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 +172,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 +196,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 +252,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 +279,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, ), ), ], @@ -315,78 +327,82 @@ 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 + : Tuple2( + "${ref.read(pWalletName(walletId))} (Spark)", + sparkAddress, + ), + ); + } } - } - }, - ), - ], - ), + }, + ), + ], + ), const SizedBox(height: 10), Row( children: [ diff --git a/lib/services/exchange/exchange.dart b/lib/services/exchange/exchange.dart index 7868b9e286..c3ca3e0725 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..abef12c08f --- /dev/null +++ b/lib/services/exchange/rosen/rosen_api.dart @@ -0,0 +1,151 @@ +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 { + RosenApi._(); + + static final instance = RosenApi._(); + + // Rosen mainnet config 7.1.1, verified against app.rosen.tech on 2026-09-17. + // https://github.com/rosen-bridge/ui/blob/dev/apps/rosen/configs/generate.mjs + static const rsFiroContract = '0x2744ea5ac9b11cb5e3cd63d3a88e858336aeddc2'; + static const firoLockAddress = 'aEF6fyd5jjCPcbiEBZJ2g8583caUme8T7Y'; + static const ethereumLockAddress = + '0x451698faa07fc68301af622a3ad42205f13c6e4b'; + static const tokenDecimals = 8; + 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 ? 10 : 50), + 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..412ef72b25 --- /dev/null +++ b/lib/services/exchange/rosen/rosen_exchange.dart @@ -0,0 +1,437 @@ +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 '../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; + + 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: 'rsFIRO', + name: 'Rosen Firo', + network: 'eth', + image: '', + isFiat: false, + rateType: SupportedRateType.estimated, + isStackCoin: false, + tokenContract: RosenApi.rsFiroContract, + 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, + warningMessage: from.toUpperCase() == 'FIRO' + ? 'Uses transparent FIRO only. Bridge fees are included; mining fees are additional.' + : 'Uses rsFIRO on Ethereum. ETH is required for gas; bridge fees are included.', + ), + ]; + }); + + @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 metadata = RosenProtocol.metadata( + fromFiro: fromFiro, + destination: addressTo, + bridgeFee: quote.bridgeFee, + networkFee: quote.networkFee, + ); + final id = const Uuid().v4(); + final now = DateTime.now(); + return 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(), + 'metadata': metadata, + 'tokenContract': RosenApi.rsFiroContract, + }), + ); + }); + + static Trade _latest(Trade trade) => + DB.instance.get(boxName: DB.boxNameTradesV2, key: trade.uuid) ?? + trade; + + 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); + validatedMetadata(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; + return trade.copyWith( + updatedAt: DateTime.now(), + payOutAmount: RosenProtocol.formatAmount(quote.receiveAmount), + other: jsonEncode({ + ...data, + 'bridgeFee': quote.bridgeFee.toString(), + 'networkFee': quote.networkFee.toString(), + 'metadata': RosenProtocol.metadata( + fromFiro: isFiro(trade), + destination: trade.payOutAddress, + bridgeFee: quote.bridgeFee, + networkFee: quote.networkFee, + ), + }), + ); + } + + 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'] != RosenApi.rsFiroContract || + 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.'); + } + final metadata = RosenProtocol.metadata( + fromFiro: isFiro(trade), + destination: trade.payOutAddress, + bridgeFee: bridgeFee, + networkFee: networkFee, + ); + if (metadata != data['metadata']) + throw const FormatException('Invalid Rosen metadata.'); + return metadata; + } + + static Future validateFunding(Trade trade, {int? sourceHeight}) async { + validatedMetadata(trade); + currentUnfunded(trade); + final data = jsonDecode(trade.other!) as Map; + final quote = await RosenApi.instance.quote( + fromFiro: isFiro(trade), + amount: RosenProtocol.parseAmount(trade.payInAmount), + sourceHeight: sourceHeight, + ); + currentUnfunded(trade); + 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, + ); + } + } + + @override + Future> updateTrade(Trade trade) => + _response(() async { + trade = _latest(trade); + if (trade.payInTxid.isEmpty || + {'Finished', 'Failed'}.contains(trade.status)) + return trade; + final event = await RosenApi.instance.getEvent(trade.payInTxid); + trade = _latest(trade); + if ({'Finished', 'Failed'}.contains(trade.status)) return trade; + if (event == null) return trade.copyWith(status: 'Confirming'); + 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' : RosenApi.rsFiroContract) || + 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.'); + } + final payoutTxid = event['paymentTxId'] as String?; + final hasPayout = RosenProtocol.isTransactionId(payoutTxid); + final status = RosenProtocol.swapStatus( + event['status'].toString(), + payoutTxid, + ); + 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..ee909af82e --- /dev/null +++ b/lib/services/exchange/rosen/rosen_fees.dart @@ -0,0 +1,196 @@ +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 rsnRatios = _RegisterReader(registers['R8'], 0x0c).ratios(); + 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'); + } + } + if (rsnRatios.length != heights.length || + rsnRatios.any( + (row) => + row.length != chains.length || + row.any((ratio) => ratio.length != 2), + )) { + throw const FormatException('Invalid Rosen RSN fee ratio 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 = _matrix(bits); + _finish(); + return result; + } + + List>> ratios() { + if (_byte() != 0x1d) { + throw const FormatException('Unexpected Rosen RSN ratio register type'); + } + final count = _length(); + final result = [for (var i = 0; i < count; i++) _matrix(64)]; + _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); + } + 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..51998ae7c2 --- /dev/null +++ b/lib/services/exchange/rosen/rosen_funding.dart @@ -0,0 +1,350 @@ +import 'package:wallet/wallet.dart' as eth; +import 'package:web3dart/web3dart.dart' as web3; + +import '../../../db/hive/db.dart'; +import '../../../models/exchange/response_objects/trade.dart'; +import '../../../models/isar/models/ethereum/eth_contract.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/enums/fee_rate_type_enum.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'; + +/// Bridge transactions retain the normal swap confirmation and wallet signing. +class RosenFunding { + // A null entry is broadcasting; a txid prevents repeat funding after local errors. + static final Map _funding = {}; + + static CryptoCurrency sourceCoin(Trade trade) => RosenExchange.isFiro(trade) + ? Firo(CryptoCurrencyNetwork.main) + : Ethereum(CryptoCurrencyNetwork.main); + + static int fractionDigits(Trade trade) => RosenApi.tokenDecimals; + + static bool canFund(Wallet wallet, Trade trade) => + !wallet.info.isViewOnly && + wallet.cryptoCurrency == sourceCoin(trade) && + (RosenExchange.isFiro(trade) + ? wallet is FiroWallet + : wallet is EthereumWallet); + + static const _abi = '''[ + {"type":"function","name":"balanceOf","stateMutability":"view","inputs":[{"name":"account","type":"address"}],"outputs":[{"name":"","type":"uint256"}]}, + {"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"}]} + ]'''; + + static EthContract _tokenFor(Wallet wallet) => EthContract( + address: wallet.info.tokenContractAddresses.firstWhere( + (address) => address.toLowerCase() == RosenApi.rsFiroContract, + orElse: () => RosenApi.rsFiroContract, + ), + name: 'Rosen Firo', + symbol: 'rsFIRO', + decimals: RosenApi.tokenDecimals, + type: EthContractType.erc20, + abi: _abi, + ); + + 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.getEthContract(token.address); + await wallet.mainDB.putEthContract(token.copyWith(id: stored?.id)); + if (!wallet.info.tokenContractAddresses.any( + (address) => address.toLowerCase() == RosenApi.rsFiroContract, + )) { + await wallet.updateTokenContracts([ + ...wallet.info.tokenContractAddresses, + RosenApi.rsFiroContract, + ]); + } + } + + static Future refreshTrade({ + required Wallet wallet, + required Trade trade, + }) async { + if (_funding.containsKey(trade.uuid)) { + throw StateError('This bridge swap is already being processed.'); + } + _funding[trade.uuid] = null; + try { + // Refresh the persisted request, including 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 await RosenExchange.saveRefreshedTrade(current, quote); + } finally { + _funding.remove(trade.uuid); + } + } + + 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: RosenApi.tokenDecimals, + ); + 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 mining fee: a bridge deposit must be 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 = web3.DeployedContract( + web3.ContractAbi.fromJson(_abi, 'rsFIRO'), + eth.EthereumAddress.fromHex(RosenApi.rsFiroContract), + ); + final decimals = await client.call( + contract: contract, + function: contract.function('decimals'), + params: [], + ); + if (decimals.single != BigInt.from(RosenApi.tokenDecimals)) { + throw StateError('Unexpected rsFIRO token precision.'); + } + final balance = await client.call( + contract: contract, + function: contract.function('balanceOf'), + params: [sender], + ); + if ((balance.single as BigInt) < amount.raw) + throw StateError('Insufficient rsFIRO balance.'); + final calldata = RosenProtocol.bytes( + RosenProtocol.transferData( + lockAddress: RosenApi.ethereumLockAddress, + amount: amount.raw, + metadata: metadata, + ), + ); + final tokenAddress = eth.EthereumAddress.fromHex(RosenApi.rsFiroContract); + 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.maxBaseFee + prep.priorityFee; + 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.priorityFee), + ), + ); + } 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 metadata = RosenExchange.validatedMetadata(trade); + final amount = RosenProtocol.parseAmount(trade.payInAmount); + if (txData.amountWithoutChange?.raw != amount || + txData.recipients?.where((e) => !e.isChange).length != 1 || + txData.recipients!.firstWhere((e) => !e.isChange).address != + trade.payInAddress) { + throw StateError('The transaction does not match this bridge swap.'); + } + if (_funding.containsKey(trade.uuid)) { + final txid = _funding[trade.uuid]; + if (txid != null) return txData.copyWith(txid: txid, txHash: txid); + throw StateError('This bridge swap is already being sent.'); + } + _funding[trade.uuid] = null; + String? broadcastTxid; + void onBroadcast(String txid) { + broadcastTxid = txid; + _funding[trade.uuid] = txid; + } + + try { + if (wallet is FiroWallet) { + if (txData.opReturnData != metadata) { + throw StateError('Missing Rosen OP_RETURN.'); + } + verifyFiroOpReturnTransaction( + raw: txData.raw ?? '', + data: metadata, + paymentScript: RosenProtocol.firoScript(trade.payInAddress), + paymentAmount: amount, + ); + await RosenExchange.validateFunding( + trade, + sourceHeight: await wallet.fetchChainHeight(), + ); + return await wallet.confirmSend( + txData: txData, + onBroadcast: onBroadcast, + ); + } + 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, + ); + if (tx == null || + txData.chainId != BigInt.one || + tx.to?.with0x.toLowerCase() != RosenApi.rsFiroContract || + (tx.value?.getInWei ?? BigInt.zero) != BigInt.zero || + RosenProtocol.hex(tx.data ?? []) != expected) { + throw StateError('Invalid rsFIRO bridge transaction.'); + } + final client = ethereum.getEthClient(); + try { + if (await client.getChainId() != BigInt.one) { + throw StateError('The Ethereum node must use mainnet.'); + } + await RosenExchange.validateFunding( + trade, + sourceHeight: await client.getBlockNumber(), + ); + if (tx.nonce != + await client.getTransactionCount( + await ethereum.getMyWeb3Address(), + atBlock: const web3.BlockNum.pending(), + )) { + throw StateError( + 'The wallet nonce changed. Prepare this swap again.', + ); + } + } finally { + await client.dispose(); + } + // Record token history, including the contract, instead of an ETH payment. + final tokenWallet = Wallet.loadTokenWallet( + ethWallet: ethereum, + contract: _tokenFor(wallet), + ) as EthTokenWallet; + return await tokenWallet.confirmSend( + txData: txData, + onBroadcast: onBroadcast, + ); + } catch (e, s) { + if (broadcastTxid == null) rethrow; + Logging.instance.e( + 'Rosen transaction $broadcastTxid was broadcast; local wallet update failed', + error: e, + stackTrace: s, + ); + return txData.copyWith(txid: broadcastTxid, txHash: broadcastTxid); + } finally { + if (_funding[trade.uuid] == null) _funding.remove(trade.uuid); + } + } +} diff --git a/lib/services/exchange/rosen/rosen_protocol.dart b/lib/services/exchange/rosen/rosen_protocol.dart new file mode 100644 index 0000000000..b437daa470 --- /dev/null +++ b/lib/services/exchange/rosen/rosen_protocol.dart @@ -0,0 +1,118 @@ +import 'dart:typed_data'; + +import 'package:dart_bs58check/dart_bs58check.dart'; +import 'package:wallet/wallet.dart' show EthereumAddress; + +/// 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 const decimals = 8; + static final maxAmount = (BigInt.one << 64) - BigInt.one; + + static bool isTransactionId(String? txid) => + txid != null && RegExp(r'^(0x)?[0-9a-fA-F]{64}$').hasMatch(txid); + + static String swapStatus(String status, String? payoutTxid) => + switch (status.toLowerCase()) { + 'completed' || + 'successful' => isTransactionId(payoutTxid) ? 'Finished' : 'Sending', + 'fraud' => 'Failed', + _ => '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 hex(Iterable bytes) => + bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(); + + static Uint8List bytes(String hex) { + if (hex.length.isOdd || !RegExp(r'^[0-9a-fA-F]*$').hasMatch(hex)) { + throw const FormatException('Invalid hexadecimal data.'); + } + return Uint8List.fromList([ + for (var i = 0; i < hex.length; i += 2) + int.parse(hex.substring(i, i + 2), radix: 16), + ]); + } + + 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) { + final decoded = bs58check.decode(address); + if (decoded.length != 21) { + throw const FormatException('Use a transparent FIRO mainnet address.'); + } + final hash = hex(decoded.sublist(1)); + return switch (decoded.first) { + 0x52 => '76a914${hash}88ac', + 0x07 => 'a914${hash}87', + _ => 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.'); + } + bytes(metadata); + return 'a9059cbb${ethereumAddress(lockAddress).padLeft(64, '0')}' + '${_uint(amount, 32)}$metadata'; + } +} diff --git a/lib/services/trade_service.dart b/lib/services/trade_service.dart index e15216a337..95e886a958 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( @@ -53,6 +57,52 @@ 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 (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 354d7fea55..a180230ed2 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -547,6 +547,7 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { Future confirmSend({ required TxData txData, TxData Function(TxData txData, String myAddress)? prepareTempTx, + void Function(String txid)? onBroadcast, }) async { final client = getEthClient(); if (_credentials == null) { @@ -559,6 +560,7 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { txData.web3dartTransaction!, chainId: txData.chainId!.toInt(), ); + onBroadcast?.call(txid); 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 6aca5a0082..50b2a0cfbf 100644 --- a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart @@ -269,11 +269,15 @@ class EthTokenWallet extends Wallet { } @override - Future confirmSend({required TxData txData}) async { + Future confirmSend({ + required TxData txData, + void Function(String txid)? onBroadcast, + }) async { try { return await ethWallet.confirmSend( txData: txData, prepareTempTx: _prepareTempTx, + onBroadcast: onBroadcast, ); } catch (e) { // rethrow to pass error in alert diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 100aa9f9fe..2edc738161 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -34,6 +34,7 @@ import '../impl/firo_wallet.dart'; import '../impl/peercoin_wallet.dart'; import '../intermediate/bip39_hd_wallet.dart'; import 'cpfp_interface.dart'; +import 'firo_op_return.dart'; import 'mweb_interface.dart'; import 'paynym_interface.dart'; import 'rbf_interface.dart'; @@ -746,7 +747,7 @@ mixin ElectrumXInterface final coinlib.CoinSelection selection = coinlib.CoinSelection.optimal( candidates: candidates, - recipients: [recipientOutput], + recipients: [recipientOutput, ?_opReturnOutput(txData)], changeProgram: changeProgram, feePerKb: feePerKb, minFee: minFee, @@ -898,12 +899,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 = []; @@ -1061,62 +1072,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) { @@ -2046,13 +2011,17 @@ mixin ElectrumXInterface } @override - Future confirmSend({required TxData txData}) async { + Future confirmSend({ + required TxData txData, + void Function(String txid)? onBroadcast, + }) async { try { Logging.instance.d("confirmSend txData: $txData"); final txHash = await electrumXClient.broadcastTransaction( rawTx: txData.raw!, ); + onBroadcast?.call(txHash); Logging.instance.d("Sent txHash: $txHash"); txData = txData.copyWith( @@ -2271,10 +2240,7 @@ mixin ElectrumXInterface } @override - Future signMessage( - final String message, { - required final Address address, - }) async { + Future signMessage(String message, {required Address address}) async { if (isViewOnly) { throw Exception("Cannot sign a message in a view only wallet"); } @@ -2295,9 +2261,9 @@ mixin ElectrumXInterface @override Future verifyMessage( - final String message, { - required final String address, - required final String signature, + String message, { + required String address, + required String signature, }) async { final signed = coinlib.MessageSignature.fromBase64(signature); 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..04250caebe --- /dev/null +++ b/lib/wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart @@ -0,0 +1,79 @@ +import 'dart:typed_data'; + +import 'package:coinlib/coinlib.dart' as coinlib; + +/// 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. +void verifyFiroOpReturnTransaction({ + required String raw, + required String data, + required String paymentScript, + required BigInt paymentAmount, +}) { + final reader = coinlib.BytesReader(coinlib.hexToBytes(raw)); + // FIRO funding uses the legacy layout. Decode its fields directly because the + // pinned coinlib Transaction reader rewinds one byte too far for legacy inputs. + int count(int minimumSize) { + final value = reader.readVarInt(); + if (value <= BigInt.zero || + value > BigInt.from(reader.bytes.lengthInBytes ~/ minimumSize)) { + throw const FormatException('Invalid FIRO transaction item count.'); + } + return value.toInt(); + } + + final tx = coinlib.Transaction( + version: reader.readInt32(), + inputs: List.generate( + count(41), + (_) => coinlib.Input.match(coinlib.RawInput.fromReader(reader)), + ), + outputs: List.generate(count(9), (_) => coinlib.Output.fromReader(reader)), + locktime: reader.readUInt32(), + ); + final expected = coinlib.bytesToHex(firoOpReturnOutput(data).scriptPubKey); + final dataOutputs = tx.outputs.where( + (o) => o.scriptPubKey.isNotEmpty && o.scriptPubKey.first == 0x6a, + ); + final payments = tx.outputs.where( + (o) => coinlib.bytesToHex(o.scriptPubKey) == paymentScript, + ); + if (!reader.atEnd || + tx.toHex() != raw.toLowerCase() || + tx.version != 1 || + !tx.complete || + tx.isWitness || + tx.inputs.any((i) => i is! coinlib.LegacyInput) || + dataOutputs.length != 1 || + dataOutputs.single.value != BigInt.zero || + coinlib.bytesToHex(dataOutputs.single.scriptPubKey) != expected || + payments.length != 1 || + payments.single.value != paymentAmount) { + throw StateError( + 'The signed FIRO transaction does not match this bridge swap.', + ); + } +} diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart index bfeb24e72f..efa74b83ac 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart @@ -516,7 +516,10 @@ mixin MwebInterface } } - Future _confirmSendMweb({required TxData txData}) async { + Future _confirmSendMweb({ + required TxData txData, + void Function(String txid)? onBroadcast, + }) async { if (!info.isMwebEnabled) { throw Exception( "Tried calling _confirmSendMweb with mweb disabled for" @@ -534,6 +537,7 @@ mixin MwebInterface ); final txHash = response.txid; + onBroadcast?.call(txHash); Logging.instance.d("Sent txHash: $txHash"); txData = txData.copyWith( @@ -724,11 +728,14 @@ mixin MwebInterface // =========================================================================== @override - Future confirmSend({required TxData txData}) async { + Future confirmSend({ + required TxData txData, + void Function(String txid)? onBroadcast, + }) async { if (txData.type.isMweb()) { - return await _confirmSendMweb(txData: txData); + return await _confirmSendMweb(txData: txData, onBroadcast: onBroadcast); } else { - return await super.confirmSend(txData: txData); + return await super.confirmSend(txData: txData, onBroadcast: onBroadcast); } } @@ -993,9 +1000,8 @@ mixin MwebInterface (utxo) => processedTx.inputs.any( (input) => input.prevOut.hash.toHex == - Uint8List.fromList( - utxo.id.toUint8ListFromHex.reversed.toList(), - ).toHex, + Uint8List.fromList(utxo.id.toUint8ListFromHex.reversed.toList()) + .toHex, ), ) .toList(); diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index 193bb133f1..e70d27414e 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -226,13 +226,15 @@ mixin SparkInterface ); } - final sparkAddress = - await computeWithLibSparkLogging(_getAddressFromFullViewKey, ( - fullViewKeyHex: _viewKeyHex!, - index: sparkIndex, - diversifier: diversifier, - isTestNet: isTestNet, - )); + final sparkAddress = await computeWithLibSparkLogging( + _getAddressFromFullViewKey, + ( + fullViewKeyHex: _viewKeyHex!, + index: sparkIndex, + diversifier: diversifier, + isTestNet: isTestNet, + ), + ); return Address( walletId: walletId, @@ -622,6 +624,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"); } @@ -1349,9 +1356,8 @@ mixin SparkInterface } for (final transaction in transactions) { if (transaction.usedSparkCoins!.length > 1) { - final transactionVersion = btc.Transaction.fromHex( - transaction.raw!, - ).version; + final transactionVersion = btc.Transaction.fromHex(transaction.raw!) + .version; if (!isChaumV2SparkTransactionVersion(transactionVersion)) { throw Exception( "Refusing to broadcast a multi-input Chaum V1 transaction.", @@ -2711,6 +2717,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/rosen_fees_check.dart b/test/rosen_fees_check.dart new file mode 100644 index 0000000000..98740dc744 --- /dev/null +++ b/test/rosen_fees_check.dart @@ -0,0 +1,117 @@ +// Run: dart --enable-asserts test/rosen_fees_check.dart +import '../lib/services/exchange/rosen/rosen_fees.dart'; + +void main() { + // Actual FIRO fee box 346493beb1be3f439c64f71f98c07f7c6707c1f02e8f9505c2366e1f57c67655. + final registers = { + 'R4': { + 'serializedValue': '1a060762696e616e63650d626974636f696e2d72756e65730763617264616e6f046572676f08657468657265756d046669726f', + }, + 'R5': { + 'serializedValue': '1c02069eee8d70c4d175ce869a0db2d8e201f8abcf1894bfa60106d6f2fd72baf575fc9aa20dd687e401a08ddd18d4cfa701', + }, + 'R6': { + 'serializedValue': '1d0206cedfebcf0acedfebcf0acedfebcf0acedfebcf0acedfebcf0acedfebcf0a06a28398b508a28398b508a28398b508a28398b508a28398b508a28398b508', + }, + 'R7': { + 'serializedValue': '1d0206b4faee01a4f1dc4ae8e2f976dc9db807ac80bb07c88b3d06d2e9cb01dae9e35fe8f6945fe0e1ce05e2a5b101c88b3d', + }, + 'R8': { + 'serializedValue': '0c1d020602fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b9070602a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f', + }, + 'R9': {'serializedValue': '1d020664646464646406646464646464'}, + }; + 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(); + assert(forward.hasFees(forward.bridgeFee, forward.networkFee)); + 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), + ]) { + assert(!forward.hasFees(fees.$1, fees.$2)); + assert( + RosenQuote( + bridgeFee: fees.$1, + networkFee: fees.$2, + minimum: forward.minimum, + receiveAmount: amount - fees.$1 - fees.$2, + ).fingerprint != + forward.fingerprint, + ); + } + assert( + RosenQuote( + bridgeFee: forward.bridgeFee, + networkFee: forward.networkFee, + minimum: forward.minimum, + receiveAmount: forward.receiveAmount + BigInt.one, + ).fingerprint != + forward.fingerprint, + ); + assert(forward.bridgeFee == BigInt.from(1129513169)); + assert(forward.networkFee == BigInt.from(1452401)); + assert(forward.minimum == BigInt.from(1130965571)); + assert(forward.receiveAmount == BigInt.from(8869034430)); + assert( + quote(fromFiro: false, height: 25992101).networkFee == BigInt.from(500452), + ); + assert(quote(height: 1373162).bridgeFee == BigInt.from(1425897447)); + assert(quote(height: 1373163).bridgeFee == BigInt.from(1129513169)); + mustReject(() => quote(height: 1363914)); + final large = RosenQuote.fromRegisters( + registers, + fromFiro: true, + height: 1378335, + amount: BigInt.from(1000000000000), + ); + assert(large.bridgeFee == BigInt.from(5000000000)); + registers['R9'] = {'serializedValue': '1d02066464646464640664646464646400'}; + mustReject(quote); + + final highRatio = { + 'R4': {'serializedValue': '1a02046669726f08657468657265756d'}, + 'R5': {'serializedValue': '1c01020000'}, + 'R6': {'serializedValue': '1d01020000'}, + 'R7': {'serializedValue': '1d0102c801c801'}, + 'R8': {'serializedValue': '0c1d0102020202020202'}, + 'R9': {'serializedValue': '1d01029e9c019e9c01'}, + }; + RosenQuote edge(BigInt value) => RosenQuote.fromRegisters( + highRatio, + fromFiro: true, + height: 1, + amount: value, + ); + final minimum = edge(BigInt.zero).minimum; + assert(minimum == BigInt.from(1000001)); + assert(edge(minimum).receiveAmount == BigInt.one); + assert(edge(minimum - BigInt.one).receiveAmount == BigInt.zero); + highRatio['R6'] = { + 'serializedValue': '1d0102feffffffffffffffff01feffffffffffffffff01', + }; + assert(edge(BigInt.zero).bridgeFee == BigInt.parse('9223372036854775807')); + highRatio['R6'] = { + 'serializedValue': '1d0102ffffffffffffffffff02ffffffffffffffffff02', + }; + mustReject(() => edge(BigInt.zero)); + print('Rosen fee checks passed'); +} + +void mustReject(Object? Function() run) { + try { + run(); + } on FormatException { + return; + } + throw StateError('Invalid fee configuration was accepted'); +} 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..0c5c23d4f6 --- /dev/null +++ b/test/services/exchange/rosen/rosen_protocol_test.dart @@ -0,0 +1,246 @@ +import 'dart:convert'; + +import 'package:dart_bs58check/dart_bs58check.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; +import 'package:wallet/wallet.dart' as eth; +import 'package:web3dart/web3dart.dart' as web3; + +import '../../../../lib/services/exchange/rosen/rosen_protocol.dart'; + +// 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(RosenProtocol.bytes('52${'11' * 20}')); + + 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(RosenProtocol.bytes('07${'22' * 20}')); + expect(RosenProtocol.firoScript(p2sh), 'a914${'22' * 20}87'); + }); + + test('rsFIRO calldata uses ERC20 transfer followed by metadata', () { + final metadata = RosenProtocol.metadata( + fromFiro: false, + destination: firo, + bridgeFee: BigInt.from(123), + networkFee: BigInt.from(456), + ); + final contract = web3.DeployedContract( + web3.ContractAbi.fromJson('''[ + {"type":"function","name":"transfer","stateMutability":"nonpayable","inputs":[{"name":"to","type":"address"},{"name":"amount","type":"uint256"}],"outputs":[{"name":"","type":"bool"}]} + ]''', 'rsFIRO'), + eth.EthereumAddress.fromHex(ethereum), + ); + final amount = BigInt.parse('9007199254740993'); + final encoded = contract.function('transfer').encodeCall([ + eth.EthereumAddress.fromHex(ethereum), + amount, + ]); + expect( + RosenProtocol.transferData( + lockAddress: ethereum, + amount: amount, + metadata: metadata, + ), + '${RosenProtocol.hex(encoded)}$metadata', + ); + }); + + 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( + RosenProtocol.bytes('$version${'11' * 20}'), + ); + 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 = RosenProtocol.bytes( + (rpc['params'] as List).single.substring(2) as String, + ); + 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], RosenProtocol.bytes(token.substring(2))); + expect(fields[6], isEmpty); // No native ETH is sent. + expect(fields[7], RosenProtocol.bytes(expectedCalldata)); + 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: RosenProtocol.bytes(calldata), + 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), 'Failed'); + expect(RosenProtocol.swapStatus('unknown', null), '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(RosenProtocol.bytes('41${'11' * 20}')); + expect(() => RosenProtocol.firoScript(testnet), throwsFormatException); + expect( + () => RosenProtocol.firoScript('spark1notatransparentaddress'), + throwsA(anything), + ); + expect( + () => RosenProtocol.ethereumAddress('0x${'00' * 20}'), + throwsFormatException, + ); + expect(() => RosenProtocol.bytes('0xz1'), throwsFormatException); + 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_registration_test.dart b/test/services/exchange/rosen_registration_test.dart new file mode 100644 index 0000000000..8d0032532b --- /dev/null +++ b/test/services/exchange/rosen_registration_test.dart @@ -0,0 +1,285 @@ +import 'dart:convert'; +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/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/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/exceptions/exchange/exchange_exception.dart'; + +void main() { + 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, RosenApi.rsFiroContract); + expect(token.supportsEstimatedRate, isTrue); + expect(token.supportsFixedRate, isFalse); + expect((await exchange.getAllCurrencies(true)).value, isEmpty); + + // These statuses drive existing swap 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 = await Directory.systemTemp.createTemp('rosen-trades-'); + 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 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'); + 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 refresh retains the request and rejects funded or stale saves', () async { + final directory = await Directory.systemTemp.createTemp('rosen-refresh-'); + 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 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( + 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 status poll captured before refresh must not restore old metadata. + 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); + } + }); +} + +Trade _rosenRequest(bool fromFiro) { + final destination = fromFiro + ? '0x00112233445566778899aabbccddeeff00112233' + : RosenApi.firoLockAddress; + final now = DateTime.utc(2020); + return Trade( + uuid: 'rosen-refresh-$fromFiro', + tradeId: 'rosen-refresh-$fromFiro', + 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': RosenApi.rsFiroContract, + 'metadata': RosenProtocol.metadata( + fromFiro: fromFiro, + destination: destination, + bridgeFee: BigInt.from(123), + networkFee: BigInt.from(456), + ), + }), + ); +} diff --git a/test/wallets/firo_op_return_test.dart b/test/wallets/firo_op_return_test.dart new file mode 100644 index 0000000000..abd6d3abe0 --- /dev/null +++ b/test/wallets/firo_op_return_test.dart @@ -0,0 +1,169 @@ +import 'dart:typed_data'; + +import 'package:coinlib/coinlib.dart' as coinlib; +import 'package:test/test.dart'; + +import '../../lib/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', 'aa' * 81]) { + 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('aa' * 80); + + 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('signed transparent FIRO bytes retain and enforce bridge outputs', () async { + await coinlib.loadCoinlib(); + // Public test key and synthetic outpoint: no network or wallet funds are used. + final key = coinlib.ECPrivateKey.fromHex('01'.padLeft(64, '0')); + const metadata = + '03000000000000007b00000000000001c81400112233445566778899aabbccddeeff00112233'; + final lock = coinlib.Address.fromString( + 'aEF6fyd5jjCPcbiEBZJ2g8583caUme8T7Y', + coinlib.Network.mainnet.copyWith(p2pkhPrefix: 0x52, p2shPrefix: 0x07), + ).program; + final amount = BigInt.from(100000000); + final payment = coinlib.Output.fromProgram(amount, lock); + final change = coinlib.Output.fromProgram( + BigInt.from(50000000), + coinlib.P2PKH.fromHash(coinlib.hash160(key.pubkey.data)), + ); + final data = firoOpReturnOutput(metadata); + coinlib.Transaction sign(List outputs) => + coinlib.Transaction( + version: 1, + inputs: [ + coinlib.P2PKHInput( + prevOut: coinlib.OutPoint( + Uint8List.fromList(List.filled(32, 1)), + 0, + ), + publicKey: key.pubkey, + ), + ], + outputs: outputs, + ).signLegacy(inputN: 0, key: key); + void verify(String raw) => verifyFiroOpReturnTransaction( + raw: raw, + data: metadata, + paymentScript: coinlib.bytesToHex(payment.scriptPubKey), + paymentAmount: amount, + ); + + final signed = sign([payment, change, data]); + expect(signed.complete, isTrue); + expect(signed.inputs.single, isA()); + expect( + signed.toHex(), + endsWith('0000000000000000286a26${metadata}00000000'), + ); + verify(signed.toHex()); + // Output order is not part of Rosen's protocol. + verify(sign([data, payment, change]).toHex()); + + for (final outputs in [ + [payment, change], // Sidecar metadata cannot replace an on-chain output. + [payment, change, firoOpReturnOutput('${metadata.substring(0, 74)}ff')], + [payment, change, data, data], + [ + payment, + change, + coinlib.Output.fromScriptBytes(BigInt.one, data.scriptPubKey), + ], + [coinlib.Output.fromProgram(amount - BigInt.one, lock), change, data], + [change, data], + ]) { + expect(() => verify(sign(outputs).toHex()), throwsStateError); + } + expect(() => verify('${signed.toHex()}00'), throwsStateError); + final unsigned = coinlib.Transaction( + version: 1, + inputs: [ + coinlib.P2PKHInput( + prevOut: signed.inputs.single.prevOut, + publicKey: key.pubkey, + ), + ], + outputs: signed.outputs, + ); + expect(() => verify(unsigned.toHex()), throwsStateError); + final nonTransparent = coinlib.Transaction( + version: 1, + inputs: [ + coinlib.RawInput( + prevOut: signed.inputs.single.prevOut, + scriptSig: Uint8List.fromList([0xd3]), + ), + ], + outputs: signed.outputs, + ); + expect(() => verify(nonTransparent.toHex()), throwsStateError); + }); +} From 45abd001c9d702f38bd556b5a8fd54b47ec7b22d Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 17 Sep 2026 12:57:27 +0800 Subject: [PATCH 04/25] fix: repair CI test compilation --- .github/workflows/test.yaml | 4 ++++ lib/wallets/wallet/impl/bitcoin_frost_wallet.dart | 6 +++--- .../wallet_mixin_interfaces/electrumx_interface.dart | 8 ++++---- test/wallets/firo_transaction_type_test.dart | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index eca0145f8b..2389ceb6af 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -56,6 +56,10 @@ jobs: const kShopInBitPartnerSecret = ""; const kCakePayApiToken = ""; const kExolixApiKey = ""; + const kLetsExchangeId = ""; + const kLetsExchangeToken = ""; + const kCypherGoatApiKey = ""; + const kCypherGoatAffiliate = ""; EOF fi diff --git a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart index 00537b12b8..b5fc45bcf5 100644 --- a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -837,11 +837,11 @@ class BitcoinFrostWallet extends Wallet const changeChain = 1; final List addresses})>> receiveFutures = [ - _checkGapsLinearly(serializedKeys, receiveChain, secure: true), + _checkGapsLinearly(serializedKeys!, receiveChain, secure: true), ]; final List addresses})>> changeFutures = [ - _checkGapsLinearly(serializedKeys, changeChain, secure: true), + _checkGapsLinearly(serializedKeys!, changeChain, secure: true), ]; // io limitations may require running these linearly instead @@ -898,7 +898,7 @@ class BitcoinFrostWallet extends Wallet await mainDB.updateOrPutAddresses(addressesToStore); - await _legacyInsecureScan(serializedKeys); + await _legacyInsecureScan(serializedKeys!); }); GlobalEventBus.instance.fire( diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 100aa9f9fe..bbab009800 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -2491,11 +2491,11 @@ mixin ElectrumXInterface canBatch ? checkGapsBatched( txCountBatchSize, - root, + root!, type, receiveChain, ) - : checkGapsLinearly(root, type, receiveChain), + : checkGapsLinearly(root!, type, receiveChain), ); } } @@ -2515,11 +2515,11 @@ mixin ElectrumXInterface canBatch ? checkGapsBatched( txCountBatchSize, - root, + root!, type, changeChain, ) - : checkGapsLinearly(root, type, changeChain), + : checkGapsLinearly(root!, type, changeChain), ); } } diff --git a/test/wallets/firo_transaction_type_test.dart b/test/wallets/firo_transaction_type_test.dart index 89abfa3f03..52304949f7 100644 --- a/test/wallets/firo_transaction_type_test.dart +++ b/test/wallets/firo_transaction_type_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:paymint/wallets/wallet/impl/firo_transaction_type.dart'; +import 'package:stackwallet/wallets/wallet/impl/firo_transaction_type.dart'; void main() { test('recognizes Spark spend transaction types', () { From ceceae38f48888bd54e3846feb9a1769350e0778 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 17 Sep 2026 13:56:02 +0800 Subject: [PATCH 05/25] feat: add Ethereum assets to Campfire --- .../edit_wallet_tokens_view.dart | 99 +++++++++++-------- .../add_wallet_view/add_wallet_view.dart | 28 ++++-- lib/utilities/default_eth_tokens.dart | 13 +++ scripts/app_config/configure_campfire.sh | 3 +- test/utilities/default_eth_tokens_test.dart | 15 +++ 5 files changed, 112 insertions(+), 46 deletions(-) create mode 100644 test/utilities/default_eth_tokens_test.dart diff --git a/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart b/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart index d81afe51e5..0d0d2a89bf 100644 --- a/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart +++ b/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart @@ -15,6 +15,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:isar_community/isar.dart'; +import '../../../app_config.dart'; import '../../../db/isar/main_db.dart'; import '../../../models/isar/models/ethereum/eth_contract.dart'; import '../../../models/isar/models/solana/sol_contract.dart'; @@ -80,6 +81,7 @@ class _EditWalletTokensViewState extends ConsumerState { final List tokenEntities = []; final bool isDesktop = Util.isDesktop; + final bool isCampfire = AppConfig.appName == "Campfire"; List filter( String text, @@ -244,6 +246,13 @@ class _EditWalletTokensViewState extends ConsumerState { _searchFocusNode = FocusNode(); final wallet = ref.read(pWallets).getWallet(widget.walletId); + final walletContracts = ref.read(pWalletTokenAddresses(widget.walletId)); + final shouldMarkAsSelectedContracts = [ + ...walletContracts, + ...(widget.contractsToMarkSelected ?? []), + ]; + final selectedContractAddresses = + shouldMarkAsSelectedContracts.map((e) => e.toLowerCase()).toSet(); if (wallet is SolanaWallet) { final contracts = MainDB.instance @@ -266,29 +275,39 @@ class _EditWalletTokensViewState extends ConsumerState { .getEthContracts() .sortByName() .findAllSync(); - - if (contracts.isEmpty) { - contracts.addAll(DefaultTokens.list); + final defaults = DefaultTokens.forApp(AppConfig.appName); + final existingAddresses = + contracts.map((e) => e.address.toLowerCase()).toSet(); + final missingDefaults = + defaults + .where( + (token) => existingAddresses.add(token.address.toLowerCase()), + ) + .toList(); + + if (missingDefaults.isNotEmpty) { + contracts.addAll(missingDefaults); MainDB.instance - .putEthContracts(contracts) + .putEthContracts(missingDefaults) .then( (_) => ref.read(priceAnd24hChangeNotifierProvider).updatePrice(), ); } + contracts.retainWhere( + (token) => + DefaultTokens.isAllowedForApp(AppConfig.appName, token) || + selectedContractAddresses.contains(token.address.toLowerCase()), + ); + tokenEntities.addAll(contracts.map((e) => AddTokenListElementData(e))); } - // Get token addresses. - final walletContracts = ref.read(pWalletTokenAddresses(widget.walletId)); - - final shouldMarkAsSelectedContracts = [ - ...walletContracts, - ...(widget.contractsToMarkSelected ?? []), - ]; - for (final e in tokenEntities) { - e.selected = shouldMarkAsSelectedContracts.contains(e.token.address); + e.selected = + wallet is EthereumWallet + ? selectedContractAddresses.contains(e.token.address.toLowerCase()) + : shouldMarkAsSelectedContracts.contains(e.token.address); } super.initState(); @@ -318,7 +337,7 @@ class _EditWalletTokensViewState extends ConsumerState { walletName, style: STextStyles.desktopSubtitleH2(context), ), - trailing: widget.contractsToMarkSelected == null + trailing: widget.contractsToMarkSelected == null && !isCampfire ? Padding( padding: const EdgeInsets.only(right: 24), child: SizedBox( @@ -410,14 +429,15 @@ class _EditWalletTokensViewState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 32), child: Row( children: [ - Expanded( - child: SecondaryButton( - label: "Add custom token", - buttonHeight: ButtonHeight.l, - onPressed: _addToken, + if (!isCampfire) + Expanded( + child: SecondaryButton( + label: "Add custom token", + buttonHeight: ButtonHeight.l, + onPressed: _addToken, + ), ), - ), - const SizedBox(width: 16), + if (!isCampfire) const SizedBox(width: 16), Expanded( child: PrimaryButton( label: "Done", @@ -526,28 +546,29 @@ class _EditWalletTokensViewState extends ConsumerState { }, ), actions: [ - Padding( - padding: const EdgeInsets.only(top: 10, bottom: 10, right: 20), - child: AspectRatio( - aspectRatio: 1, - child: AppBarIconButton( - size: 36, - shadows: const [], - color: Theme.of( - context, - ).extension()!.background, - icon: SvgPicture.asset( - Assets.svg.circlePlusFilled, + if (!isCampfire) + Padding( + padding: const EdgeInsets.only(top: 10, bottom: 10, right: 20), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + size: 36, + shadows: const [], color: Theme.of( context, - ).extension()!.topNavIconPrimary, - width: 20, - height: 20, + ).extension()!.background, + icon: SvgPicture.asset( + Assets.svg.circlePlusFilled, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + width: 20, + height: 20, + ), + onPressed: _addToken, ), - onPressed: _addToken, ), ), - ), ], ), body: SafeArea( @@ -620,7 +641,7 @@ class _EditWalletTokensViewState extends ConsumerState { child: AddTokenList( walletId: widget.walletId, items: filter(_searchTerm, tokenEntities), - addFunction: _addToken, + addFunction: isCampfire ? null : _addToken, ), ), const SizedBox(height: 16), diff --git a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart index 13cb7a022e..70acd0472d 100644 --- a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart +++ b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart @@ -178,17 +178,30 @@ class _AddWalletViewState extends ConsumerState { if (AppConfig.coins.whereType().isNotEmpty) { final contracts = MainDB.instance.getEthContracts().sortByName().findAllSync(); + final defaults = DefaultTokens.forApp(AppConfig.appName); + final existingAddresses = + contracts.map((e) => e.address.toLowerCase()).toSet(); + final missingDefaults = + defaults + .where( + (token) => existingAddresses.add(token.address.toLowerCase()), + ) + .toList(); - if (contracts.isEmpty) { - contracts.addAll(DefaultTokens.list); + if (missingDefaults.isNotEmpty) { + contracts.addAll(missingDefaults); MainDB.instance - .putEthContracts(contracts) + .putEthContracts(missingDefaults) .then( (value) => ref.read(priceAnd24hChangeNotifierProvider).updatePrice(), ); } + contracts.retainWhere( + (token) => DefaultTokens.isAllowedForApp(AppConfig.appName, token), + ); + tokenEntities.addAll(contracts.map((e) => EthTokenEntity(e))); } @@ -358,9 +371,12 @@ class _AddWalletViewState extends ConsumerState { entities: filter(_searchTerm, tokenEntities), initialState: ExpandableState.expanded, animationDurationMultiplier: 0.5, - trailing: AddCustomTokenSelector( - addFunction: _addToken, - ), + trailing: + AppConfig.appName == "Campfire" + ? null + : AddCustomTokenSelector( + addFunction: _addToken, + ), ), if (solTokenEntities.isNotEmpty) ExpandingSubListItem( diff --git a/lib/utilities/default_eth_tokens.dart b/lib/utilities/default_eth_tokens.dart index ca6591ed8f..7b85c60fc6 100644 --- a/lib/utilities/default_eth_tokens.dart +++ b/lib/utilities/default_eth_tokens.dart @@ -11,6 +11,12 @@ import '../models/isar/models/ethereum/eth_contract.dart'; abstract class DefaultTokens { + static const _campfireTokenAddresses = { + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "0xdac17f958d2ee523a2206206994597c13d831ec7", + "0x2744ea5ac9b11cb5e3cd63d3a88e858336aeddc2", + }; + static List list = [ EthContract( address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", @@ -55,4 +61,11 @@ abstract class DefaultTokens { type: EthContractType.erc20, ), ]; + + static bool isAllowedForApp(String appName, EthContract token) => + appName != "Campfire" || + _campfireTokenAddresses.contains(token.address.toLowerCase()); + + static List forApp(String appName) => + list.where((token) => isAllowedForApp(appName, token)).toList(); } diff --git a/scripts/app_config/configure_campfire.sh b/scripts/app_config/configure_campfire.sh index e12697b35e..e4cf54312e 100755 --- a/scripts/app_config/configure_campfire.sh +++ b/scripts/app_config/configure_campfire.sh @@ -77,6 +77,7 @@ const ({String light, String dark})? _appIconAsset = ( final List _supportedCoins = List.unmodifiable([ Firo(CryptoCurrencyNetwork.main), + Ethereum(CryptoCurrencyNetwork.main), ]); final ({String from, String fromFuzzyNet, String to, String toFuzzyNet}) @@ -87,4 +88,4 @@ _swapDefaults = ( toFuzzyNet: "firo", ); -EOF \ No newline at end of file +EOF diff --git a/test/utilities/default_eth_tokens_test.dart b/test/utilities/default_eth_tokens_test.dart new file mode 100644 index 0000000000..012e2674f6 --- /dev/null +++ b/test/utilities/default_eth_tokens_test.dart @@ -0,0 +1,15 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/default_eth_tokens.dart'; + +void main() { + test('Campfire Ethereum defaults are allowlisted', () { + expect( + DefaultTokens.forApp('Campfire').map((e) => (e.symbol, e.address)), + unorderedEquals([ + ('USDC', '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'), + ('USDT', '0xdac17f958d2ee523a2206206994597c13d831ec7'), + ('rsFIRO', '0x2744ea5ac9b11cb5e3cd63d3a88e858336aeddc2'), + ]), + ); + }); +} From aa1ac2bba7da49d9765e599b16287f6609832822 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 17 Sep 2026 13:58:51 +0800 Subject: [PATCH 06/25] chore: retry CI From 255687006bcf3e34e237cb850f8c21b5d09b71c2 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 17 Sep 2026 14:06:53 +0800 Subject: [PATCH 07/25] style: format Campfire token views --- .../edit_wallet_tokens_view.dart | 91 +++---- .../add_wallet_view/add_wallet_view.dart | 254 +++++++++--------- 2 files changed, 171 insertions(+), 174 deletions(-) diff --git a/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart b/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart index 0d0d2a89bf..da02153572 100644 --- a/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart +++ b/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart @@ -102,11 +102,10 @@ class _EditWalletTokensViewState extends ConsumerState { } Future onNextPressed() async { - final selectedTokens = - tokenEntities - .where((e) => e.selected) - .map((e) => e.token.address) - .toList(); + final selectedTokens = tokenEntities + .where((e) => e.selected) + .map((e) => e.token.address) + .toList(); final wallet = ref.read(pWallets).getWallet(widget.walletId); @@ -121,13 +120,11 @@ class _EditWalletTokensViewState extends ConsumerState { Navigator.of(context).pop(42); } else { if (isDesktop) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(DesktopHomeView.routeName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(DesktopHomeView.routeName)); } else { - await Navigator.of( - context, - ).pushNamedAndRemoveUntil(HomeView.routeName, (route) => false); + await Navigator.of(context) + .pushNamedAndRemoveUntil(HomeView.routeName, (route) => false); } if (mounted) { unawaited( @@ -162,9 +159,8 @@ class _EditWalletTokensViewState extends ConsumerState { ), ); } else { - final result = await Navigator.of( - context, - ).pushNamed(AddCustomTokenView.routeName); + final result = await Navigator.of(context) + .pushNamed(AddCustomTokenView.routeName); contract = result as EthContract?; } @@ -179,7 +175,9 @@ class _EditWalletTokensViewState extends ConsumerState { tokenEntities.add( AddTokenListElementData(contract!)..selected = true, ); - tokenEntities.sort((a, b) => a.token.name.compareTo(b.token.name)); + tokenEntities.sort( + (a, b) => a.token.name.compareTo(b.token.name), + ); } }); } @@ -201,9 +199,7 @@ class _EditWalletTokensViewState extends ConsumerState { ), ); } else { - final result = await Navigator.of( - context, - ).pushNamed( + final result = await Navigator.of(context).pushNamed( AddCustomSolanaTokenView.routeName, arguments: widget.walletId, ); @@ -230,9 +226,7 @@ class _EditWalletTokensViewState extends ConsumerState { if (tokenEntities .where((e) => e.token.address == token!.address) .isEmpty) { - tokenEntities.add( - AddTokenListElementData(token!)..selected = true, - ); + tokenEntities.add(AddTokenListElementData(token!)..selected = true); tokenEntities.sort((a, b) => a.token.name.compareTo(b.token.name)); } }); @@ -251,8 +245,9 @@ class _EditWalletTokensViewState extends ConsumerState { ...walletContracts, ...(widget.contractsToMarkSelected ?? []), ]; - final selectedContractAddresses = - shouldMarkAsSelectedContracts.map((e) => e.toLowerCase()).toSet(); + final selectedContractAddresses = shouldMarkAsSelectedContracts + .map((e) => e.toLowerCase()) + .toSet(); if (wallet is SolanaWallet) { final contracts = MainDB.instance @@ -276,14 +271,12 @@ class _EditWalletTokensViewState extends ConsumerState { .sortByName() .findAllSync(); final defaults = DefaultTokens.forApp(AppConfig.appName); - final existingAddresses = - contracts.map((e) => e.address.toLowerCase()).toSet(); - final missingDefaults = - defaults - .where( - (token) => existingAddresses.add(token.address.toLowerCase()), - ) - .toList(); + final existingAddresses = contracts + .map((e) => e.address.toLowerCase()) + .toSet(); + final missingDefaults = defaults + .where((token) => existingAddresses.add(token.address.toLowerCase())) + .toList(); if (missingDefaults.isNotEmpty) { contracts.addAll(missingDefaults); @@ -304,10 +297,9 @@ class _EditWalletTokensViewState extends ConsumerState { } for (final e in tokenEntities) { - e.selected = - wallet is EthereumWallet - ? selectedContractAddresses.contains(e.token.address.toLowerCase()) - : shouldMarkAsSelectedContracts.contains(e.token.address); + e.selected = wallet is EthereumWallet + ? selectedContractAddresses.contains(e.token.address.toLowerCase()) + : shouldMarkAsSelectedContracts.contains(e.token.address); } super.initState(); @@ -468,9 +460,8 @@ class _EditWalletTokensViewState extends ConsumerState { _searchTerm = value; }); }, - style: STextStyles.desktopTextMedium( - context, - ).copyWith(height: 2), + style: STextStyles.desktopTextMedium(context) + .copyWith(height: 2), decoration: standardInputDecoration( "Search", @@ -536,9 +527,9 @@ class _EditWalletTokensViewState extends ConsumerState { } else { return Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -548,20 +539,24 @@ class _EditWalletTokensViewState extends ConsumerState { actions: [ if (!isCampfire) Padding( - padding: const EdgeInsets.only(top: 10, bottom: 10, right: 20), + padding: const EdgeInsets.only( + top: 10, + bottom: 10, + right: 20, + ), child: AspectRatio( aspectRatio: 1, child: AppBarIconButton( size: 36, shadows: const [], - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, icon: SvgPicture.asset( Assets.svg.circlePlusFilled, - color: Theme.of( - context, - ).extension()!.topNavIconPrimary, + color: Theme.of(context) + .extension()! + .topNavIconPrimary, width: 20, height: 20, ), diff --git a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart index 70acd0472d..cba861bc2a 100644 --- a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart +++ b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart @@ -103,17 +103,15 @@ class _AddWalletViewState extends ConsumerState { if (isDesktop) { contract = await showDialog( context: context, - builder: - (context) => const DesktopDialog( - maxWidth: 580, - maxHeight: 500, - child: AddCustomTokenView(), - ), + builder: (context) => const DesktopDialog( + maxWidth: 580, + maxHeight: 500, + child: AddCustomTokenView(), + ), ); } else { - contract = await Navigator.of( - context, - ).pushNamed(AddCustomTokenView.routeName); + contract = await Navigator.of(context) + .pushNamed(AddCustomTokenView.routeName); } if (contract != null) { @@ -137,17 +135,15 @@ class _AddWalletViewState extends ConsumerState { if (isDesktop) { token = await showDialog( context: context, - builder: - (context) => const DesktopDialog( - maxWidth: 580, - maxHeight: 500, - child: AddCustomSolanaTokenView(), - ), + builder: (context) => const DesktopDialog( + maxWidth: 580, + maxHeight: 500, + child: AddCustomSolanaTokenView(), + ), ); } else { - token = await Navigator.of( - context, - ).pushNamed(AddCustomSolanaTokenView.routeName); + token = await Navigator.of(context) + .pushNamed(AddCustomSolanaTokenView.routeName); } if (token != null) { @@ -176,17 +172,17 @@ class _AddWalletViewState extends ConsumerState { } if (AppConfig.coins.whereType().isNotEmpty) { - final contracts = - MainDB.instance.getEthContracts().sortByName().findAllSync(); + final contracts = MainDB.instance + .getEthContracts() + .sortByName() + .findAllSync(); final defaults = DefaultTokens.forApp(AppConfig.appName); - final existingAddresses = - contracts.map((e) => e.address.toLowerCase()).toSet(); - final missingDefaults = - defaults - .where( - (token) => existingAddresses.add(token.address.toLowerCase()), - ) - .toList(); + final existingAddresses = contracts + .map((e) => e.address.toLowerCase()) + .toSet(); + final missingDefaults = defaults + .where((token) => existingAddresses.add(token.address.toLowerCase())) + .toList(); if (missingDefaults.isNotEmpty) { contracts.addAll(missingDefaults); @@ -287,60 +283,60 @@ class _AddWalletViewState extends ConsumerState { _searchTerm = value; }); }, - style: STextStyles.desktopTextMedium( - context, - ).copyWith(height: 2), - decoration: standardInputDecoration( - "Search", - _searchFocusNode, - context, - ).copyWith( - contentPadding: const EdgeInsets.symmetric( - vertical: 10, - ), - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - // vertical: 20, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 24, - height: 24, - color: - Theme.of(context) + style: STextStyles.desktopTextMedium(context) + .copyWith(height: 2), + decoration: + standardInputDecoration( + "Search", + _searchFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.symmetric( + vertical: 10, + ), + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + // vertical: 20, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 24, + height: 24, + color: Theme.of(context) .extension()! .textFieldDefaultSearchIconLeft, - ), - ), - suffixIcon: - _searchFieldController.text.isNotEmpty + ), + ), + suffixIcon: + _searchFieldController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only( - right: 10, - ), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon( - width: 24, - height: 24, + padding: const EdgeInsets.only( + right: 10, + ), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon( + width: 24, + height: 24, + ), + onTap: () async { + setState(() { + _searchFieldController + .text = + ""; + _searchTerm = ""; + }); + }, ), - onTap: () async { - setState(() { - _searchFieldController - .text = ""; - _searchTerm = ""; - }); - }, - ), - ], + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), @@ -371,17 +367,19 @@ class _AddWalletViewState extends ConsumerState { entities: filter(_searchTerm, tokenEntities), initialState: ExpandableState.expanded, animationDurationMultiplier: 0.5, - trailing: - AppConfig.appName == "Campfire" - ? null - : AddCustomTokenSelector( - addFunction: _addToken, - ), + trailing: AppConfig.appName == "Campfire" + ? null + : AddCustomTokenSelector( + addFunction: _addToken, + ), ), if (solTokenEntities.isNotEmpty) ExpandingSubListItem( title: "Solana tokens", - entities: filter(_searchTerm, solTokenEntities), + entities: filter( + _searchTerm, + solTokenEntities, + ), initialState: ExpandableState.expanded, animationDurationMultiplier: 0.5, trailing: AddCustomTokenSelector( @@ -411,8 +409,9 @@ class _AddWalletViewState extends ConsumerState { } else { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -435,8 +434,7 @@ class _AddWalletViewState extends ConsumerState { Constants.size.circularBorderRadius, ), child: Semantics( - label: - "Search Text Field. Inputs Text To Search In Wallets.", + label: "Search Text Field. Inputs Text To Search In Wallets.", excludeSemantics: true, child: TextField( autofocus: isDesktop, @@ -444,49 +442,53 @@ class _AddWalletViewState extends ConsumerState { enableSuggestions: !isDesktop, controller: _searchFieldController, focusNode: _searchFocusNode, - onChanged: - (value) => setState(() => _searchTerm = value), + onChanged: (value) => + setState(() => _searchTerm = value), style: STextStyles.field(context), - decoration: standardInputDecoration( - "Search", - _searchFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 16, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 16, - height: 16, - ), - ), - suffixIcon: - _searchFieldController.text.isNotEmpty + decoration: + standardInputDecoration( + "Search", + _searchFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 16, + height: 16, + ), + ), + suffixIcon: + _searchFieldController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _searchFieldController.text = - ""; - _searchTerm = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only( + right: 0, ), - ), - ) + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _searchFieldController + .text = + ""; + _searchTerm = ""; + }); + }, + ), + ], + ), + ), + ) : null, - ), + ), ), ), ), From 62f32da5362dc199638a6b1d1321704d43be0969 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 17 Sep 2026 14:14:37 +0800 Subject: [PATCH 08/25] style: match CI Dart formatter --- .../edit_wallet_tokens_view.dart | 38 ++++++++++--------- .../add_wallet_view/add_wallet_view.dart | 24 +++++++----- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart b/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart index da02153572..d75a3d827f 100644 --- a/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart +++ b/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart @@ -120,11 +120,13 @@ class _EditWalletTokensViewState extends ConsumerState { Navigator.of(context).pop(42); } else { if (isDesktop) { - Navigator.of(context) - .popUntil(ModalRoute.withName(DesktopHomeView.routeName)); + Navigator.of( + context, + ).popUntil(ModalRoute.withName(DesktopHomeView.routeName)); } else { - await Navigator.of(context) - .pushNamedAndRemoveUntil(HomeView.routeName, (route) => false); + await Navigator.of( + context, + ).pushNamedAndRemoveUntil(HomeView.routeName, (route) => false); } if (mounted) { unawaited( @@ -159,8 +161,9 @@ class _EditWalletTokensViewState extends ConsumerState { ), ); } else { - final result = await Navigator.of(context) - .pushNamed(AddCustomTokenView.routeName); + final result = await Navigator.of( + context, + ).pushNamed(AddCustomTokenView.routeName); contract = result as EthContract?; } @@ -460,8 +463,9 @@ class _EditWalletTokensViewState extends ConsumerState { _searchTerm = value; }); }, - style: STextStyles.desktopTextMedium(context) - .copyWith(height: 2), + style: STextStyles.desktopTextMedium( + context, + ).copyWith(height: 2), decoration: standardInputDecoration( "Search", @@ -527,9 +531,9 @@ class _EditWalletTokensViewState extends ConsumerState { } else { return Background( child: Scaffold( - backgroundColor: Theme.of(context) - .extension()! - .background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -549,14 +553,14 @@ class _EditWalletTokensViewState extends ConsumerState { child: AppBarIconButton( size: 36, shadows: const [], - color: Theme.of(context) - .extension()! - .background, + color: Theme.of( + context, + ).extension()!.background, icon: SvgPicture.asset( Assets.svg.circlePlusFilled, - color: Theme.of(context) - .extension()! - .topNavIconPrimary, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, width: 20, height: 20, ), diff --git a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart index cba861bc2a..50df83b65a 100644 --- a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart +++ b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart @@ -110,8 +110,9 @@ class _AddWalletViewState extends ConsumerState { ), ); } else { - contract = await Navigator.of(context) - .pushNamed(AddCustomTokenView.routeName); + contract = await Navigator.of( + context, + ).pushNamed(AddCustomTokenView.routeName); } if (contract != null) { @@ -142,8 +143,9 @@ class _AddWalletViewState extends ConsumerState { ), ); } else { - token = await Navigator.of(context) - .pushNamed(AddCustomSolanaTokenView.routeName); + token = await Navigator.of( + context, + ).pushNamed(AddCustomSolanaTokenView.routeName); } if (token != null) { @@ -283,8 +285,9 @@ class _AddWalletViewState extends ConsumerState { _searchTerm = value; }); }, - style: STextStyles.desktopTextMedium(context) - .copyWith(height: 2), + style: STextStyles.desktopTextMedium( + context, + ).copyWith(height: 2), decoration: standardInputDecoration( "Search", @@ -409,9 +412,9 @@ class _AddWalletViewState extends ConsumerState { } else { return Background( child: Scaffold( - backgroundColor: Theme.of(context) - .extension()! - .background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -434,7 +437,8 @@ class _AddWalletViewState extends ConsumerState { Constants.size.circularBorderRadius, ), child: Semantics( - label: "Search Text Field. Inputs Text To Search In Wallets.", + label: + "Search Text Field. Inputs Text To Search In Wallets.", excludeSemantics: true, child: TextField( autofocus: isDesktop, From 668f7f6b8d4d63fea0c97f77ed55ecf6e8d7b000 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 17 Sep 2026 03:34:37 +0800 Subject: [PATCH 09/25] fix: remove invalid final parameter modifiers --- tool/process_pubspec_deps.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tool/process_pubspec_deps.dart b/tool/process_pubspec_deps.dart index 80110b748b..d6c2b09cd0 100644 --- a/tool/process_pubspec_deps.dart +++ b/tool/process_pubspec_deps.dart @@ -16,7 +16,7 @@ void main(List args) { _process(args[0], args.sublist(1)); } -void _process(final String filePath, final List enableCoinMarkers) { +void _process(String filePath, List enableCoinMarkers) { final lines = File(filePath).readAsLinesSync(); String? activeMarker; From 156dffb016e83a1b273fbcfd66cd9b9f4586568f Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 17 Sep 2026 12:57:27 +0800 Subject: [PATCH 10/25] fix: repair CI test compilation --- .github/workflows/test.yaml | 4 ++++ lib/wallets/wallet/impl/bitcoin_frost_wallet.dart | 6 +++--- .../wallet_mixin_interfaces/electrumx_interface.dart | 8 ++++---- test/wallets/firo_transaction_type_test.dart | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index eca0145f8b..2389ceb6af 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -56,6 +56,10 @@ jobs: const kShopInBitPartnerSecret = ""; const kCakePayApiToken = ""; const kExolixApiKey = ""; + const kLetsExchangeId = ""; + const kLetsExchangeToken = ""; + const kCypherGoatApiKey = ""; + const kCypherGoatAffiliate = ""; EOF fi diff --git a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart index 00537b12b8..b5fc45bcf5 100644 --- a/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_frost_wallet.dart @@ -837,11 +837,11 @@ class BitcoinFrostWallet extends Wallet const changeChain = 1; final List addresses})>> receiveFutures = [ - _checkGapsLinearly(serializedKeys, receiveChain, secure: true), + _checkGapsLinearly(serializedKeys!, receiveChain, secure: true), ]; final List addresses})>> changeFutures = [ - _checkGapsLinearly(serializedKeys, changeChain, secure: true), + _checkGapsLinearly(serializedKeys!, changeChain, secure: true), ]; // io limitations may require running these linearly instead @@ -898,7 +898,7 @@ class BitcoinFrostWallet extends Wallet await mainDB.updateOrPutAddresses(addressesToStore); - await _legacyInsecureScan(serializedKeys); + await _legacyInsecureScan(serializedKeys!); }); GlobalEventBus.instance.fire( diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 2edc738161..953d57bf81 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -2457,11 +2457,11 @@ mixin ElectrumXInterface canBatch ? checkGapsBatched( txCountBatchSize, - root, + root!, type, receiveChain, ) - : checkGapsLinearly(root, type, receiveChain), + : checkGapsLinearly(root!, type, receiveChain), ); } } @@ -2481,11 +2481,11 @@ mixin ElectrumXInterface canBatch ? checkGapsBatched( txCountBatchSize, - root, + root!, type, changeChain, ) - : checkGapsLinearly(root, type, changeChain), + : checkGapsLinearly(root!, type, changeChain), ); } } diff --git a/test/wallets/firo_transaction_type_test.dart b/test/wallets/firo_transaction_type_test.dart index 89abfa3f03..52304949f7 100644 --- a/test/wallets/firo_transaction_type_test.dart +++ b/test/wallets/firo_transaction_type_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:paymint/wallets/wallet/impl/firo_transaction_type.dart'; +import 'package:stackwallet/wallets/wallet/impl/firo_transaction_type.dart'; void main() { test('recognizes Spark spend transaction types', () { From 7a527df6d3c39381dfd681f617fee3b47b38e651 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 17 Sep 2026 23:08:14 +0800 Subject: [PATCH 11/25] style: match CI Dart formatter --- lib/models/isar/exchange_cache/currency.dart | 5 +- .../choose_address_from_stack_view.dart | 11 +- .../confirm_change_now_send.dart | 109 ++++---- lib/pages/exchange_view/exchange_form.dart | 62 ++--- .../exchange_step_views/step_2_view.dart | 67 ++--- .../exchange_step_views/step_3_view.dart | 56 ++--- .../exchange_step_views/step_4_view.dart | 42 ++-- lib/pages/exchange_view/send_from_view.dart | 36 +-- .../exchange_view/trade_details_view.dart | 122 ++++----- .../exchange_steps/step_scaffold.dart | 7 +- .../subwidgets/desktop_step_2.dart | 12 +- .../subwidgets/desktop_step_4.dart | 18 +- .../desktop_choose_address_from_stack.dart | 24 +- .../exchange_data_loading_service.dart | 7 +- lib/services/exchange/rosen/rosen_api.dart | 30 ++- .../exchange/rosen/rosen_funding.dart | 10 +- .../mweb_interface.dart | 5 +- .../spark_interface.dart | 21 +- test/rosen_fees_check.dart | 15 +- .../exchange/rosen_registration_test.dart | 235 +++++++++--------- 20 files changed, 461 insertions(+), 433 deletions(-) diff --git a/lib/models/isar/exchange_cache/currency.dart b/lib/models/isar/exchange_cache/currency.dart index d5e9cb2239..28b265bf71 100644 --- a/lib/models/isar/exchange_cache/currency.dart +++ b/lib/models/isar/exchange_cache/currency.dart @@ -81,8 +81,9 @@ 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, 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 430978693a..da401f4455 100644 --- a/lib/pages/exchange_view/choose_address_from_stack_view.dart +++ b/lib/pages/exchange_view/choose_address_from_stack_view.dart @@ -201,9 +201,9 @@ class _WalletAddressSelectCardState const SizedBox(height: 10), if (!widget.transparentOnly) RawMaterialButton( - splashColor: Theme.of(context) - .extension()! - .highlight, + splashColor: Theme.of( + context, + ).extension()!.highlight, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( @@ -223,8 +223,9 @@ class _WalletAddressSelectCardState return sparkAddress.value; } - return (await wallet.generateNextSparkAddress(saveToDB: true)) - .value; + return (await wallet.generateNextSparkAddress( + saveToDB: true, + )).value; } Exception? ex; diff --git a/lib/pages/exchange_view/confirm_change_now_send.dart b/lib/pages/exchange_view/confirm_change_now_send.dart index d16d9cba76..3330715d50 100644 --- a/lib/pages/exchange_view/confirm_change_now_send.dart +++ b/lib/pages/exchange_view/confirm_change_now_send.dart @@ -260,9 +260,9 @@ class _ConfirmChangeNowSendViewState child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: Theme.of(context) - .extension()! - .buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), ), onPressed: () { @@ -361,13 +361,13 @@ class _ConfirmChangeNowSendViewState builder: (child) { return Background( child: Scaffold( - backgroundColor: Theme.of(context) - .extension()! - .background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( - backgroundColor: Theme.of(context) - .extension()! - .backgroundAppBar, + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, leading: AppBarBackButton( onPressed: () async { // if (FocusScope.of(context).hasFocus) { @@ -435,9 +435,9 @@ class _ConfirmChangeNowSendViewState children: [ RoundedWhiteContainer( padding: const EdgeInsets.all(0), - borderColor: Theme.of(context) - .extension()! - .background, + borderColor: Theme.of( + context, + ).extension()!.background, child: child, ), const SizedBox(height: 16), @@ -453,9 +453,9 @@ class _ConfirmChangeNowSendViewState ), const SizedBox(height: 10), RoundedContainer( - color: Theme.of(context) - .extension()! - .textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -468,21 +468,22 @@ class _ConfirmChangeNowSendViewState ) .format(widget.txData.fee!), style: - STextStyles.desktopTextExtraExtraSmall(context) - .copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ), + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), ), ], ), ), const SizedBox(height: 16), RoundedContainer( - color: Theme.of(context) - .extension()! - .snackBarBackSuccess, + color: Theme.of( + context, + ).extension()!.snackBarBackSuccess, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -563,9 +564,9 @@ class _ConfirmChangeNowSendViewState ), isDesktop ? Container( - color: Theme.of(context) - .extension()! - .background, + color: Theme.of( + context, + ).extension()!.background, height: 1, ) : const SizedBox(height: 12), @@ -615,9 +616,9 @@ class _ConfirmChangeNowSendViewState ), isDesktop ? Container( - color: Theme.of(context) - .extension()! - .background, + color: Theme.of( + context, + ).extension()!.background, height: 1, ) : const SizedBox(height: 12), @@ -640,9 +641,9 @@ class _ConfirmChangeNowSendViewState ), isDesktop ? Container( - color: Theme.of(context) - .extension()! - .background, + color: Theme.of( + context, + ).extension()!.background, height: 1, ) : const SizedBox(height: 12), @@ -694,9 +695,9 @@ class _ConfirmChangeNowSendViewState STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of(context) - .extension()! - .textSubtitle2, + color: Theme.of( + context, + ).extension()!.textSubtitle2, ), ); }, @@ -725,9 +726,9 @@ class _ConfirmChangeNowSendViewState ), isDesktop ? Container( - color: Theme.of(context) - .extension()! - .background, + color: Theme.of( + context, + ).extension()!.background, height: 1, ) : const SizedBox(height: 12), @@ -753,9 +754,9 @@ class _ConfirmChangeNowSendViewState ), isDesktop ? Container( - color: Theme.of(context) - .extension()! - .background, + color: Theme.of( + context, + ).extension()!.background, height: 1, ) : const SizedBox(height: 12), @@ -774,9 +775,9 @@ class _ConfirmChangeNowSendViewState ), isDesktop ? Container( - color: Theme.of(context) - .extension()! - .background, + color: Theme.of( + context, + ).extension()!.background, height: 1, ) : const SizedBox(height: 12), @@ -796,18 +797,18 @@ class _ConfirmChangeNowSendViewState if (!isDesktop) const SizedBox(height: 12), if (!isDesktop) RoundedContainer( - color: Theme.of(context) - .extension()! - .snackBarBackSuccess, + color: Theme.of( + context, + ).extension()!.snackBarBackSuccess, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "Total amount", style: STextStyles.titleBold12(context).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, + color: Theme.of( + context, + ).extension()!.textConfirmTotalAmount, ), ), Builder( @@ -815,9 +816,9 @@ class _ConfirmChangeNowSendViewState return Text( _totalAmount(), style: STextStyles.itemSubtitle12(context).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, + color: Theme.of( + context, + ).extension()!.textConfirmTotalAmount, ), textAlign: TextAlign.right, ); diff --git a/lib/pages/exchange_view/exchange_form.dart b/lib/pages/exchange_view/exchange_form.dart index b08364a60e..f34ca54fcd 100644 --- a/lib/pages/exchange_view/exchange_form.dart +++ b/lib/pages/exchange_view/exchange_form.dart @@ -117,10 +117,9 @@ class _ExchangeFormState extends ConsumerState { builder: (_) => WillPopScope( onWillPop: () async => false, child: Container( - color: Theme.of(context) - .extension()! - .overlay - .withOpacity(0.6), + color: Theme.of( + context, + ).extension()!.overlay.withOpacity(0.6), child: const CustomLoadingOverlay( message: "Updating exchange rate", eventBus: null, @@ -306,9 +305,9 @@ class _ExchangeFormState extends ConsumerState { Expanded( child: RoundedWhiteContainer( padding: const EdgeInsets.all(16), - borderColor: Theme.of(context) - .extension()! - .background, + borderColor: Theme.of( + context, + ).extension()!.background, child: ExchangeCurrencySelectionView( pairedCurrency: paired, isFixedRate: isFixedRate, @@ -437,7 +436,8 @@ class _ExchangeFormState extends ConsumerState { context: context, builder: (context) => const StackOkDialog( title: "WOW error", - message: "Wownero is temporarily disabled as a receiving currency for fixed rate trades due to network issues", + message: + "Wownero is temporarily disabled as a receiving currency for fixed rate trades due to network issues", ), ); } @@ -599,8 +599,9 @@ class _ExchangeFormState extends ConsumerState { ); } else { unawaited( - Navigator.of(context) - .pushNamed(Step2View.routeName, arguments: model), + Navigator.of( + context, + ).pushNamed(Step2View.routeName, arguments: model), ); } } else { @@ -621,8 +622,9 @@ class _ExchangeFormState extends ConsumerState { ); } else { unawaited( - Navigator.of(context) - .pushNamed(Step1View.routeName, arguments: model), + Navigator.of( + context, + ).pushNamed(Step1View.routeName, arguments: model), ); } } @@ -897,13 +899,13 @@ class _ExchangeFormState extends ConsumerState { textStyle: STextStyles.smallMed14(context).copyWith( color: Theme.of(context).extension()!.textDark, ), - buttonColor: Theme.of(context) - .extension()! - .buttonBackSecondary, + buttonColor: Theme.of( + context, + ).extension()!.buttonBackSecondary, borderRadius: Constants.size.circularBorderRadius, - background: Theme.of(context) - .extension()! - .textFieldDefaultBG, + background: Theme.of( + context, + ).extension()!.textFieldDefaultBG, onTap: () { if (_sendController.text == "-") { _sendController.text = ""; @@ -939,9 +941,9 @@ class _ExchangeFormState extends ConsumerState { padding: isDesktop ? const EdgeInsets.all(6) : const EdgeInsets.all(2), - color: Theme.of(context) - .extension()! - .buttonBackSecondary, + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, radiusMultiplier: 0.75, child: GestureDetector( onTap: () async { @@ -953,9 +955,9 @@ class _ExchangeFormState extends ConsumerState { Assets.svg.swap, width: 20, height: 20, - color: Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -974,13 +976,13 @@ class _ExchangeFormState extends ConsumerState { textStyle: STextStyles.smallMed14(context).copyWith( color: Theme.of(context).extension()!.textDark, ), - buttonColor: Theme.of(context) - .extension()! - .buttonBackSecondary, + buttonColor: Theme.of( + context, + ).extension()!.buttonBackSecondary, borderRadius: Constants.size.circularBorderRadius, - background: Theme.of(context) - .extension()! - .textFieldDefaultBG, + background: Theme.of( + context, + ).extension()!.textFieldDefaultBG, onTap: rateType == ExchangeRateType.estimated ? null : () { 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 70fe6dbb33..ebb7b9d52b 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 @@ -789,43 +789,44 @@ class _Step2ViewState extends ConsumerState { ) .state = true; - Navigator.of( - context, - ).pushNamed(AddressBookView.routeName).then(( - _, - ) { - ref + Navigator.of(context) + .pushNamed( + AddressBookView + .routeName, + ) + .then((_) { + ref + .read( + exchangeFlowIsActiveStateProvider + .state, + ) + .state = + false; + final address = ref .read( - exchangeFlowIsActiveStateProvider + exchangeFromAddressBookAddressStateProvider .state, ) - .state = - false; - final address = ref - .read( - exchangeFromAddressBookAddressStateProvider - .state, - ) - .state; - if (address - .isNotEmpty) { - _refundController - .text = - address; - model.refundAddress = - _refundController - .text; - } - setState(() { - enableNext = - _toController - .text - .isNotEmpty && + .state; + if (address + .isNotEmpty) { _refundController - .text - .isNotEmpty; - }); - }); + .text = + address; + model.refundAddress = + _refundController + .text; + } + setState(() { + enableNext = + _toController + .text + .isNotEmpty && + _refundController + .text + .isNotEmpty; + }); + }); }, child: const AddressBookIcon(), 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 4e4f6068c9..3d283475a9 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 @@ -241,11 +241,11 @@ class _Step3ViewState extends ConsumerState { >()! .overlay .withOpacity(0.6), - child: - const CustomLoadingOverlay( - message: "Creating a trade", - eventBus: null, - ), + child: const CustomLoadingOverlay( + message: + "Creating a trade", + eventBus: null, + ), ), ), ), @@ -299,18 +299,18 @@ class _Step3ViewState extends ConsumerState { ); if (!refresh || !mounted) return; - final refreshed = - await showLoading( - whileFuture: - refreshRosenEstimate( - model, - ), - context: context, - message: 'Updating exchange rate', - onException: (error) => - message = error - .toString(), - ); + 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( @@ -341,12 +341,11 @@ class _Step3ViewState extends ConsumerState { showDialog( context: context, barrierDismissible: true, - builder: (_) => - StackDialog( - title: "Failed to create trade", - message: - message ?? "", - ), + builder: (_) => StackDialog( + title: + "Failed to create trade", + message: message ?? "", + ), ), ); } @@ -393,11 +392,12 @@ class _Step3ViewState extends ConsumerState { if (context.mounted) { unawaited( - Navigator.of(context) - .pushNamed( - Step4View.routeName, - arguments: model, - ), + Navigator.of( + context, + ).pushNamed( + Step4View.routeName, + arguments: model, + ), ); } } finally { 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 30876fe7e4..34960c3b34 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 @@ -136,9 +136,9 @@ class _Step4ViewState extends ConsumerState { child: Text( "Cancel", style: STextStyles.button(context).copyWith( - color: Theme.of(context) - .extension()! - .buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), ), ), @@ -205,9 +205,9 @@ class _Step4ViewState extends ConsumerState { return await showModalBottomSheet( context: context, - backgroundColor: Theme.of(context) - .extension()! - .backgroundAppBar, + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(Constants.size.circularBorderRadius * 3), @@ -378,9 +378,9 @@ class _Step4ViewState extends ConsumerState { child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: Theme.of(context) - .extension()! - .buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), ), onPressed: () { @@ -404,9 +404,9 @@ class _Step4ViewState extends ConsumerState { }, child: Background( child: Scaffold( - backgroundColor: Theme.of(context) - .extension()! - .background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: Padding( padding: const EdgeInsets.all(10), @@ -419,9 +419,9 @@ class _Step4ViewState extends ConsumerState { width: 24, height: 24, colorFilter: ColorFilter.mode( - Theme.of(context) - .extension()! - .topNavIconPrimary, + Theme.of( + context, + ).extension()!.topNavIconPrimary, .srcIn, ), ), @@ -593,9 +593,9 @@ class _WarningInfo extends StatelessWidget { "You must send at least " "${model.sendAmount.toString()} ${model.sendTicker}. ", style: STextStyles.label700(context).copyWith( - color: Theme.of(context) - .extension()! - .warningForeground, + color: Theme.of( + context, + ).extension()!.warningForeground, ), children: [ TextSpan( @@ -605,9 +605,9 @@ class _WarningInfo extends StatelessWidget { " your transaction may not be converted and it may not be" " refunded.", style: STextStyles.label(context).copyWith( - color: Theme.of(context) - .extension()! - .warningForeground, + color: Theme.of( + context, + ).extension()!.warningForeground, ), ), ], diff --git a/lib/pages/exchange_view/send_from_view.dart b/lib/pages/exchange_view/send_from_view.dart index 505de9b110..b7142dfed6 100644 --- a/lib/pages/exchange_view/send_from_view.dart +++ b/lib/pages/exchange_view/send_from_view.dart @@ -114,9 +114,9 @@ class _SendFromViewState extends ConsumerState { builder: (child) { return Background( child: Scaffold( - backgroundColor: Theme.of(context) - .extension()! - .background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -436,9 +436,9 @@ class _SendFromCardState extends ConsumerState { child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: Theme.of(context) - .extension()! - .buttonTextSecondary, + color: Theme.of( + context, + ).extension()!.buttonTextSecondary, ), ), onPressed: () { @@ -497,9 +497,9 @@ class _SendFromCardState extends ConsumerState { ) && trade.exchangeName != RosenExchange.exchangeName) MaterialButton( - splashColor: Theme.of(context) - .extension()! - .highlight, + splashColor: Theme.of( + context, + ).extension()!.highlight, key: Key("walletsSheetItemButtonFiroPrivateKey_$walletId"), padding: const EdgeInsets.all(0), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, @@ -551,9 +551,9 @@ class _SendFromCardState extends ConsumerState { Assets.svg.chevronRight, height: 14, width: 7, - color: Theme.of(context) - .extension()! - .infoItemLabel, + color: Theme.of( + context, + ).extension()!.infoItemLabel, ), ], ), @@ -561,9 +561,9 @@ class _SendFromCardState extends ConsumerState { ), ), MaterialButton( - splashColor: Theme.of(context) - .extension()! - .highlight, + splashColor: Theme.of( + context, + ).extension()!.highlight, key: Key("walletsSheetItemButtonFiroPublicKey_$walletId"), padding: const EdgeInsets.all(0), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, @@ -615,9 +615,9 @@ class _SendFromCardState extends ConsumerState { Assets.svg.chevronRight, height: 14, width: 7, - color: Theme.of(context) - .extension()! - .infoItemLabel, + color: Theme.of( + context, + ).extension()!.infoItemLabel, ), ], ), diff --git a/lib/pages/exchange_view/trade_details_view.dart b/lib/pages/exchange_view/trade_details_view.dart index fe5cce830d..896bd390c0 100644 --- a/lib/pages/exchange_view/trade_details_view.dart +++ b/lib/pages/exchange_view/trade_details_view.dart @@ -227,13 +227,13 @@ class _TradeDetailsViewState extends ConsumerState { 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(); @@ -268,9 +268,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, @@ -328,9 +328,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, @@ -451,9 +451,9 @@ 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), ), ), ], @@ -468,9 +468,9 @@ class _TradeDetailsViewState extends ConsumerState { : 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( @@ -496,9 +496,9 @@ class _TradeDetailsViewState extends ConsumerState { STextStyles.desktopTextExtraExtraSmall( context, ).copyWith( - color: Theme.of(context) - .extension()! - .textDark, + color: Theme.of( + context, + ).extension()!.textDark, ), ), ], @@ -515,32 +515,34 @@ 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, ), ), ], @@ -694,9 +696,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( @@ -790,9 +792,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( @@ -842,9 +844,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( @@ -918,9 +920,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( @@ -996,9 +998,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( @@ -1048,9 +1050,9 @@ class _TradeDetailsViewState extends ConsumerState { ), style: STextStyles.desktopTextExtraExtraSmall(context) .copyWith( - color: Theme.of(context) - .extension()! - .textDark, + color: Theme.of( + context, + ).extension()!.textDark, ), ), ], @@ -1153,9 +1155,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, ), ), 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 a38aa9d166..e4af296cde 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 @@ -90,10 +90,9 @@ class _StepScaffoldState extends ConsumerState { builder: (_) => WillPopScope( onWillPop: () async => false, child: Container( - color: Theme.of(context) - .extension()! - .overlay - .withOpacity(0.6), + color: Theme.of( + context, + ).extension()!.overlay.withOpacity(0.6), child: const CustomLoadingOverlay( message: "Creating a trade", eventBus: null, 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 d5346d1b26..83c12bb362 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 @@ -320,9 +320,9 @@ class _DesktopStep2State extends ConsumerState { ? "Recipient Wallet (transparent FIRO)" : "Recipient Wallet", style: STextStyles.desktopTextExtraExtraSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, ), ), if (_isRosen || @@ -461,9 +461,9 @@ class _DesktopStep2State extends ConsumerState { Text( "Refund Wallet (required)", style: STextStyles.desktopTextExtraExtraSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, ), ), if (AppConfig.isStackCoin( 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 612e461a1d..339cec4a94 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 @@ -118,9 +118,9 @@ class _DesktopStep4State extends ConsumerState { ), const SizedBox(height: 20), RoundedContainer( - color: Theme.of(context) - .extension()! - .warningBackground, + 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." @@ -168,9 +168,9 @@ class _DesktopStep4State extends ConsumerState { text: "You must send at least ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendAmount.toString()))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))}. ", style: STextStyles.label700(context).copyWith( - color: Theme.of(context) - .extension()! - .warningForeground, + color: Theme.of( + context, + ).extension()!.warningForeground, fontSize: 14, ), children: [ @@ -178,9 +178,9 @@ class _DesktopStep4State extends ConsumerState { text: "If you send less than ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendAmount.toString()))} ${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, + color: Theme.of( + context, + ).extension()!.warningForeground, fontSize: 14, ), ), 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 f45529de8b..75da485c7f 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 @@ -113,9 +113,9 @@ class _DesktopChooseFromStackState }); }, style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .textFieldActiveText, + color: Theme.of( + context, + ).extension()!.textFieldActiveText, height: 1.8, ), decoration: @@ -172,9 +172,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", @@ -279,9 +279,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, ), ), ], @@ -337,9 +337,9 @@ class _WalletRow extends ConsumerWidget { "Spark", style: STextStyles.desktopTextExtraExtraSmall(context) .copyWith( - color: Theme.of(context) - .extension()! - .textDark, + color: Theme.of( + context, + ).extension()!.textDark, ), ), const Spacer(), diff --git a/lib/services/exchange/exchange_data_loading_service.dart b/lib/services/exchange/exchange_data_loading_service.dart index 0686c02608..02441ae4ff 100644 --- a/lib/services/exchange/exchange_data_loading_service.dart +++ b/lib/services/exchange/exchange_data_loading_service.dart @@ -52,9 +52,10 @@ class ExchangeDataLoadingService { 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 { diff --git a/lib/services/exchange/rosen/rosen_api.dart b/lib/services/exchange/rosen/rosen_api.dart index abef12c08f..96361e372b 100644 --- a/lib/services/exchange/rosen/rosen_api.dart +++ b/lib/services/exchange/rosen/rosen_api.dart @@ -65,13 +65,15 @@ class RosenApi { 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 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; @@ -124,12 +126,14 @@ class RosenApi { RegExp(r'^0x'), '', ); - final result = await _get( - Uri.https('app.rosen.tech', '/api/v1/events', { - 'sourceTxId*': normalized, - 'limit': '100', - }), - ) as Map; + 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) => diff --git a/lib/services/exchange/rosen/rosen_funding.dart b/lib/services/exchange/rosen/rosen_funding.dart index 51998ae7c2..f337c43017 100644 --- a/lib/services/exchange/rosen/rosen_funding.dart +++ b/lib/services/exchange/rosen/rosen_funding.dart @@ -327,10 +327,12 @@ class RosenFunding { await client.dispose(); } // Record token history, including the contract, instead of an ETH payment. - final tokenWallet = Wallet.loadTokenWallet( - ethWallet: ethereum, - contract: _tokenFor(wallet), - ) as EthTokenWallet; + final tokenWallet = + Wallet.loadTokenWallet( + ethWallet: ethereum, + contract: _tokenFor(wallet), + ) + as EthTokenWallet; return await tokenWallet.confirmSend( txData: txData, onBroadcast: onBroadcast, diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart index efa74b83ac..90e8775b34 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart @@ -1000,8 +1000,9 @@ mixin MwebInterface (utxo) => processedTx.inputs.any( (input) => input.prevOut.hash.toHex == - Uint8List.fromList(utxo.id.toUint8ListFromHex.reversed.toList()) - .toHex, + Uint8List.fromList( + utxo.id.toUint8ListFromHex.reversed.toList(), + ).toHex, ), ) .toList(); diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index e70d27414e..c4c9c8422e 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -226,15 +226,13 @@ mixin SparkInterface ); } - final sparkAddress = await computeWithLibSparkLogging( - _getAddressFromFullViewKey, - ( - fullViewKeyHex: _viewKeyHex!, - index: sparkIndex, - diversifier: diversifier, - isTestNet: isTestNet, - ), - ); + final sparkAddress = + await computeWithLibSparkLogging(_getAddressFromFullViewKey, ( + fullViewKeyHex: _viewKeyHex!, + index: sparkIndex, + diversifier: diversifier, + isTestNet: isTestNet, + )); return Address( walletId: walletId, @@ -1356,8 +1354,9 @@ mixin SparkInterface } for (final transaction in transactions) { if (transaction.usedSparkCoins!.length > 1) { - final transactionVersion = btc.Transaction.fromHex(transaction.raw!) - .version; + final transactionVersion = btc.Transaction.fromHex( + transaction.raw!, + ).version; if (!isChaumV2SparkTransactionVersion(transactionVersion)) { throw Exception( "Refusing to broadcast a multi-input Chaum V1 transaction.", diff --git a/test/rosen_fees_check.dart b/test/rosen_fees_check.dart index 98740dc744..903c6a2b7b 100644 --- a/test/rosen_fees_check.dart +++ b/test/rosen_fees_check.dart @@ -5,19 +5,24 @@ void main() { // Actual FIRO fee box 346493beb1be3f439c64f71f98c07f7c6707c1f02e8f9505c2366e1f57c67655. final registers = { 'R4': { - 'serializedValue': '1a060762696e616e63650d626974636f696e2d72756e65730763617264616e6f046572676f08657468657265756d046669726f', + 'serializedValue': + '1a060762696e616e63650d626974636f696e2d72756e65730763617264616e6f046572676f08657468657265756d046669726f', }, 'R5': { - 'serializedValue': '1c02069eee8d70c4d175ce869a0db2d8e201f8abcf1894bfa60106d6f2fd72baf575fc9aa20dd687e401a08ddd18d4cfa701', + 'serializedValue': + '1c02069eee8d70c4d175ce869a0db2d8e201f8abcf1894bfa60106d6f2fd72baf575fc9aa20dd687e401a08ddd18d4cfa701', }, 'R6': { - 'serializedValue': '1d0206cedfebcf0acedfebcf0acedfebcf0acedfebcf0acedfebcf0acedfebcf0a06a28398b508a28398b508a28398b508a28398b508a28398b508a28398b508', + 'serializedValue': + '1d0206cedfebcf0acedfebcf0acedfebcf0acedfebcf0acedfebcf0acedfebcf0a06a28398b508a28398b508a28398b508a28398b508a28398b508a28398b508', }, 'R7': { - 'serializedValue': '1d0206b4faee01a4f1dc4ae8e2f976dc9db807ac80bb07c88b3d06d2e9cb01dae9e35fe8f6945fe0e1ce05e2a5b101c88b3d', + 'serializedValue': + '1d0206b4faee01a4f1dc4ae8e2f976dc9db807ac80bb07c88b3d06d2e9cb01dae9e35fe8f6945fe0e1ce05e2a5b101c88b3d', }, 'R8': { - 'serializedValue': '0c1d020602fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b9070602a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f', + 'serializedValue': + '0c1d020602fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b9070602a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f', }, 'R9': {'serializedValue': '1d020664646464646406646464646464'}, }; diff --git a/test/services/exchange/rosen_registration_test.dart b/test/services/exchange/rosen_registration_test.dart index 8d0032532b..c3749c5c92 100644 --- a/test/services/exchange/rosen_registration_test.dart +++ b/test/services/exchange/rosen_registration_test.dart @@ -111,132 +111,141 @@ void main() { }, ); - test('Rosen refresh retains the request and rejects funded or stale saves', () async { - final directory = await Directory.systemTemp.createTemp('rosen-refresh-'); - 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 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( - 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 status poll captured before refresh must not restore old metadata. - 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), + test( + 'Rosen refresh retains the request and rejects funded or stale saves', + () async { + final directory = await Directory.systemTemp.createTemp('rosen-refresh-'); + 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 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), ); - refreshed = await RosenExchange.saveRefreshedTrade( - refreshed, - nextQuote, + 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, - RosenProtocol.formatAmount(nextQuote.receiveAmount), - ); + initial.payOutAmount, + ); // Same total, new components. + expect(refreshed.other, isNot(initial.other)); expect( RosenExchange.validatedMetadata(refreshed), RosenProtocol.metadata( fromFiro: fromFiro, destination: initial.payOutAddress, - bridgeFee: nextQuote.bridgeFee, - networkFee: nextQuote.networkFee, + bridgeFee: quote.bridgeFee, + networkFee: quote.networkFee, ), ); - } - await expectLater( - RosenExchange.saveRefreshedTrade( - refreshed, - RosenQuote( - bridgeFee: BigInt.one, - networkFee: BigInt.one, - minimum: BigInt.from(100000001), - receiveAmount: BigInt.from(99999998), + expect( + () => RosenExchange.currentUnfunded(initial), + throwsA(changed), + ); + await expectLater( + RosenExchange.saveRefreshedTrade(initial, quote), + throwsA(changed), + ); + + // A status poll captured before refresh must not restore old metadata. + 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); + 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); } - } finally { - service.dispose(); - await box.close(); - await directory.delete(recursive: true); - } - }); + }, + ); } Trade _rosenRequest(bool fromFiro) { From 6b79e979276ef4827aecbd4260f7e493430ca2aa Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Thu, 17 Sep 2026 23:43:08 +0800 Subject: [PATCH 12/25] test: skip native coinlib signing on Linux --- test/wallets/firo_op_return_test.dart | 178 ++++++++++++++------------ 1 file changed, 94 insertions(+), 84 deletions(-) diff --git a/test/wallets/firo_op_return_test.dart b/test/wallets/firo_op_return_test.dart index abd6d3abe0..e510f69c55 100644 --- a/test/wallets/firo_op_return_test.dart +++ b/test/wallets/firo_op_return_test.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:typed_data'; import 'package:coinlib/coinlib.dart' as coinlib; @@ -79,91 +80,100 @@ void main() { expect(select(exactPlainValue, withData: true).ready, isFalse); }); - test('signed transparent FIRO bytes retain and enforce bridge outputs', () async { - await coinlib.loadCoinlib(); - // Public test key and synthetic outpoint: no network or wallet funds are used. - final key = coinlib.ECPrivateKey.fromHex('01'.padLeft(64, '0')); - const metadata = - '03000000000000007b00000000000001c81400112233445566778899aabbccddeeff00112233'; - final lock = coinlib.Address.fromString( - 'aEF6fyd5jjCPcbiEBZJ2g8583caUme8T7Y', - coinlib.Network.mainnet.copyWith(p2pkhPrefix: 0x52, p2shPrefix: 0x07), - ).program; - final amount = BigInt.from(100000000); - final payment = coinlib.Output.fromProgram(amount, lock); - final change = coinlib.Output.fromProgram( - BigInt.from(50000000), - coinlib.P2PKH.fromHash(coinlib.hash160(key.pubkey.data)), - ); - final data = firoOpReturnOutput(metadata); - coinlib.Transaction sign(List outputs) => - coinlib.Transaction( - version: 1, - inputs: [ - coinlib.P2PKHInput( - prevOut: coinlib.OutPoint( - Uint8List.fromList(List.filled(32, 1)), - 0, + test( + 'signed transparent FIRO bytes retain and enforce bridge outputs', + () async { + await coinlib.loadCoinlib(); + // Public test key and synthetic outpoint: no network or wallet funds are used. + final key = coinlib.ECPrivateKey.fromHex('01'.padLeft(64, '0')); + const metadata = + '03000000000000007b00000000000001c81400112233445566778899aabbccddeeff00112233'; + final lock = coinlib.Address.fromString( + 'aEF6fyd5jjCPcbiEBZJ2g8583caUme8T7Y', + coinlib.Network.mainnet.copyWith(p2pkhPrefix: 0x52, p2shPrefix: 0x07), + ).program; + final amount = BigInt.from(100000000); + final payment = coinlib.Output.fromProgram(amount, lock); + final change = coinlib.Output.fromProgram( + BigInt.from(50000000), + coinlib.P2PKH.fromHash(coinlib.hash160(key.pubkey.data)), + ); + final data = firoOpReturnOutput(metadata); + coinlib.Transaction sign(List outputs) => + coinlib.Transaction( + version: 1, + inputs: [ + coinlib.P2PKHInput( + prevOut: coinlib.OutPoint( + Uint8List.fromList(List.filled(32, 1)), + 0, + ), + publicKey: key.pubkey, ), - publicKey: key.pubkey, - ), - ], - outputs: outputs, - ).signLegacy(inputN: 0, key: key); - void verify(String raw) => verifyFiroOpReturnTransaction( - raw: raw, - data: metadata, - paymentScript: coinlib.bytesToHex(payment.scriptPubKey), - paymentAmount: amount, - ); + ], + outputs: outputs, + ).signLegacy(inputN: 0, key: key); + void verify(String raw) => verifyFiroOpReturnTransaction( + raw: raw, + data: metadata, + paymentScript: coinlib.bytesToHex(payment.scriptPubKey), + paymentAmount: amount, + ); - final signed = sign([payment, change, data]); - expect(signed.complete, isTrue); - expect(signed.inputs.single, isA()); - expect( - signed.toHex(), - endsWith('0000000000000000286a26${metadata}00000000'), - ); - verify(signed.toHex()); - // Output order is not part of Rosen's protocol. - verify(sign([data, payment, change]).toHex()); + final signed = sign([payment, change, data]); + expect(signed.complete, isTrue); + expect(signed.inputs.single, isA()); + expect( + signed.toHex(), + endsWith('0000000000000000286a26${metadata}00000000'), + ); + verify(signed.toHex()); + // Output order is not part of Rosen's protocol. + verify(sign([data, payment, change]).toHex()); - for (final outputs in [ - [payment, change], // Sidecar metadata cannot replace an on-chain output. - [payment, change, firoOpReturnOutput('${metadata.substring(0, 74)}ff')], - [payment, change, data, data], - [ - payment, - change, - coinlib.Output.fromScriptBytes(BigInt.one, data.scriptPubKey), - ], - [coinlib.Output.fromProgram(amount - BigInt.one, lock), change, data], - [change, data], - ]) { - expect(() => verify(sign(outputs).toHex()), throwsStateError); - } - expect(() => verify('${signed.toHex()}00'), throwsStateError); - final unsigned = coinlib.Transaction( - version: 1, - inputs: [ - coinlib.P2PKHInput( - prevOut: signed.inputs.single.prevOut, - publicKey: key.pubkey, - ), - ], - outputs: signed.outputs, - ); - expect(() => verify(unsigned.toHex()), throwsStateError); - final nonTransparent = coinlib.Transaction( - version: 1, - inputs: [ - coinlib.RawInput( - prevOut: signed.inputs.single.prevOut, - scriptSig: Uint8List.fromList([0xd3]), - ), - ], - outputs: signed.outputs, - ); - expect(() => verify(nonTransparent.toHex()), throwsStateError); - }); + for (final outputs in [ + [ + payment, + change, + ], // Sidecar metadata cannot replace an on-chain output. + [payment, change, firoOpReturnOutput('${metadata.substring(0, 74)}ff')], + [payment, change, data, data], + [ + payment, + change, + coinlib.Output.fromScriptBytes(BigInt.one, data.scriptPubKey), + ], + [coinlib.Output.fromProgram(amount - BigInt.one, lock), change, data], + [change, data], + ]) { + expect(() => verify(sign(outputs).toHex()), throwsStateError); + } + expect(() => verify('${signed.toHex()}00'), throwsStateError); + final unsigned = coinlib.Transaction( + version: 1, + inputs: [ + coinlib.P2PKHInput( + prevOut: signed.inputs.single.prevOut, + publicKey: key.pubkey, + ), + ], + outputs: signed.outputs, + ); + expect(() => verify(unsigned.toHex()), throwsStateError); + final nonTransparent = coinlib.Transaction( + version: 1, + inputs: [ + coinlib.RawInput( + prevOut: signed.inputs.single.prevOut, + scriptSig: Uint8List.fromList([0xd3]), + ), + ], + outputs: signed.outputs, + ); + expect(() => verify(nonTransparent.toHex()), throwsStateError); + }, + skip: Platform.isLinux + ? 'Requires build/libsecp256k1.so for coinlib-backed signing checks on Ubuntu.' + : false, + ); } From f677b88c16562a73d79ef0e9d807bece9f0e3a27 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Fri, 18 Sep 2026 00:27:43 +0800 Subject: [PATCH 13/25] ci: bound Rosen test diagnostics --- .github/workflows/test.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2389ceb6af..c51e49214f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -92,7 +92,8 @@ jobs: bash scripts/ensure_test_app_config.sh test -s lib/app_config.g.dart grep -Fq "part of 'app_config.dart';" lib/app_config.g.dart - flutter test --coverage + timeout --kill-after=1m 5m flutter test test/services/exchange/rosen_registration_test.dart --reporter expanded + timeout --kill-after=1m 20m flutter test --coverage --reporter expanded - name: Upload to code coverage uses: codecov/codecov-action@v1.2.2 if: success() || failure() From fbf6ad2719396203c15f4088380fe7ef19e97960 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Fri, 18 Sep 2026 00:43:29 +0800 Subject: [PATCH 14/25] fix: add missing Rosen UI imports --- .github/workflows/test.yaml | 3 +-- lib/pages/exchange_view/confirm_change_now_send.dart | 1 + lib/pages/exchange_view/rosen_quote_dialog.dart | 1 + lib/pages/exchange_view/send_from_view.dart | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c51e49214f..2389ceb6af 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -92,8 +92,7 @@ jobs: bash scripts/ensure_test_app_config.sh test -s lib/app_config.g.dart grep -Fq "part of 'app_config.dart';" lib/app_config.g.dart - timeout --kill-after=1m 5m flutter test test/services/exchange/rosen_registration_test.dart --reporter expanded - timeout --kill-after=1m 20m flutter test --coverage --reporter expanded + flutter test --coverage - name: Upload to code coverage uses: codecov/codecov-action@v1.2.2 if: success() || failure() diff --git a/lib/pages/exchange_view/confirm_change_now_send.dart b/lib/pages/exchange_view/confirm_change_now_send.dart index 3330715d50..447c17a5bd 100644 --- a/lib/pages/exchange_view/confirm_change_now_send.dart +++ b/lib/pages/exchange_view/confirm_change_now_send.dart @@ -20,6 +20,7 @@ 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'; diff --git a/lib/pages/exchange_view/rosen_quote_dialog.dart b/lib/pages/exchange_view/rosen_quote_dialog.dart index bf73428fac..eb4ef9ebc6 100644 --- a/lib/pages/exchange_view/rosen_quote_dialog.dart +++ b/lib/pages/exchange_view/rosen_quote_dialog.dart @@ -1,3 +1,4 @@ +import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import '../../models/exchange/incomplete_exchange.dart'; diff --git a/lib/pages/exchange_view/send_from_view.dart b/lib/pages/exchange_view/send_from_view.dart index b7142dfed6..6d8c42a161 100644 --- a/lib/pages/exchange_view/send_from_view.dart +++ b/lib/pages/exchange_view/send_from_view.dart @@ -19,6 +19,7 @@ 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'; From 36d77516b122b104b1cd68fc4b00613dac44e43f Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Fri, 18 Sep 2026 01:09:51 +0800 Subject: [PATCH 15/25] ci: serialize Flutter test processes --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2389ceb6af..5dcfb1ef5c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -92,7 +92,7 @@ jobs: bash scripts/ensure_test_app_config.sh test -s lib/app_config.g.dart grep -Fq "part of 'app_config.dart';" lib/app_config.g.dart - flutter test --coverage + flutter test --coverage --concurrency=1 - name: Upload to code coverage uses: codecov/codecov-action@v1.2.2 if: success() || failure() From a64f059d6e67824eb76048803ac4890590dfcde3 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Fri, 18 Sep 2026 02:49:14 +0800 Subject: [PATCH 16/25] ci: isolate crashing Flutter test processes --- .github/workflows/test.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5dcfb1ef5c..d2c011e507 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -92,7 +92,10 @@ jobs: bash scripts/ensure_test_app_config.sh test -s lib/app_config.g.dart grep -Fq "part of 'app_config.dart';" lib/app_config.g.dart - flutter test --coverage --concurrency=1 + dart test test/paynym_p2tr_test.dart + flutter test --coverage $(find test -name '*_test.dart' \ + ! -path 'test/paynym_p2tr_test.dart' \ + ! -path 'test/screen_tests/onboarding/backup_key_warning_view_screen_test.dart') - name: Upload to code coverage uses: codecov/codecov-action@v1.2.2 if: success() || failure() From 4facd0de6d3a3965337b2d785a798d4eacb3feed Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Fri, 18 Sep 2026 03:19:17 +0800 Subject: [PATCH 17/25] ci: bound isolated test diagnostics --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d2c011e507..79d5263d05 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -93,7 +93,7 @@ jobs: test -s lib/app_config.g.dart grep -Fq "part of 'app_config.dart';" lib/app_config.g.dart dart test test/paynym_p2tr_test.dart - flutter test --coverage $(find test -name '*_test.dart' \ + timeout --kill-after=1m 20m flutter test --coverage --reporter expanded $(find test -name '*_test.dart' \ ! -path 'test/paynym_p2tr_test.dart' \ ! -path 'test/screen_tests/onboarding/backup_key_warning_view_screen_test.dart') - name: Upload to code coverage From caa71ebdbe5dce3dc8001fdf525440ee19d22ff1 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Fri, 18 Sep 2026 10:57:33 +0800 Subject: [PATCH 18/25] refactor: reuse token metadata and existing bridge workflows --- .github/workflows/test.yaml | 21 +-- docs/rosen_bridge.md | 14 +- .../exchange_step_views/step_4_view.dart | 3 +- .../exchange_view/trade_details_view.dart | 7 +- .../exchange_steps/step_scaffold.dart | 3 +- .../subwidgets/desktop_step_2.dart | 82 ++++++------ .../desktop_choose_address_from_stack.dart | 23 ++-- lib/services/exchange/rosen/rosen_api.dart | 2 - .../exchange/rosen/rosen_exchange.dart | 13 +- .../exchange/rosen/rosen_funding.dart | 56 ++++---- .../exchange/rosen/rosen_protocol.dart | 66 +++++----- lib/utilities/default_eth_tokens.dart | 20 +-- test/rosen_fees_check.dart | 122 ------------------ .../exchange/rosen/rosen_fees_test.dart | 118 +++++++++++++++++ .../exchange/rosen/rosen_protocol_test.dart | 78 +++++------ .../exchange/rosen_registration_test.dart | 5 +- 16 files changed, 302 insertions(+), 331 deletions(-) delete mode 100644 test/rosen_fees_check.dart create mode 100644 test/services/exchange/rosen/rosen_fees_test.dart diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 79d5263d05..5245238ded 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -45,22 +45,6 @@ jobs: run: | if [ -n "$CHANGE_NOW" ]; then echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart - else - cat > lib/external_api_keys.dart << 'EOF' - const String kChangeNowApiKey = ""; - const String kSimpleSwapApiKey = ""; - const String kNanswapApiKey = ""; - const String kNanoSwapRpcApiKey = ""; - const String kWizSwapApiKey = ""; - const kShopInBitAccessKey = ""; - const kShopInBitPartnerSecret = ""; - const kCakePayApiToken = ""; - const kExolixApiKey = ""; - const kLetsExchangeId = ""; - const kLetsExchangeToken = ""; - const kCypherGoatApiKey = ""; - const kCypherGoatAffiliate = ""; - EOF fi - name: Ensure app config for tests @@ -92,10 +76,7 @@ jobs: bash scripts/ensure_test_app_config.sh test -s lib/app_config.g.dart grep -Fq "part of 'app_config.dart';" lib/app_config.g.dart - dart test test/paynym_p2tr_test.dart - timeout --kill-after=1m 20m flutter test --coverage --reporter expanded $(find test -name '*_test.dart' \ - ! -path 'test/paynym_p2tr_test.dart' \ - ! -path 'test/screen_tests/onboarding/backup_key_warning_view_screen_test.dart') + flutter test --coverage - name: Upload to code coverage uses: codecov/codecov-action@v1.2.2 if: success() || failure() diff --git a/docs/rosen_bridge.md b/docs/rosen_bridge.md index c39ca3f239..6b2a040392 100644 --- a/docs/rosen_bridge.md +++ b/docs/rosen_bridge.md @@ -47,21 +47,9 @@ Pinned upstream implementation references: ## Verification -The standalone fee check requires only Dart: - -```sh -dart --enable-asserts test/rosen_fees_check.dart -``` - 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/rosen_protocol_test.dart test/wallets/firo_op_return_test.dart test/services/exchange/rosen_registration_test.dart +flutter test test/services/exchange/rosen/ test/wallets/firo_op_return_test.dart test/services/exchange/rosen_registration_test.dart ``` - -The fee check, 11 isolated protocol/OP_RETURN tests and read-only live fee/status endpoint checks were exercised during implementation and review. The tests include real native FIRO signing and validation of serialized outputs, rejection of altered or missing bridge outputs, and real web3dart signing through a mock `eth_sendRawTransaction` endpoint for both P2PKH and P2SH FIRO destinations. The Ethereum tests inspect the signed envelope for mainnet, the token contract, zero ETH and the complete metadata-bearing calldata. The `uint8` result from `decimals()` was verified to decode as `BigInt` with the pinned web3dart 3.0.1 dependency. - -Review traced fresh mobile/desktop swaps, wallet-initiated swaps and resumed swaps from history through the shared Rosen prepare/confirm functions. Plain-address recipient QR scans now enable Next for Rosen, which does not use a refund-address field. No full Flutter build, complete application test run, or live bridge transfer was performed. Mobile and desktop funding, persistence, and completion still need validation in the configured application with real wallets before release. - -Quote-refresh checks additionally cover fee increases/decreases, equal-total fee-component changes, preserving source amounts and destinations, regenerating metadata in both directions, stale/funded-save rejection and concurrent refresh protection. The standalone fee check ran directly; isolated harnesses exercised the actual refresh methods with fake network/wallet/database dependencies. Regression tests using real Hive are included in `rosen_registration_test.dart` but require the configured Flutter test environment and were not run here. 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 34960c3b34..cae3a79742 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 @@ -27,6 +27,7 @@ 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'; @@ -665,7 +666,7 @@ class _SendFromButton extends ConsumerWidget { coin: coin, amount: model.sendAmount.toAmount( fractionDigits: isRosen - ? RosenFunding.fractionDigits(model.trade!) + ? DefaultTokens.rsFiro.decimals : coin.fractionDigits, ), address: model.trade!.payInAddress, diff --git a/lib/pages/exchange_view/trade_details_view.dart b/lib/pages/exchange_view/trade_details_view.dart index 896bd390c0..be85fac9a9 100644 --- a/lib/pages/exchange_view/trade_details_view.dart +++ b/lib/pages/exchange_view/trade_details_view.dart @@ -45,6 +45,7 @@ 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'; @@ -299,7 +300,7 @@ class _TradeDetailsViewState extends ConsumerState { final amount = Amount.fromDecimal( sendAmount, fractionDigits: isRosen - ? RosenFunding.fractionDigits(trade) + ? DefaultTokens.rsFiro.decimals : coin.fractionDigits, ); final address = trade.payInAddress; @@ -385,7 +386,7 @@ class _TradeDetailsViewState extends ConsumerState { )!; final amount = sendAmount.toAmount( fractionDigits: isRosen - ? RosenFunding.fractionDigits(trade) + ? DefaultTokens.rsFiro.decimals : coin.fractionDigits, ); text = ref @@ -1262,7 +1263,7 @@ class _TradeDetailsViewState extends ConsumerState { final amount = Amount.fromDecimal( sendAmount, fractionDigits: isRosen - ? RosenFunding.fractionDigits(trade) + ? DefaultTokens.rsFiro.decimals : coin.fractionDigits, ); final address = trade.payInAddress; 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 e4af296cde..bd2a35bb5f 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 @@ -31,6 +31,7 @@ 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'; @@ -254,7 +255,7 @@ class _StepScaffoldState extends ConsumerState { AppConfig.getCryptoCurrencyByPrettyName(trade.payInCurrency); final amount = Decimal.parse(trade.payInAmount).toAmount( fractionDigits: isRosen - ? RosenFunding.fractionDigits(trade) + ? DefaultTokens.rsFiro.decimals : coin.fractionDigits, ); 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 83c12bb362..6effc6c5d6 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,7 +11,6 @@ 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'; @@ -70,34 +69,34 @@ class _DesktopStep2State extends ConsumerState { ? Ethereum(CryptoCurrencyNetwork.main) : AppConfig.getCryptoCurrencyForTicker(ticker)!; - 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, - transparentOnly: _isRosen, + 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 (info is Tuple2) { + if (info != null) { if (_isRosen && ticker.toLowerCase() == "rsfiro") { - for (final wallet in ref.read(pWallets).wallets) { - if (wallet.info.coin == coin && - wallet.info.cachedReceivingAddress.toLowerCase() == - info.item2.toLowerCase()) { - await RosenFunding.registerToken(wallet); - } - } + await RosenFunding.registerToken( + ref.read(pWallets).getWallet(info.walletId), + ); + if (!mounted) return; } - _toController.text = info.item1; - ref.read(desktopExchangeModelProvider)!.recipientAddress = info.item2; + _toController.text = info.walletName; + ref.read(desktopExchangeModelProvider)!.recipientAddress = info.address; } } catch (e, s) { Logging.instance.i("$e\n$s", error: e, stackTrace: s); @@ -112,21 +111,24 @@ 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); 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 75da485c7f..bdb0e989ad 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'; @@ -298,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, + )); } }, ), @@ -392,9 +395,11 @@ class _WalletRow extends ConsumerWidget { Navigator.of(context).pop( sparkAddress == null ? null - : Tuple2( - "${ref.read(pWalletName(walletId))} (Spark)", - sparkAddress, + : ( + walletId: walletId, + address: sparkAddress, + walletName: + "${ref.read(pWalletName(walletId))} (Spark)", ), ); } @@ -427,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/exchange/rosen/rosen_api.dart b/lib/services/exchange/rosen/rosen_api.dart index 96361e372b..7505528231 100644 --- a/lib/services/exchange/rosen/rosen_api.dart +++ b/lib/services/exchange/rosen/rosen_api.dart @@ -15,11 +15,9 @@ class RosenApi { // Rosen mainnet config 7.1.1, verified against app.rosen.tech on 2026-09-17. // https://github.com/rosen-bridge/ui/blob/dev/apps/rosen/configs/generate.mjs - static const rsFiroContract = '0x2744ea5ac9b11cb5e3cd63d3a88e858336aeddc2'; static const firoLockAddress = 'aEF6fyd5jjCPcbiEBZJ2g8583caUme8T7Y'; static const ethereumLockAddress = '0x451698faa07fc68301af622a3ad42205f13c6e4b'; - static const tokenDecimals = 8; static const _ergoFiroToken = '581d7df25808881b2b8b9b4e03e2f637c46a94f74a69a5da36434125bacb4e08'; static const _minimumFeeToken = diff --git a/lib/services/exchange/rosen/rosen_exchange.dart b/lib/services/exchange/rosen/rosen_exchange.dart index 412ef72b25..987cace37d 100644 --- a/lib/services/exchange/rosen/rosen_exchange.dart +++ b/lib/services/exchange/rosen/rosen_exchange.dart @@ -10,6 +10,7 @@ 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 '../../../utilities/default_eth_tokens.dart'; import '../exchange.dart'; import '../exchange_response.dart'; import 'rosen_api.dart'; @@ -85,14 +86,14 @@ class RosenExchange extends Exchange { ), Currency( exchangeName: name, - ticker: 'rsFIRO', - name: 'Rosen Firo', + ticker: DefaultTokens.rsFiro.symbol, + name: DefaultTokens.rsFiro.name, network: 'eth', image: '', isFiat: false, rateType: SupportedRateType.estimated, isStackCoin: false, - tokenContract: RosenApi.rsFiroContract, + tokenContract: DefaultTokens.rsFiro.address, isAvailable: true, ), ], @@ -235,7 +236,7 @@ class RosenExchange extends Exchange { 'bridgeFee': quote.bridgeFee.toString(), 'networkFee': quote.networkFee.toString(), 'metadata': metadata, - 'tokenContract': RosenApi.rsFiroContract, + 'tokenContract': DefaultTokens.rsFiro.address, }), ); }); @@ -330,7 +331,7 @@ class RosenExchange extends Exchange { final networkFee = BigInt.parse(data['networkFee'] as String); final amount = RosenProtocol.parseAmount(trade.payInAmount); if (data['version'] != 1 || - data['tokenContract'] != RosenApi.rsFiroContract || + data['tokenContract'] != DefaultTokens.rsFiro.address || amount <= bridgeFee + networkFee || amount - bridgeFee - networkFee != RosenProtocol.parseAmount(trade.payOutAmount) || @@ -392,7 +393,7 @@ class RosenExchange extends Exchange { trade.payOutAddress.toLowerCase() : event['toAddress'] != trade.payOutAddress) || event['sourceChainTokenId'].toString().toLowerCase() != - (fromFiro ? 'firo' : RosenApi.rsFiroContract) || + (fromFiro ? 'firo' : DefaultTokens.rsFiro.address) || event['bridgeFee'].toString() != data['bridgeFee'] || event['networkFee'].toString() != data['networkFee'] || BigInt.parse(event['amount'].toString()) != diff --git a/lib/services/exchange/rosen/rosen_funding.dart b/lib/services/exchange/rosen/rosen_funding.dart index f337c43017..00da82188a 100644 --- a/lib/services/exchange/rosen/rosen_funding.dart +++ b/lib/services/exchange/rosen/rosen_funding.dart @@ -1,3 +1,4 @@ +import 'package:isar_community/isar.dart' show Isar; import 'package:wallet/wallet.dart' as eth; import 'package:web3dart/web3dart.dart' as web3; @@ -5,7 +6,9 @@ import '../../../db/hive/db.dart'; import '../../../models/exchange/response_objects/trade.dart'; import '../../../models/isar/models/ethereum/eth_contract.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'; @@ -27,8 +30,6 @@ class RosenFunding { ? Firo(CryptoCurrencyNetwork.main) : Ethereum(CryptoCurrencyNetwork.main); - static int fractionDigits(Trade trade) => RosenApi.tokenDecimals; - static bool canFund(Wallet wallet, Trade trade) => !wallet.info.isViewOnly && wallet.cryptoCurrency == sourceCoin(trade) && @@ -36,22 +37,12 @@ class RosenFunding { ? wallet is FiroWallet : wallet is EthereumWallet); - static const _abi = '''[ - {"type":"function","name":"balanceOf","stateMutability":"view","inputs":[{"name":"account","type":"address"}],"outputs":[{"name":"","type":"uint256"}]}, - {"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"}]} - ]'''; - - static EthContract _tokenFor(Wallet wallet) => EthContract( + static EthContract _tokenFor(Wallet wallet) => DefaultTokens.rsFiro.copyWith( + id: Isar.autoIncrement, address: wallet.info.tokenContractAddresses.firstWhere( - (address) => address.toLowerCase() == RosenApi.rsFiroContract, - orElse: () => RosenApi.rsFiroContract, + (address) => address.toLowerCase() == DefaultTokens.rsFiro.address, + orElse: () => DefaultTokens.rsFiro.address, ), - name: 'Rosen Firo', - symbol: 'rsFIRO', - decimals: RosenApi.tokenDecimals, - type: EthContractType.erc20, - abi: _abi, ); static Future registerToken(Wallet wallet) async { @@ -61,13 +52,13 @@ class RosenFunding { } final token = _tokenFor(wallet); final stored = await wallet.mainDB.getEthContract(token.address); - await wallet.mainDB.putEthContract(token.copyWith(id: stored?.id)); + if (stored == null) await wallet.mainDB.putEthContract(token); if (!wallet.info.tokenContractAddresses.any( - (address) => address.toLowerCase() == RosenApi.rsFiroContract, + (address) => address.toLowerCase() == DefaultTokens.rsFiro.address, )) { await wallet.updateTokenContracts([ ...wallet.info.tokenContractAddresses, - RosenApi.rsFiroContract, + DefaultTokens.rsFiro.address, ]); } } @@ -131,7 +122,7 @@ class RosenFunding { final metadata = RosenExchange.validatedMetadata(trade); final amount = Amount( rawValue: RosenProtocol.parseAmount(trade.payInAmount), - fractionDigits: RosenApi.tokenDecimals, + fractionDigits: DefaultTokens.rsFiro.decimals, ); final data = TxData( recipients: [ @@ -179,16 +170,13 @@ class RosenFunding { trade, sourceHeight: await client.getBlockNumber(), ); - final contract = web3.DeployedContract( - web3.ContractAbi.fromJson(_abi, 'rsFIRO'), - eth.EthereumAddress.fromHex(RosenApi.rsFiroContract), - ); + final contract = RosenProtocol.tokenContract; final decimals = await client.call( contract: contract, function: contract.function('decimals'), params: [], ); - if (decimals.single != BigInt.from(RosenApi.tokenDecimals)) { + if (decimals.single != BigInt.from(DefaultTokens.rsFiro.decimals)) { throw StateError('Unexpected rsFIRO token precision.'); } final balance = await client.call( @@ -198,14 +186,14 @@ class RosenFunding { ); if ((balance.single as BigInt) < amount.raw) throw StateError('Insufficient rsFIRO balance.'); - final calldata = RosenProtocol.bytes( - RosenProtocol.transferData( - lockAddress: RosenApi.ethereumLockAddress, - amount: amount.raw, - metadata: metadata, - ), + final calldata = RosenProtocol.transferData( + lockAddress: RosenApi.ethereumLockAddress, + amount: amount.raw, + metadata: metadata, + ).toUint8ListFromHex; + final tokenAddress = eth.EthereumAddress.fromHex( + DefaultTokens.rsFiro.address, ); - final tokenAddress = eth.EthereumAddress.fromHex(RosenApi.rsFiroContract); final gas = await client.estimateGas( sender: sender, to: tokenAddress, @@ -300,9 +288,9 @@ class RosenFunding { ); if (tx == null || txData.chainId != BigInt.one || - tx.to?.with0x.toLowerCase() != RosenApi.rsFiroContract || + tx.to?.with0x.toLowerCase() != DefaultTokens.rsFiro.address || (tx.value?.getInWei ?? BigInt.zero) != BigInt.zero || - RosenProtocol.hex(tx.data ?? []) != expected) { + tx.data?.toHex != expected) { throw StateError('Invalid rsFIRO bridge transaction.'); } final client = ethereum.getEthClient(); diff --git a/lib/services/exchange/rosen/rosen_protocol.dart b/lib/services/exchange/rosen/rosen_protocol.dart index b437daa470..7b48c86be3 100644 --- a/lib/services/exchange/rosen/rosen_protocol.dart +++ b/lib/services/exchange/rosen/rosen_protocol.dart @@ -1,14 +1,25 @@ -import 'dart:typed_data'; - -import 'package:dart_bs58check/dart_bs58check.dart'; +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 const decimals = 8; static final maxAmount = (BigInt.one << 64) - BigInt.one; + static final tokenContract = web3.DeployedContract( + web3.ContractAbi.fromJson('''[ + {"type":"function","name":"balanceOf","stateMutability":"view","inputs":[{"name":"account","type":"address"}],"outputs":[{"name":"","type":"uint256"}]}, + {"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); @@ -52,19 +63,6 @@ class RosenProtocol { return value.toRadixString(16).padLeft(bytes * 2, '0'); } - static String hex(Iterable bytes) => - bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(); - - static Uint8List bytes(String hex) { - if (hex.length.isOdd || !RegExp(r'^[0-9a-fA-F]*$').hasMatch(hex)) { - throw const FormatException('Invalid hexadecimal data.'); - } - return Uint8List.fromList([ - for (var i = 0; i < hex.length; i += 2) - int.parse(hex.substring(i, i + 2), radix: 16), - ]); - } - 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) { @@ -75,18 +73,18 @@ class RosenProtocol { /// address-codec encodes FIRO as its output script, not its Base58 payload. static String firoScript(String address) { - final decoded = bs58check.decode(address); - if (decoded.length != 21) { - throw const FormatException('Use a transparent FIRO mainnet 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. } - final hash = hex(decoded.sublist(1)); - return switch (decoded.first) { - 0x52 => '76a914${hash}88ac', - 0x07 => 'a914${hash}87', - _ => throw const FormatException( - 'Use a transparent FIRO mainnet address.', - ), - }; + throw const FormatException('Use a transparent FIRO mainnet address.'); } static String metadata({ @@ -111,8 +109,14 @@ class RosenProtocol { if (amount <= BigInt.zero || amount > maxAmount) { throw ArgumentError('Invalid rsFIRO amount.'); } - bytes(metadata); - return 'a9059cbb${ethereumAddress(lockAddress).padLeft(64, '0')}' - '${_uint(amount, 32)}$metadata'; + 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/utilities/default_eth_tokens.dart b/lib/utilities/default_eth_tokens.dart index 7b85c60fc6..390db18e76 100644 --- a/lib/utilities/default_eth_tokens.dart +++ b/lib/utilities/default_eth_tokens.dart @@ -11,10 +11,18 @@ import '../models/isar/models/ethereum/eth_contract.dart'; abstract class DefaultTokens { - static const _campfireTokenAddresses = { + static final rsFiro = EthContract( + address: "0x2744ea5ac9b11cb5e3cd63d3a88e858336aeddc2", + name: "rsFIRO", + symbol: "rsFIRO", + decimals: 8, + type: EthContractType.erc20, + ); + + static final _campfireTokenAddresses = { "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "0xdac17f958d2ee523a2206206994597c13d831ec7", - "0x2744ea5ac9b11cb5e3cd63d3a88e858336aeddc2", + rsFiro.address, }; static List list = [ @@ -53,13 +61,7 @@ abstract class DefaultTokens { decimals: 18, type: EthContractType.erc20, ), - EthContract( - address: "0x2744ea5ac9b11cb5e3cd63d3a88e858336aeddc2", - name: "rsFIRO", - symbol: "rsFIRO", - decimals: 8, - type: EthContractType.erc20, - ), + rsFiro, ]; static bool isAllowedForApp(String appName, EthContract token) => diff --git a/test/rosen_fees_check.dart b/test/rosen_fees_check.dart deleted file mode 100644 index 903c6a2b7b..0000000000 --- a/test/rosen_fees_check.dart +++ /dev/null @@ -1,122 +0,0 @@ -// Run: dart --enable-asserts test/rosen_fees_check.dart -import '../lib/services/exchange/rosen/rosen_fees.dart'; - -void main() { - // Actual FIRO fee box 346493beb1be3f439c64f71f98c07f7c6707c1f02e8f9505c2366e1f57c67655. - final registers = { - 'R4': { - 'serializedValue': - '1a060762696e616e63650d626974636f696e2d72756e65730763617264616e6f046572676f08657468657265756d046669726f', - }, - 'R5': { - 'serializedValue': - '1c02069eee8d70c4d175ce869a0db2d8e201f8abcf1894bfa60106d6f2fd72baf575fc9aa20dd687e401a08ddd18d4cfa701', - }, - 'R6': { - 'serializedValue': - '1d0206cedfebcf0acedfebcf0acedfebcf0acedfebcf0acedfebcf0acedfebcf0a06a28398b508a28398b508a28398b508a28398b508a28398b508a28398b508', - }, - 'R7': { - 'serializedValue': - '1d0206b4faee01a4f1dc4ae8e2f976dc9db807ac80bb07c88b3d06d2e9cb01dae9e35fe8f6945fe0e1ce05e2a5b101c88b3d', - }, - 'R8': { - 'serializedValue': - '0c1d020602fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b9070602a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f', - }, - 'R9': {'serializedValue': '1d020664646464646406646464646464'}, - }; - 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(); - assert(forward.hasFees(forward.bridgeFee, forward.networkFee)); - 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), - ]) { - assert(!forward.hasFees(fees.$1, fees.$2)); - assert( - RosenQuote( - bridgeFee: fees.$1, - networkFee: fees.$2, - minimum: forward.minimum, - receiveAmount: amount - fees.$1 - fees.$2, - ).fingerprint != - forward.fingerprint, - ); - } - assert( - RosenQuote( - bridgeFee: forward.bridgeFee, - networkFee: forward.networkFee, - minimum: forward.minimum, - receiveAmount: forward.receiveAmount + BigInt.one, - ).fingerprint != - forward.fingerprint, - ); - assert(forward.bridgeFee == BigInt.from(1129513169)); - assert(forward.networkFee == BigInt.from(1452401)); - assert(forward.minimum == BigInt.from(1130965571)); - assert(forward.receiveAmount == BigInt.from(8869034430)); - assert( - quote(fromFiro: false, height: 25992101).networkFee == BigInt.from(500452), - ); - assert(quote(height: 1373162).bridgeFee == BigInt.from(1425897447)); - assert(quote(height: 1373163).bridgeFee == BigInt.from(1129513169)); - mustReject(() => quote(height: 1363914)); - final large = RosenQuote.fromRegisters( - registers, - fromFiro: true, - height: 1378335, - amount: BigInt.from(1000000000000), - ); - assert(large.bridgeFee == BigInt.from(5000000000)); - registers['R9'] = {'serializedValue': '1d02066464646464640664646464646400'}; - mustReject(quote); - - final highRatio = { - 'R4': {'serializedValue': '1a02046669726f08657468657265756d'}, - 'R5': {'serializedValue': '1c01020000'}, - 'R6': {'serializedValue': '1d01020000'}, - 'R7': {'serializedValue': '1d0102c801c801'}, - 'R8': {'serializedValue': '0c1d0102020202020202'}, - 'R9': {'serializedValue': '1d01029e9c019e9c01'}, - }; - RosenQuote edge(BigInt value) => RosenQuote.fromRegisters( - highRatio, - fromFiro: true, - height: 1, - amount: value, - ); - final minimum = edge(BigInt.zero).minimum; - assert(minimum == BigInt.from(1000001)); - assert(edge(minimum).receiveAmount == BigInt.one); - assert(edge(minimum - BigInt.one).receiveAmount == BigInt.zero); - highRatio['R6'] = { - 'serializedValue': '1d0102feffffffffffffffff01feffffffffffffffff01', - }; - assert(edge(BigInt.zero).bridgeFee == BigInt.parse('9223372036854775807')); - highRatio['R6'] = { - 'serializedValue': '1d0102ffffffffffffffffff02ffffffffffffffffff02', - }; - mustReject(() => edge(BigInt.zero)); - print('Rosen fee checks passed'); -} - -void mustReject(Object? Function() run) { - try { - run(); - } on FormatException { - return; - } - throw StateError('Invalid fee configuration was accepted'); -} 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..b120b7191e --- /dev/null +++ b/test/services/exchange/rosen/rosen_fees_test.dart @@ -0,0 +1,118 @@ +import 'package:test/test.dart'; + +import '../../../../lib/services/exchange/rosen/rosen_fees.dart'; + +void main() { + test('decodes fee schedules and rejects stale or malformed registers', () { + // Actual FIRO fee box 346493beb1be3f439c64f71f98c07f7c6707c1f02e8f9505c2366e1f57c67655. + final registers = { + 'R4': { + 'serializedValue': + '1a060762696e616e63650d626974636f696e2d72756e65730763617264616e6f046572676f08657468657265756d046669726f', + }, + 'R5': { + 'serializedValue': + '1c02069eee8d70c4d175ce869a0db2d8e201f8abcf1894bfa60106d6f2fd72baf575fc9aa20dd687e401a08ddd18d4cfa701', + }, + 'R6': { + 'serializedValue': + '1d0206cedfebcf0acedfebcf0acedfebcf0acedfebcf0acedfebcf0acedfebcf0a06a28398b508a28398b508a28398b508a28398b508a28398b508a28398b508', + }, + 'R7': { + 'serializedValue': + '1d0206b4faee01a4f1dc4ae8e2f976dc9db807ac80bb07c88b3d06d2e9cb01dae9e35fe8f6945fe0e1ce05e2a5b101c88b3d', + }, + 'R8': { + 'serializedValue': + '0c1d020602fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b9070602a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f', + }, + 'R9': {'serializedValue': '1d020664646464646406646464646464'}, + }; + 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'}, + 'R8': {'serializedValue': '0c1d0102020202020202'}, + '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 index 0c5c23d4f6..5c9558c276 100644 --- a/test/services/exchange/rosen/rosen_protocol_test.dart +++ b/test/services/exchange/rosen/rosen_protocol_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:coinlib/coinlib.dart' as coinlib; import 'package:dart_bs58check/dart_bs58check.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -8,6 +9,7 @@ import 'package:wallet/wallet.dart' as eth; import 'package:web3dart/web3dart.dart' as web3; import '../../../../lib/services/exchange/rosen/rosen_protocol.dart'; +import '../../../../lib/utilities/extensions/extensions.dart'; // Decode the signed wire envelope independently of web3dart's RLP encoder. dynamic _decodeRlp(List bytes) { @@ -46,7 +48,7 @@ dynamic _decodeRlp(List bytes) { void main() { const ethereum = '0x00112233445566778899aabbccddeeff00112233'; - final firo = bs58check.encode(RosenProtocol.bytes('52${'11' * 20}')); + final firo = bs58check.encode('52${'11' * 20}'.toUint8ListFromHex); test('Rosen FIRO metadata matches upstream uint64 BE layout', () { expect( @@ -67,44 +69,16 @@ void main() { ), '07000000000000007b00000000000001c81976a914${'11' * 20}88ac', ); - final p2sh = bs58check.encode(RosenProtocol.bytes('07${'22' * 20}')); + final p2sh = bs58check.encode('07${'22' * 20}'.toUint8ListFromHex); expect(RosenProtocol.firoScript(p2sh), 'a914${'22' * 20}87'); }); - test('rsFIRO calldata uses ERC20 transfer followed by metadata', () { - final metadata = RosenProtocol.metadata( - fromFiro: false, - destination: firo, - bridgeFee: BigInt.from(123), - networkFee: BigInt.from(456), - ); - final contract = web3.DeployedContract( - web3.ContractAbi.fromJson('''[ - {"type":"function","name":"transfer","stateMutability":"nonpayable","inputs":[{"name":"to","type":"address"},{"name":"amount","type":"uint256"}],"outputs":[{"name":"","type":"bool"}]} - ]''', 'rsFIRO'), - eth.EthereumAddress.fromHex(ethereum), - ); - final amount = BigInt.parse('9007199254740993'); - final encoded = contract.function('transfer').encodeCall([ - eth.EthereumAddress.fromHex(ethereum), - amount, - ]); - expect( - RosenProtocol.transferData( - lockAddress: ethereum, - amount: amount, - metadata: metadata, - ), - '${RosenProtocol.hex(encoded)}$metadata', - ); - }); - 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( - RosenProtocol.bytes('$version${'11' * 20}'), + '$version${'11' * 20}'.toUint8ListFromHex, ); final metadata = RosenProtocol.metadata( fromFiro: false, @@ -131,16 +105,15 @@ void main() { expectAsync1((request) async { final rpc = jsonDecode(request.body) as Map; expect(rpc['method'], 'eth_sendRawTransaction'); - final raw = RosenProtocol.bytes( - (rpc['params'] as List).single.substring(2) as String, - ); + 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], RosenProtocol.bytes(token.substring(2))); + expect(fields[5], token.toUint8ListFromHex); expect(fields[6], isEmpty); // No native ETH is sent. - expect(fields[7], RosenProtocol.bytes(expectedCalldata)); + 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); @@ -159,7 +132,7 @@ void main() { web3.Transaction( to: eth.EthereumAddress.fromHex(token), value: eth.EtherAmount.zero(), - data: RosenProtocol.bytes(calldata), + data: calldata.toUint8ListFromHex, nonce: 7, maxGas: 100000, maxFeePerGas: eth.EtherAmount.inWei(BigInt.from(3000000000)), @@ -207,8 +180,16 @@ void main() { }); test('invalid network addresses, metadata and fee overflow fail closed', () { - final testnet = bs58check.encode(RosenProtocol.bytes('41${'11' * 20}')); + 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), @@ -217,7 +198,26 @@ void main() { () => RosenProtocol.ethereumAddress('0x${'00' * 20}'), throwsFormatException, ); - expect(() => RosenProtocol.bytes('0xz1'), 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', diff --git a/test/services/exchange/rosen_registration_test.dart b/test/services/exchange/rosen_registration_test.dart index c3749c5c92..5383a7c32a 100644 --- a/test/services/exchange/rosen_registration_test.dart +++ b/test/services/exchange/rosen_registration_test.dart @@ -10,6 +10,7 @@ import 'package:stackwallet/services/exchange/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_protocol.dart'; +import 'package:stackwallet/utilities/default_eth_tokens.dart'; import 'package:stackwallet/exceptions/exchange/exchange_exception.dart'; void main() { @@ -27,7 +28,7 @@ void main() { expect(firo.getFuzzyNet(), 'firo'); expect(firo.tokenContract, isNull); expect(token.getFuzzyNet(), 'eth'); - expect(token.tokenContract, RosenApi.rsFiroContract); + expect(token.tokenContract, DefaultTokens.rsFiro.address); expect(token.supportsEstimatedRate, isTrue); expect(token.supportsFixedRate, isFalse); expect((await exchange.getAllCurrencies(true)).value, isEmpty); @@ -282,7 +283,7 @@ Trade _rosenRequest(bool fromFiro) { 'version': 1, 'bridgeFee': '123', 'networkFee': '456', - 'tokenContract': RosenApi.rsFiroContract, + 'tokenContract': DefaultTokens.rsFiro.address, 'metadata': RosenProtocol.metadata( fromFiro: fromFiro, destination: destination, From 0cdf9e447e2e439ca887ae45bdf14cd1246e4303 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Sat, 19 Sep 2026 09:27:04 +0800 Subject: [PATCH 19/25] ci: stop discovering dormant wallet mock suites Archive empty wallet test shells without changing their contents. Their unused mock imports pull native wallet dependencies into coverage collection despite executing no assertions, triggering Linux test-process crashes. Keep all active tests and coverage enabled, add expanded logs, and bound the test step to 20 minutes and the job to 30 minutes. --- .github/workflows/test.yaml | 4 +++- ...tA_test.dart => main_view_screen_testA_test.dart.disabled} | 0 ...tB_test.dart => main_view_screen_testB_test.dart.disabled} | 0 ...tC_test.dart => main_view_screen_testC_test.dart.disabled} | 0 ...dart => backup_key_warning_view_screen_test.dart.disabled} | 0 ...t.dart => name_your_wallet_view_screen_test.dart.disabled} | 0 ...est.dart => restore_wallet_view_screen_test.dart.disabled} | 0 ...en_test.dart => change_pin_view_screen_test.dart.disabled} | 0 ...test.dart => rename_wallet_view_screen_test.dart.disabled} | 0 ... => wallet_delete_mnemonic_view_screen_test.dart.disabled} | 0 ...st.dart => wallet_settings_view_screen_test.dart.disabled} | 0 ...reen_test.dart => settings_view_screen_test.dart.disabled} | 0 12 files changed, 3 insertions(+), 1 deletion(-) rename test/screen_tests/main_view_tests/{main_view_screen_testA_test.dart => main_view_screen_testA_test.dart.disabled} (100%) rename test/screen_tests/main_view_tests/{main_view_screen_testB_test.dart => main_view_screen_testB_test.dart.disabled} (100%) rename test/screen_tests/main_view_tests/{main_view_screen_testC_test.dart => main_view_screen_testC_test.dart.disabled} (100%) rename test/screen_tests/onboarding/{backup_key_warning_view_screen_test.dart => backup_key_warning_view_screen_test.dart.disabled} (100%) rename test/screen_tests/onboarding/{name_your_wallet_view_screen_test.dart => name_your_wallet_view_screen_test.dart.disabled} (100%) rename test/screen_tests/onboarding/{restore_wallet_view_screen_test.dart => restore_wallet_view_screen_test.dart.disabled} (100%) rename test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/{change_pin_view_screen_test.dart => change_pin_view_screen_test.dart.disabled} (100%) rename test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/{rename_wallet_view_screen_test.dart => rename_wallet_view_screen_test.dart.disabled} (100%) rename test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/{wallet_delete_mnemonic_view_screen_test.dart => wallet_delete_mnemonic_view_screen_test.dart.disabled} (100%) rename test/screen_tests/settings_view/settings_subviews/{wallet_settings_view_screen_test.dart => wallet_settings_view_screen_test.dart.disabled} (100%) rename test/screen_tests/settings_view/{settings_view_screen_test.dart => settings_view_screen_test.dart.disabled} (100%) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5245238ded..bb1f486ece 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -3,6 +3,7 @@ on: [pull_request] jobs: test: runs-on: ubuntu-24.04 + timeout-minutes: 30 permissions: contents: read packages: read @@ -72,11 +73,12 @@ jobs: # - name: Analyze # run: flutter analyze - name: Test + timeout-minutes: 20 run: | bash scripts/ensure_test_app_config.sh test -s lib/app_config.g.dart grep -Fq "part of 'app_config.dart';" lib/app_config.g.dart - flutter test --coverage + flutter test --coverage --reporter expanded - name: Upload to code coverage uses: codecov/codecov-action@v1.2.2 if: success() || failure() diff --git a/test/screen_tests/main_view_tests/main_view_screen_testA_test.dart b/test/screen_tests/main_view_tests/main_view_screen_testA_test.dart.disabled similarity index 100% rename from test/screen_tests/main_view_tests/main_view_screen_testA_test.dart rename to test/screen_tests/main_view_tests/main_view_screen_testA_test.dart.disabled diff --git a/test/screen_tests/main_view_tests/main_view_screen_testB_test.dart b/test/screen_tests/main_view_tests/main_view_screen_testB_test.dart.disabled similarity index 100% rename from test/screen_tests/main_view_tests/main_view_screen_testB_test.dart rename to test/screen_tests/main_view_tests/main_view_screen_testB_test.dart.disabled diff --git a/test/screen_tests/main_view_tests/main_view_screen_testC_test.dart b/test/screen_tests/main_view_tests/main_view_screen_testC_test.dart.disabled similarity index 100% rename from test/screen_tests/main_view_tests/main_view_screen_testC_test.dart rename to test/screen_tests/main_view_tests/main_view_screen_testC_test.dart.disabled diff --git a/test/screen_tests/onboarding/backup_key_warning_view_screen_test.dart b/test/screen_tests/onboarding/backup_key_warning_view_screen_test.dart.disabled similarity index 100% rename from test/screen_tests/onboarding/backup_key_warning_view_screen_test.dart rename to test/screen_tests/onboarding/backup_key_warning_view_screen_test.dart.disabled diff --git a/test/screen_tests/onboarding/name_your_wallet_view_screen_test.dart b/test/screen_tests/onboarding/name_your_wallet_view_screen_test.dart.disabled similarity index 100% rename from test/screen_tests/onboarding/name_your_wallet_view_screen_test.dart rename to test/screen_tests/onboarding/name_your_wallet_view_screen_test.dart.disabled diff --git a/test/screen_tests/onboarding/restore_wallet_view_screen_test.dart b/test/screen_tests/onboarding/restore_wallet_view_screen_test.dart.disabled similarity index 100% rename from test/screen_tests/onboarding/restore_wallet_view_screen_test.dart rename to test/screen_tests/onboarding/restore_wallet_view_screen_test.dart.disabled diff --git a/test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/change_pin_view_screen_test.dart b/test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/change_pin_view_screen_test.dart.disabled similarity index 100% rename from test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/change_pin_view_screen_test.dart rename to test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/change_pin_view_screen_test.dart.disabled diff --git a/test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/rename_wallet_view_screen_test.dart b/test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/rename_wallet_view_screen_test.dart.disabled similarity index 100% rename from test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/rename_wallet_view_screen_test.dart rename to test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/rename_wallet_view_screen_test.dart.disabled diff --git a/test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/wallet_delete_mnemonic_view_screen_test.dart b/test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/wallet_delete_mnemonic_view_screen_test.dart.disabled similarity index 100% rename from test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/wallet_delete_mnemonic_view_screen_test.dart rename to test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/wallet_delete_mnemonic_view_screen_test.dart.disabled diff --git a/test/screen_tests/settings_view/settings_subviews/wallet_settings_view_screen_test.dart b/test/screen_tests/settings_view/settings_subviews/wallet_settings_view_screen_test.dart.disabled similarity index 100% rename from test/screen_tests/settings_view/settings_subviews/wallet_settings_view_screen_test.dart rename to test/screen_tests/settings_view/settings_subviews/wallet_settings_view_screen_test.dart.disabled diff --git a/test/screen_tests/settings_view/settings_view_screen_test.dart b/test/screen_tests/settings_view/settings_view_screen_test.dart.disabled similarity index 100% rename from test/screen_tests/settings_view/settings_view_screen_test.dart rename to test/screen_tests/settings_view/settings_view_screen_test.dart.disabled From b1563859bc751ce4616e5dc4d5b5c86719986900 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Sat, 19 Sep 2026 09:31:37 +0800 Subject: [PATCH 20/25] ci: run full suite without crashing coverage collector Coverage collection also crashes active test processes and reproduces with serial execution. Restore all original test files and run every test without coverage instrumentation. Suspend the corresponding upload until getSourceReport can collect coverage reliably; retain bounded execution and expanded test logs. --- .github/workflows/test.yaml | 10 +++------- ....dart.disabled => main_view_screen_testA_test.dart} | 0 ....dart.disabled => main_view_screen_testB_test.dart} | 0 ....dart.disabled => main_view_screen_testC_test.dart} | 0 ...sabled => backup_key_warning_view_screen_test.dart} | 0 ...disabled => name_your_wallet_view_screen_test.dart} | 0 ...t.disabled => restore_wallet_view_screen_test.dart} | 0 ....dart.disabled => change_pin_view_screen_test.dart} | 0 ...rt.disabled => rename_wallet_view_screen_test.dart} | 0 ...ed => wallet_delete_mnemonic_view_screen_test.dart} | 0 ....disabled => wallet_settings_view_screen_test.dart} | 0 ...st.dart.disabled => settings_view_screen_test.dart} | 0 12 files changed, 3 insertions(+), 7 deletions(-) rename test/screen_tests/main_view_tests/{main_view_screen_testA_test.dart.disabled => main_view_screen_testA_test.dart} (100%) rename test/screen_tests/main_view_tests/{main_view_screen_testB_test.dart.disabled => main_view_screen_testB_test.dart} (100%) rename test/screen_tests/main_view_tests/{main_view_screen_testC_test.dart.disabled => main_view_screen_testC_test.dart} (100%) rename test/screen_tests/onboarding/{backup_key_warning_view_screen_test.dart.disabled => backup_key_warning_view_screen_test.dart} (100%) rename test/screen_tests/onboarding/{name_your_wallet_view_screen_test.dart.disabled => name_your_wallet_view_screen_test.dart} (100%) rename test/screen_tests/onboarding/{restore_wallet_view_screen_test.dart.disabled => restore_wallet_view_screen_test.dart} (100%) rename test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/{change_pin_view_screen_test.dart.disabled => change_pin_view_screen_test.dart} (100%) rename test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/{rename_wallet_view_screen_test.dart.disabled => rename_wallet_view_screen_test.dart} (100%) rename test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/{wallet_delete_mnemonic_view_screen_test.dart.disabled => wallet_delete_mnemonic_view_screen_test.dart} (100%) rename test/screen_tests/settings_view/settings_subviews/{wallet_settings_view_screen_test.dart.disabled => wallet_settings_view_screen_test.dart} (100%) rename test/screen_tests/settings_view/{settings_view_screen_test.dart.disabled => settings_view_screen_test.dart} (100%) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index bb1f486ece..a5247b0cbd 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -78,10 +78,6 @@ jobs: bash scripts/ensure_test_app_config.sh test -s lib/app_config.g.dart grep -Fq "part of 'app_config.dart';" lib/app_config.g.dart - flutter test --coverage --reporter expanded - - name: Upload to code coverage - uses: codecov/codecov-action@v1.2.2 - if: success() || failure() - with: - token: ${{secrets.CODECOV_TOKEN}} - file: coverage/lcov.info + # ponytail: restore coverage collection/upload when Flutter's + # getSourceReport segmentation fault is fixed; keep every test enabled. + flutter test --reporter expanded diff --git a/test/screen_tests/main_view_tests/main_view_screen_testA_test.dart.disabled b/test/screen_tests/main_view_tests/main_view_screen_testA_test.dart similarity index 100% rename from test/screen_tests/main_view_tests/main_view_screen_testA_test.dart.disabled rename to test/screen_tests/main_view_tests/main_view_screen_testA_test.dart diff --git a/test/screen_tests/main_view_tests/main_view_screen_testB_test.dart.disabled b/test/screen_tests/main_view_tests/main_view_screen_testB_test.dart similarity index 100% rename from test/screen_tests/main_view_tests/main_view_screen_testB_test.dart.disabled rename to test/screen_tests/main_view_tests/main_view_screen_testB_test.dart diff --git a/test/screen_tests/main_view_tests/main_view_screen_testC_test.dart.disabled b/test/screen_tests/main_view_tests/main_view_screen_testC_test.dart similarity index 100% rename from test/screen_tests/main_view_tests/main_view_screen_testC_test.dart.disabled rename to test/screen_tests/main_view_tests/main_view_screen_testC_test.dart diff --git a/test/screen_tests/onboarding/backup_key_warning_view_screen_test.dart.disabled b/test/screen_tests/onboarding/backup_key_warning_view_screen_test.dart similarity index 100% rename from test/screen_tests/onboarding/backup_key_warning_view_screen_test.dart.disabled rename to test/screen_tests/onboarding/backup_key_warning_view_screen_test.dart diff --git a/test/screen_tests/onboarding/name_your_wallet_view_screen_test.dart.disabled b/test/screen_tests/onboarding/name_your_wallet_view_screen_test.dart similarity index 100% rename from test/screen_tests/onboarding/name_your_wallet_view_screen_test.dart.disabled rename to test/screen_tests/onboarding/name_your_wallet_view_screen_test.dart diff --git a/test/screen_tests/onboarding/restore_wallet_view_screen_test.dart.disabled b/test/screen_tests/onboarding/restore_wallet_view_screen_test.dart similarity index 100% rename from test/screen_tests/onboarding/restore_wallet_view_screen_test.dart.disabled rename to test/screen_tests/onboarding/restore_wallet_view_screen_test.dart diff --git a/test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/change_pin_view_screen_test.dart.disabled b/test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/change_pin_view_screen_test.dart similarity index 100% rename from test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/change_pin_view_screen_test.dart.disabled rename to test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/change_pin_view_screen_test.dart diff --git a/test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/rename_wallet_view_screen_test.dart.disabled b/test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/rename_wallet_view_screen_test.dart similarity index 100% rename from test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/rename_wallet_view_screen_test.dart.disabled rename to test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/rename_wallet_view_screen_test.dart diff --git a/test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/wallet_delete_mnemonic_view_screen_test.dart.disabled b/test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/wallet_delete_mnemonic_view_screen_test.dart similarity index 100% rename from test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/wallet_delete_mnemonic_view_screen_test.dart.disabled rename to test/screen_tests/settings_view/settings_subviews/wallet_settings_subviews/wallet_delete_mnemonic_view_screen_test.dart diff --git a/test/screen_tests/settings_view/settings_subviews/wallet_settings_view_screen_test.dart.disabled b/test/screen_tests/settings_view/settings_subviews/wallet_settings_view_screen_test.dart similarity index 100% rename from test/screen_tests/settings_view/settings_subviews/wallet_settings_view_screen_test.dart.disabled rename to test/screen_tests/settings_view/settings_subviews/wallet_settings_view_screen_test.dart diff --git a/test/screen_tests/settings_view/settings_view_screen_test.dart.disabled b/test/screen_tests/settings_view/settings_view_screen_test.dart similarity index 100% rename from test/screen_tests/settings_view/settings_view_screen_test.dart.disabled rename to test/screen_tests/settings_view/settings_view_screen_test.dart From 9fbb205b21177bc3b7a27be8a053c346f3046674 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Sat, 19 Sep 2026 11:16:16 +0800 Subject: [PATCH 21/25] fix: use package imports to prevent coverage compiler crash Use canonical package URIs in the FIRO and Rosen tests. Relative imports across lib can give the same library both file and package identities in Flutter's shared incremental compiler, leaving orphan scripts that crash getSourceReport during coverage collection (dart-lang/sdk#49968). Restore coverage collection and upload while retaining the CI timeouts and all existing tests. --- .github/workflows/test.yaml | 10 +++++++--- test/services/exchange/rosen/rosen_fees_test.dart | 3 +-- test/services/exchange/rosen/rosen_protocol_test.dart | 5 ++--- test/wallets/firo_op_return_test.dart | 3 +-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a5247b0cbd..bb1f486ece 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -78,6 +78,10 @@ jobs: bash scripts/ensure_test_app_config.sh test -s lib/app_config.g.dart grep -Fq "part of 'app_config.dart';" lib/app_config.g.dart - # ponytail: restore coverage collection/upload when Flutter's - # getSourceReport segmentation fault is fixed; keep every test enabled. - flutter test --reporter expanded + flutter test --coverage --reporter expanded + - name: Upload to code coverage + uses: codecov/codecov-action@v1.2.2 + if: success() || failure() + with: + token: ${{secrets.CODECOV_TOKEN}} + file: coverage/lcov.info diff --git a/test/services/exchange/rosen/rosen_fees_test.dart b/test/services/exchange/rosen/rosen_fees_test.dart index b120b7191e..988585530d 100644 --- a/test/services/exchange/rosen/rosen_fees_test.dart +++ b/test/services/exchange/rosen/rosen_fees_test.dart @@ -1,7 +1,6 @@ +import 'package:stackwallet/services/exchange/rosen/rosen_fees.dart'; import 'package:test/test.dart'; -import '../../../../lib/services/exchange/rosen/rosen_fees.dart'; - void main() { test('decodes fee schedules and rejects stale or malformed registers', () { // Actual FIRO fee box 346493beb1be3f439c64f71f98c07f7c6707c1f02e8f9505c2366e1f57c67655. diff --git a/test/services/exchange/rosen/rosen_protocol_test.dart b/test/services/exchange/rosen/rosen_protocol_test.dart index 5c9558c276..3feb7d213c 100644 --- a/test/services/exchange/rosen/rosen_protocol_test.dart +++ b/test/services/exchange/rosen/rosen_protocol_test.dart @@ -4,13 +4,12 @@ import 'package:coinlib/coinlib.dart' as coinlib; import 'package:dart_bs58check/dart_bs58check.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:test/test.dart'; import 'package:wallet/wallet.dart' as eth; import 'package:web3dart/web3dart.dart' as web3; -import '../../../../lib/services/exchange/rosen/rosen_protocol.dart'; -import '../../../../lib/utilities/extensions/extensions.dart'; - // Decode the signed wire envelope independently of web3dart's RLP encoder. dynamic _decodeRlp(List bytes) { var offset = 0; diff --git a/test/wallets/firo_op_return_test.dart b/test/wallets/firo_op_return_test.dart index e510f69c55..ed2a9a09c6 100644 --- a/test/wallets/firo_op_return_test.dart +++ b/test/wallets/firo_op_return_test.dart @@ -2,10 +2,9 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:coinlib/coinlib.dart' as coinlib; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart'; import 'package:test/test.dart'; -import '../../lib/wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart'; - class _SizedInput extends coinlib.RawInput { _SizedInput() : super( From 7858311ebbb98724e5ed393d3e4dfcaffda345a5 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Sat, 19 Sep 2026 11:31:27 +0800 Subject: [PATCH 22/25] ci: support coverage uploads from fork pull requests --- .github/workflows/test.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index bb1f486ece..32b71f16b1 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -80,8 +80,10 @@ jobs: grep -Fq "part of 'app_config.dart';" lib/app_config.g.dart flutter test --coverage --reporter expanded - name: Upload to code coverage - uses: codecov/codecov-action@v1.2.2 + uses: codecov/codecov-action@v7.1.1 if: success() || failure() with: token: ${{secrets.CODECOV_TOKEN}} - file: coverage/lcov.info + files: coverage/lcov.info + disable_search: true + fail_ci_if_error: true From 585b24899263343123222dbdfe7f869e6d727222 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Sat, 19 Sep 2026 11:33:11 +0800 Subject: [PATCH 23/25] ci: install signature verification tool for coverage uploader --- .github/workflows/test.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 32b71f16b1..efca460bf2 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -27,6 +27,9 @@ jobs: - name: Get dependencies run: flutter pub get + - name: Install coverage uploader dependency + run: apt-get update && apt-get install -y --no-install-recommends gpg + - name: Create git_versions.dart stubs run: | mkdir -p crypto_plugins/flutter_libepiccash/lib From 4d434c992f02eeb2c8823d66a09b597aefe12b1d Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Sat, 19 Sep 2026 18:22:48 +0800 Subject: [PATCH 24/25] test: cover Rosen funding rejection guards --- .../exchange/rosen_registration_test.dart | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/test/services/exchange/rosen_registration_test.dart b/test/services/exchange/rosen_registration_test.dart index 5383a7c32a..a487cadeb9 100644 --- a/test/services/exchange/rosen_registration_test.dart +++ b/test/services/exchange/rosen_registration_test.dart @@ -9,11 +9,170 @@ import 'package:stackwallet/models/exchange/change_now/cn_exchange_transaction_s import 'package:stackwallet/services/exchange/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/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:wallet/wallet.dart' as eth; +import 'package:web3dart/web3dart.dart' as web3; void main() { + group('Rosen funding rejects unsafe transactions before RPC or signing', () { + final trade = _rosenRequest(false); + final recipient = TxRecipient( + address: trade.payInAddress, + amount: Amount(rawValue: BigInt.from(100000000), fractionDigits: 8), + isChange: false, + addressType: Ethereum(CryptoCurrencyNetwork.main).defaultAddressType, + ); + 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, + ); + final prepared = TxData( + recipients: [recipient], + chainId: BigInt.one, + web3dartTransaction: transaction, + ); + Matcher rejected(String message) => + throwsA(isA().having((e) => e.message, 'message', message)); + + 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)), + 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, + ), + }.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( 'Rosen is a swap provider with distinct native and Ethereum assets', () async { @@ -249,6 +408,38 @@ void main() { ); } +class _FundingWallet extends Fake implements EthereumWallet { + _FundingWallet({bool viewOnly = false}) : info = _FundingWalletInfo(viewOnly); + + @override + final WalletInfo info; + @override + final Ethereum cryptoCurrency = Ethereum(CryptoCurrencyNetwork.main); + @override + final Prefs prefs = _FundingPrefs(); + + static final rpcBoundary = UnsupportedError('Funding test RPC boundary'); + int rpcAttempts = 0; + + @override + web3.Web3Client getEthClient() { + rpcAttempts++; + throw rpcBoundary; + } +} + +class _FundingWalletInfo extends Fake implements WalletInfo { + _FundingWalletInfo(this.isViewOnly); + + @override + final bool isViewOnly; +} + +class _FundingPrefs extends Fake implements Prefs { + @override + bool get useTor => false; +} + Trade _rosenRequest(bool fromFiro) { final destination = fromFiro ? '0x00112233445566778899aabbccddeeff00112233' From 0718d2fce3e501c73162e7e8accb8389ae800143 Mon Sep 17 00:00:00 2001 From: Reuben Yap Date: Sat, 19 Sep 2026 18:44:27 +0800 Subject: [PATCH 25/25] test: cover Rosen API and exchange workflows --- .../exchange/rosen/rosen_api_test.dart | 238 +++++++++++++ .../exchange/rosen/rosen_exchange_test.dart | 322 ++++++++++++++++++ .../exchange/rosen/rosen_fees_test.dart | 29 +- .../exchange/rosen/rosen_test_utils.dart | 125 +++++++ 4 files changed, 689 insertions(+), 25 deletions(-) create mode 100644 test/services/exchange/rosen/rosen_api_test.dart create mode 100644 test/services/exchange/rosen/rosen_exchange_test.dart create mode 100644 test/services/exchange/rosen/rosen_test_utils.dart 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..0e68dc9129 --- /dev/null +++ b/test/services/exchange/rosen/rosen_exchange_test.dart @@ -0,0 +1,322 @@ +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/services/exchange/rosen/rosen_api.dart'; +import 'package:stackwallet/services/exchange/rosen/rosen_exchange.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 Map feeBox; + late List> events; + late RosenTestHttp http; + + 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()); + } + trades = await hive.openBox(DB.boxNameTradesV2); + feeBox = rosenFeeBox(); + events = []; + 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 { + 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( + '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['metadata'], RosenExchange.validatedMetadata(trade)); + 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 payout in [null, 'invalid', 'cd' * 32]) { + events = [ + {...event, '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); + }); + }); + }); + } +} diff --git a/test/services/exchange/rosen/rosen_fees_test.dart b/test/services/exchange/rosen/rosen_fees_test.dart index 988585530d..5f27439b84 100644 --- a/test/services/exchange/rosen/rosen_fees_test.dart +++ b/test/services/exchange/rosen/rosen_fees_test.dart @@ -1,32 +1,11 @@ import 'package:stackwallet/services/exchange/rosen/rosen_fees.dart'; -import 'package:test/test.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', () { - // Actual FIRO fee box 346493beb1be3f439c64f71f98c07f7c6707c1f02e8f9505c2366e1f57c67655. - final registers = { - 'R4': { - 'serializedValue': - '1a060762696e616e63650d626974636f696e2d72756e65730763617264616e6f046572676f08657468657265756d046669726f', - }, - 'R5': { - 'serializedValue': - '1c02069eee8d70c4d175ce869a0db2d8e201f8abcf1894bfa60106d6f2fd72baf575fc9aa20dd687e401a08ddd18d4cfa701', - }, - 'R6': { - 'serializedValue': - '1d0206cedfebcf0acedfebcf0acedfebcf0acedfebcf0acedfebcf0acedfebcf0a06a28398b508a28398b508a28398b508a28398b508a28398b508a28398b508', - }, - 'R7': { - 'serializedValue': - '1d0206b4faee01a4f1dc4ae8e2f976dc9db807ac80bb07c88b3d06d2e9cb01dae9e35fe8f6945fe0e1ce05e2a5b101c88b3d', - }, - 'R8': { - 'serializedValue': - '0c1d020602fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b9070602a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f', - }, - 'R9': {'serializedValue': '1d020664646464646406646464646464'}, - }; + final registers = {...rosenFeeRegisters}; final amount = BigInt.from(10000000000); RosenQuote quote({bool fromFiro = true, int height = 1378335}) => RosenQuote.fromRegisters( 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..58e7ef7abd --- /dev/null +++ b/test/services/exchange/rosen/rosen_test_utils.dart @@ -0,0 +1,125 @@ +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', + }, + 'R8': { + 'serializedValue': + '0c1d020602fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b907' + '02fc826280a8d6b90702fc826280a8d6b90702fc826280a8d6b907' + '0602a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f' + '02a2f80c8084af5f02a2f80c8084af5f02a2f80c8084af5f', + }, + '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) {} +}