diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index eca0145f8b..5245238ded 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -45,18 +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 = ""; - EOF fi - name: Ensure app config for tests diff --git a/docs/rosen_bridge.md b/docs/rosen_bridge.md new file mode 100644 index 0000000000..6b2a040392 --- /dev/null +++ b/docs/rosen_bridge.md @@ -0,0 +1,55 @@ +# 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 + +After completing the repository's [build setup](building.md), including generated configuration, dependencies and required native libraries: + +```sh +bash scripts/ensure_test_app_config.sh +flutter test test/services/exchange/rosen/ test/wallets/firo_op_return_test.dart test/services/exchange/rosen_registration_test.dart +``` diff --git a/lib/exceptions/exchange/exchange_exception.dart b/lib/exceptions/exchange/exchange_exception.dart index 952170d721..cc4450b375 100644 --- a/lib/exceptions/exchange/exchange_exception.dart +++ b/lib/exceptions/exchange/exchange_exception.dart @@ -10,7 +10,12 @@ import '../sw_exception.dart'; -enum ExchangeExceptionType { generic, serializeResponseError, orderNotFound } +enum ExchangeExceptionType { + generic, + serializeResponseError, + orderNotFound, + quoteChanged, +} class ExchangeException extends SWException { ExchangeExceptionType type; diff --git a/lib/models/isar/exchange_cache/currency.dart b/lib/models/isar/exchange_cache/currency.dart index 414deff9e3..28b265bf71 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'; @@ -87,6 +88,7 @@ class Currency { 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/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..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 @@ -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, @@ -100,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); @@ -177,7 +178,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), + ); } }); } @@ -199,9 +202,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, ); @@ -228,9 +229,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)); } }); @@ -244,6 +243,14 @@ 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 +273,36 @@ 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 +332,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 +424,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 +541,33 @@ 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 +640,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..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 @@ -103,12 +103,11 @@ 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( @@ -137,12 +136,11 @@ 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( @@ -176,19 +174,32 @@ 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(); - 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))); } @@ -277,57 +288,58 @@ class _AddWalletViewState extends ConsumerState { 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) + 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, - ), + ), ), ), ), @@ -358,14 +370,19 @@ 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( title: "Solana tokens", - entities: filter(_searchTerm, solTokenEntities), + entities: filter( + _searchTerm, + solTokenEntities, + ), initialState: ExpandableState.expanded, animationDurationMultiplier: 0.5, trailing: AddCustomTokenSelector( @@ -395,8 +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: () { @@ -428,49 +446,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, - ), + ), ), ), ), 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..da401f4455 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,100 @@ 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..447c17a5bd 100644 --- a/lib/pages/exchange_view/confirm_change_now_send.dart +++ b/lib/pages/exchange_view/confirm_change_now_send.dart @@ -14,13 +14,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; +import '../../exceptions/exchange/exchange_exception.dart'; import '../../models/exchange/response_objects/trade.dart'; import '../../models/isar/models/isar_models.dart'; import '../../models/trade_wallet_lookup.dart'; import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; +import '../../providers/global/trades_service_provider.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; +import '../../services/exchange/rosen/rosen_exchange.dart'; +import '../../services/exchange/rosen/rosen_funding.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; @@ -44,6 +48,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 +82,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 +149,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 +171,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 +223,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 +233,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()! @@ -206,7 +276,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)); @@ -344,7 +425,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), ), ], @@ -417,13 +498,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 +526,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 +557,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), @@ -495,6 +571,37 @@ class _ConfirmChangeNowSendViewState height: 1, ) : const SizedBox(height: 12), + if (_isRosen) ...[ + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Estimated receive", + style: STextStyles.smallMed12(context), + ), + const SizedBox(height: 4), + Text( + "${trade.payOutAmount} ${trade.payOutCurrency}", + style: STextStyles.itemSubtitle12(context), + ), + const SizedBox(height: 8), + Text( + trade.payOutAddress, + style: STextStyles.itemSubtitle12(context), + ), + const SizedBox(height: 8), + Text( + _quoteChanged + ? "This quote has changed. Refresh it before sending." + : "Bridge fees are checked again when you send. Any change requires a new quote and your confirmation.", + style: STextStyles.smallMed12(context), + ), + ], + ), + ), + const SizedBox(height: 12), + ], RoundedWhiteContainer( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -560,7 +667,7 @@ class _ConfirmChangeNowSendViewState ), ); final String extra; - if (price == null) { + if (price == null || _isRosenToken) { extra = ""; } else { final amountWithoutChange = @@ -599,14 +706,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, ), @@ -703,15 +814,8 @@ class _ConfirmChangeNowSendViewState ), Builder( builder: (context) { - final coin = ref.watch(pWalletCoin(walletId)); - final fee = widget.txData.fee!; - final amount = - widget.txData.amountWithoutChange ?? - widget.txData.amountSparkWithoutChange!; - final total = amount + fee; - return Text( - ref.watch(pAmountFormatter(coin)).format(total), + _totalAmount(), style: STextStyles.itemSubtitle12(context).copyWith( color: Theme.of( context, @@ -728,7 +832,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..f34ca54fcd 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, ]; } } @@ -344,6 +346,9 @@ class _ExchangeFormState extends ConsumerState { _sendFocusNode.unfocus(); ref.read(efRateTypeProvider.notifier).state = newType; + if (newType == ExchangeRateType.estimated) { + ref.read(efReversedProvider.notifier).state = false; + } update(); } @@ -761,7 +766,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; 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..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 @@ -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 = 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..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 @@ -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( + 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..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 @@ -19,12 +19,15 @@ import '../../../app_config.dart'; import '../../../models/exchange/incomplete_exchange.dart'; import '../../../providers/providers.dart'; import '../../../route_generator.dart'; +import '../../../services/exchange/rosen/rosen_exchange.dart'; +import '../../../services/exchange/rosen/rosen_funding.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/amount/amount_formatter.dart'; import '../../../utilities/assets.dart'; import '../../../utilities/clipboard_interface.dart'; import '../../../utilities/constants.dart'; +import '../../../utilities/default_eth_tokens.dart'; import '../../../utilities/enums/fee_rate_type_enum.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/text_styles.dart'; @@ -72,6 +75,7 @@ class _Step4ViewState extends ConsumerState { late final ClipboardInterface clipboard; String _statusString = "New"; + bool get _isRosen => model.trade!.exchangeName == RosenExchange.exchangeName; Timer? _statusTimer; @@ -153,12 +157,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(); @@ -440,19 +451,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 +483,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 +536,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 +575,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( @@ -599,7 +632,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 +654,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 + ? DefaultTokens.rsFiro.decimals + : coin.fractionDigits, ), address: model.trade!.payInAddress, trade: model.trade!, diff --git a/lib/pages/exchange_view/rosen_quote_dialog.dart b/lib/pages/exchange_view/rosen_quote_dialog.dart new file mode 100644 index 0000000000..eb4ef9ebc6 --- /dev/null +++ b/lib/pages/exchange_view/rosen_quote_dialog.dart @@ -0,0 +1,70 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; + +import '../../models/exchange/incomplete_exchange.dart'; +import '../../services/exchange/rosen/rosen_exchange.dart'; +import '../../utilities/enums/exchange_rate_type_enum.dart'; +import '../../utilities/util.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/dialogs/basic_dialog.dart'; + +Future showRosenQuoteChangedDialog(BuildContext context) async => + await showDialog( + context: context, + builder: (context) => BasicDialog( + title: 'Bridge fees changed', + message: + 'Rosen Bridge fees changed. No funds were sent. Refresh the quote, ' + 'then review the updated amount before confirming again.', + desktopHeight: 340, + canPopWithBackButton: true, + flex: true, + leftButton: SecondaryButton( + label: 'Cancel', + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: () => Navigator.of(context).pop(false), + ), + rightButton: PrimaryButton( + label: 'Refresh quote', + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: () => Navigator.of(context).pop(true), + ), + ), + ) ?? + false; + +Future refreshRosenEstimate( + IncompleteExchangeModel model, +) async { + final response = await RosenExchange.instance.getEstimates( + model.sendTicker, + model.sendCurrency.network, + model.receiveTicker, + model.receiveCurrency.network, + model.sendAmount, + model.rateType == ExchangeRateType.fixed, + model.reversed, + ); + if (response.value == null || response.value!.isEmpty) { + throw response.exception ?? + StateError('Unable to refresh the bridge quote.'); + } + final estimate = response.value!.single; + return IncompleteExchangeModel( + sendCurrency: model.sendCurrency, + receiveCurrency: model.receiveCurrency, + rateInfo: + '1 ${model.sendTicker.toUpperCase()} ' + '~${(estimate.estimatedAmount / model.sendAmount).toDecimal(scaleOnInfinitePrecision: 8).toStringAsFixed(8)} ' + '${model.receiveTicker.toUpperCase()}', + sendAmount: model.sendAmount, + receiveAmount: estimate.estimatedAmount, + rateType: model.rateType, + reversed: model.reversed, + walletInitiated: model.walletInitiated, + estimate: estimate, + ) + ..recipientAddress = model.recipientAddress + ..refundAddress = model.refundAddress; +} diff --git a/lib/pages/exchange_view/send_from_view.dart b/lib/pages/exchange_view/send_from_view.dart index 9fe4121ee0..6d8c42a161 100644 --- a/lib/pages/exchange_view/send_from_view.dart +++ b/lib/pages/exchange_view/send_from_view.dart @@ -16,10 +16,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../../app_config.dart'; +import '../../exceptions/exchange/exchange_exception.dart'; import '../../models/exchange/response_objects/trade.dart'; import '../../pages_desktop_specific/desktop_exchange/desktop_exchange_view.dart'; +import '../../providers/global/trades_service_provider.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; +import '../../services/exchange/rosen/rosen_exchange.dart'; +import '../../services/exchange/rosen/rosen_funding.dart'; import '../../services/exchange/trocador/trocador_exchange.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; @@ -48,6 +52,7 @@ import '../../widgets/stack_dialog.dart'; import '../home_view/home_view.dart'; import '../send_view/sub_widgets/building_transaction_dialog.dart'; import 'confirm_change_now_send.dart'; +import 'rosen_quote_dialog.dart'; class SendFromView extends ConsumerStatefulWidget { const SendFromView({ @@ -95,7 +100,11 @@ class _SendFromViewState extends ConsumerState { final walletIds = ref .watch(pWallets) .wallets - .where((e) => e.info.coin == coin) + .where( + (e) => trade.exchangeName == RosenExchange.exchangeName + ? RosenFunding.canFund(e, trade) + : e.info.coin == coin, + ) .map((e) => e.walletId) .toList(); @@ -160,7 +169,9 @@ class _SendFromViewState extends ConsumerState { Row( children: [ Text( - "You need to send ${ref.watch(pAmountFormatter(coin)).format(amount)}", + trade.exchangeName == RosenExchange.exchangeName + ? "You need to send ${trade.payInAmount} ${trade.payInCurrency}" + : "You need to send ${ref.watch(pAmountFormatter(coin)).format(amount)}", style: isDesktop ? STextStyles.desktopTextExtraExtraSmall(context) : STextStyles.itemSubtitle(context), @@ -168,6 +179,21 @@ class _SendFromViewState extends ConsumerState { ], ), const SizedBox(height: 16), + if (trade.exchangeName == RosenExchange.exchangeName) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + coin is Firo + ? "Rosen Bridge uses your transparent FIRO balance and includes the required bridge data." + : "Select the Ethereum wallet holding rsFIRO. ETH is required for network fees; the rsFIRO balance is checked before confirmation.", + style: STextStyles.itemSubtitle(context), + ), + ), + if (walletIds.isEmpty) + Text( + "No compatible wallets available", + style: STextStyles.itemSubtitle(context), + ), ConditionalParent( condition: !isDesktop, builder: (child) => Expanded(child: child), @@ -220,16 +246,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 +286,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 +332,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 +384,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 +393,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 +411,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,7 +493,10 @@ class _SendFromCardState extends ConsumerState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (!trade.exchangeName.startsWith(TrocadorExchange.exchangeName)) + if (!trade.exchangeName.startsWith( + TrocadorExchange.exchangeName, + ) && + trade.exchangeName != RosenExchange.exchangeName) MaterialButton( splashColor: Theme.of( context, @@ -524,7 +595,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( @@ -606,11 +679,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..be85fac9a9 100644 --- a/lib/pages/exchange_view/trade_details_view.dart +++ b/lib/pages/exchange_view/trade_details_view.dart @@ -33,6 +33,8 @@ import '../../services/exchange/exchange.dart'; import '../../services/exchange/exolix/exolix_exchange.dart'; import '../../services/exchange/lets_exchange/lets_exchange_exchange.dart'; import '../../services/exchange/nanswap/nanswap_exchange.dart'; +import '../../services/exchange/rosen/rosen_exchange.dart'; +import '../../services/exchange/rosen/rosen_funding.dart'; import '../../services/exchange/simpleswap/simpleswap_exchange.dart'; import '../../services/exchange/trocador/trocador_exchange.dart'; import '../../services/exchange/wizard_swap/wizard_swap_exchange.dart'; @@ -43,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'; @@ -171,8 +174,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 +208,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" || @@ -277,9 +287,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 +299,9 @@ class _TradeDetailsViewState extends ConsumerState { } final amount = Amount.fromDecimal( sendAmount, - fractionDigits: coin.fractionDigits, + fractionDigits: isRosen + ? DefaultTokens.rsFiro.decimals + : coin.fractionDigits, ); final address = trade.payInAddress; @@ -371,7 +385,9 @@ class _TradeDetailsViewState extends ConsumerState { trade.payInCurrency, )!; final amount = sendAmount.toAmount( - fractionDigits: coin.fractionDigits, + fractionDigits: isRosen + ? DefaultTokens.rsFiro.decimals + : coin.fractionDigits, ); text = ref .watch(pAmountFormatter(coin)) @@ -444,9 +460,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) RoundedContainer( padding: isDesktop ? const EdgeInsets.all(16) @@ -535,6 +551,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 +580,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 +656,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) @@ -1156,6 +1183,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 +1250,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 +1262,9 @@ class _TradeDetailsViewState extends ConsumerState { } final amount = Amount.fromDecimal( sendAmount, - fractionDigits: coin.fractionDigits, + fractionDigits: isRosen + ? 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 27d0b68a64..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 @@ -15,19 +15,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app_config.dart'; +import '../../../exceptions/exchange/exchange_exception.dart'; import '../../../models/exchange/incomplete_exchange.dart'; import '../../../models/exchange/response_objects/trade.dart'; +import '../../../pages/exchange_view/rosen_quote_dialog.dart'; import '../../../pages/exchange_view/send_from_view.dart'; import '../../../providers/exchange/exchange_form_state_provider.dart'; import '../../../providers/global/trades_service_provider.dart'; import '../../../providers/global/wallets_provider.dart'; import '../../../route_generator.dart'; import '../../../services/exchange/exchange_response.dart'; +import '../../../services/exchange/rosen/rosen_exchange.dart'; +import '../../../services/exchange/rosen/rosen_funding.dart'; import '../../../services/notifications_api.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/assets.dart'; +import '../../../utilities/default_eth_tokens.dart'; import '../../../utilities/enums/exchange_rate_type_enum.dart'; +import '../../../utilities/show_loading.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -64,6 +70,7 @@ class StepScaffold extends ConsumerStatefulWidget { class _StepScaffoldState extends ConsumerState { int currentStep = 1; bool enableNext = false; + bool _creating = false; late final Duration duration; @@ -74,113 +81,142 @@ 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 + ? DefaultTokens.rsFiro.decimals + : 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..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,16 +11,18 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:tuple/tuple.dart'; import '../../../../app_config.dart'; import '../../../../models/contact_address_entry.dart'; import '../../../../providers/providers.dart'; +import '../../../../services/exchange/rosen/rosen_exchange.dart'; +import '../../../../services/exchange/rosen/rosen_funding.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/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,28 +59,44 @@ 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 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) { - _toController.text = info.item1; - ref.read(desktopExchangeModelProvider)!.recipientAddress = info.item2; + final ticker = ref.read(desktopExchangeModelProvider)!.receiveTicker; + final coin = _isRosen && ticker.toLowerCase() == "rsfiro" + ? Ethereum(CryptoCurrencyNetwork.main) + : AppConfig.getCryptoCurrencyForTicker(ticker)!; + + final info = + await showDialog< + ({String walletId, String address, String walletName}) + >( + context: context, + barrierColor: Colors.transparent, + builder: (context) => DesktopDialog( + maxWidth: 720, + maxHeight: 670, + child: Padding( + padding: const EdgeInsets.all(32), + child: DesktopChooseAddressFromStack( + coin: coin, + transparentOnly: _isRosen, + ), + ), + ), + ); + + if (info != null) { + if (_isRosen && ticker.toLowerCase() == "rsfiro") { + await RosenFunding.registerToken( + ref.read(pWallets).getWallet(info.walletId), + ); + if (!mounted) return; + } + _toController.text = info.walletName; + ref.read(desktopExchangeModelProvider)!.recipientAddress = info.address; } } catch (e, s) { Logging.instance.i("$e\n$s", error: e, stackTrace: s); @@ -93,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); @@ -281,7 +302,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 +313,28 @@ class _DesktopStep2State extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "Recipient Wallet", + _isRosen && + ref + .read(desktopExchangeModelProvider)! + .receiveTicker + .toLowerCase() == + "firo" + ? "Recipient Wallet (transparent FIRO)" + : "Recipient Wallet", style: STextStyles.desktopTextExtraExtraSmall(context).copyWith( color: Theme.of( context, ).extension()!.textFieldActiveSearchIconRight, ), ), - if (AppConfig.isStackCoin( - ref.watch( - desktopExchangeModelProvider.select( - (value) => value!.receiveTicker, - ), - ), - )) + if (_isRosen || + AppConfig.isStackCoin( + ref.watch( + desktopExchangeModelProvider.select( + (value) => value!.receiveTicker, + ), + ), + )) CustomTextButton( text: "Choose from ${AppConfig.prefix}", onTap: selectRecipientAddressFromStack, diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_4.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_4.dart index 6e18c5086b..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 @@ -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..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'; @@ -36,9 +35,14 @@ import '../../../widgets/textfield_icon_button.dart'; import '../../../widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart'; class DesktopChooseAddressFromStack extends ConsumerStatefulWidget { - const DesktopChooseAddressFromStack({super.key, required this.coin}); + const DesktopChooseAddressFromStack({ + super.key, + required this.coin, + this.transparentOnly = false, + }); final CryptoCurrency coin; + final bool transparentOnly; @override ConsumerState createState() => @@ -191,8 +195,10 @@ class _DesktopChooseFromStackState primary: false, itemCount: walletIds.length, separatorBuilder: (_, __) => const SizedBox(height: 5), - itemBuilder: (context, index) => - _WalletRow(walletId: walletIds[index]), + itemBuilder: (context, index) => _WalletRow( + walletId: walletIds[index], + transparentOnly: widget.transparentOnly, + ), ); }, ), @@ -245,9 +251,14 @@ class _BalanceDisplay extends ConsumerWidget { } class _WalletRow extends ConsumerWidget { - const _WalletRow({super.key, required this.walletId}); + const _WalletRow({ + super.key, + required this.walletId, + required this.transparentOnly, + }); final String walletId; + final bool transparentOnly; @override Widget build(BuildContext context, WidgetRef ref) { @@ -286,7 +297,11 @@ class _WalletRow extends ConsumerWidget { wallet.info.cachedReceivingAddress; if (context.mounted) { - Navigator.of(context).pop(Tuple2(wallet.info.name, address)); + Navigator.of(context).pop(( + walletId: walletId, + address: address, + walletName: wallet.info.name, + )); } }, ), @@ -315,78 +330,84 @@ class _WalletRow extends ConsumerWidget { ], ), const SizedBox(height: 10), - Row( - children: [ - const SizedBox( - width: 12 + 32, // space + size of WalletInfoCoinIcon - ), - Text( - "Spark", - style: STextStyles.desktopTextExtraExtraSmall(context).copyWith( - color: Theme.of(context).extension()!.textDark, + if (!transparentOnly) + Row( + children: [ + const SizedBox( + width: 12 + 32, // space + size of WalletInfoCoinIcon ), - ), - const Spacer(), - _BalanceDisplay(walletId: walletId, balanceType: .private), - const SizedBox(width: 80), - CustomTextButton( - text: "Select wallet", - onTap: () async { - Future _future() async { - final wallet = - ref.read(pWallets).getWallet(walletId) - as SparkInterface; - - final sparkAddress = await wallet - .getCurrentReceivingSparkAddress(); - if (sparkAddress != null) { - return sparkAddress.value; + Text( + "Spark", + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + ), + const Spacer(), + _BalanceDisplay(walletId: walletId, balanceType: .private), + const SizedBox(width: 80), + CustomTextButton( + text: "Select wallet", + onTap: () async { + Future _future() async { + final wallet = + ref.read(pWallets).getWallet(walletId) + as SparkInterface; + + final sparkAddress = await wallet + .getCurrentReceivingSparkAddress(); + if (sparkAddress != null) { + return sparkAddress.value; + } + + return (await wallet.generateNextSparkAddress( + saveToDB: true, + )).value; } - return (await wallet.generateNextSparkAddress( - saveToDB: true, - )).value; - } - - Exception? ex; - final sparkAddress = await showLoading( - context: context, - message: "Fetching Spark address", - rootNavigator: Util.isDesktop, - delay: const Duration(milliseconds: 1200), - whileFutureAlt: _future, - onException: (e) => ex = e, - ); - - if (context.mounted) { - if (ex != null) { - await showDialog( - context: context, - builder: (context) => StackOkDialog( - title: "Error", - message: ex - .toString() - .replaceFirst("Exception:", "") - .trim(), - maxWidth: 400, - desktopPopRootNavigator: true, - ), - ); - } else { - Navigator.of(context).pop( - sparkAddress == null - ? null - : Tuple2( - "${ref.read(pWalletName(walletId))} (Spark)", - sparkAddress, - ), - ); + Exception? ex; + final sparkAddress = await showLoading( + context: context, + message: "Fetching Spark address", + rootNavigator: Util.isDesktop, + delay: const Duration(milliseconds: 1200), + whileFutureAlt: _future, + onException: (e) => ex = e, + ); + + if (context.mounted) { + if (ex != null) { + await showDialog( + context: context, + builder: (context) => StackOkDialog( + title: "Error", + message: ex + .toString() + .replaceFirst("Exception:", "") + .trim(), + maxWidth: 400, + desktopPopRootNavigator: true, + ), + ); + } else { + Navigator.of(context).pop( + sparkAddress == null + ? null + : ( + walletId: walletId, + address: sparkAddress, + walletName: + "${ref.read(pWalletName(walletId))} (Spark)", + ), + ); + } } - } - }, - ), - ], - ), + }, + ), + ], + ), const SizedBox(height: 10), Row( children: [ @@ -411,9 +432,11 @@ class _WalletRow extends ConsumerWidget { wallet.info.cachedReceivingAddress; if (context.mounted) { - Navigator.of( - context, - ).pop(Tuple2("${wallet.info.name} (Transparent)", address)); + Navigator.of(context).pop(( + walletId: walletId, + address: address, + walletName: "${wallet.info.name} (Transparent)", + )); } }, ), diff --git a/lib/services/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..02441ae4ff 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,7 +48,7 @@ class ExchangeDataLoadingService { VoidCallback? onLoadingError; VoidCallback? onLoadingComplete; - static const int cacheVersion = 1; + static const int cacheVersion = 2; static int get currentCacheVersion => DB.instance.get( @@ -130,7 +131,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 +223,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 +246,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..7505528231 --- /dev/null +++ b/lib/services/exchange/rosen/rosen_api.dart @@ -0,0 +1,153 @@ +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 firoLockAddress = 'aEF6fyd5jjCPcbiEBZJ2g8583caUme8T7Y'; + static const ethereumLockAddress = + '0x451698faa07fc68301af622a3ad42205f13c6e4b'; + static const _ergoFiroToken = + '581d7df25808881b2b8b9b4e03e2f637c46a94f74a69a5da36434125bacb4e08'; + static const _minimumFeeToken = + 'e2ed4d64393222db666f20e67803e9e6fbe6d64531e14ff52ddd95615b0cbf17'; + + final _client = const HTTP(); + + Future _get(Uri url) async { + final response = await _client + .get( + url: url, + headers: {'Accept': 'application/json'}, + connectionTimeout: const Duration(seconds: 20), + proxyInfo: + AppConfig.hasFeature(AppFeature.tor) && Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ) + .timeout(const Duration(seconds: 30)); + if (response.code != 200) { + throw StateError('Rosen API returned HTTP ${response.code}'); + } + return jsonDecode(response.body); + } + + Future quote({ + required bool fromFiro, + required BigInt amount, + int? sourceHeight, + }) async { + // The funding wallet supplies its chain tip; discovery uses Rosen's scanners. + var height = sourceHeight; + if (height == null) { + final heights = + await _get(Uri.https('app.rosen.tech', '/api/v1/heights')) as List; + final source = fromFiro ? 'firo' : 'ethereum'; + height = int.parse( + heights + .singleWhere((entry) => entry['network'] == source)['height'] + .toString(), + ); + } + final candidates = >[]; + var offset = 0; + while (true) { + final result = + await _get( + Uri.https( + 'api.ergoplatform.com', + '/api/v1/boxes/unspent/byTokenId/$_minimumFeeToken', + {'offset': '$offset', 'limit': '100'}, + ), + ) + as Map; + final items = result['items'] as List; + for (final item in items) { + final assets = item['assets'] as List; + if (assets.length == 2 && + assets.any((asset) => asset['tokenId'] == _minimumFeeToken) && + assets.any((asset) => asset['tokenId'] == _ergoFiroToken) && + item['spentTransactionId'] == null && + item['mainChain'] == true) { + candidates.add(Map.from(item as Map)); + } + } + offset += items.length; + if (offset >= int.parse(result['total'].toString())) break; + if (items.isEmpty || offset > 10000) { + throw const FormatException('Incomplete Rosen fee configuration'); + } + } + if (candidates.length != 1) { + throw StateError('Expected one active Rosen FIRO fee configuration'); + } + final registers = Map.from( + candidates.single['additionalRegisters'] as Map, + ); + final quote = RosenQuote.fromRegisters( + registers, + fromFiro: fromFiro, + height: height, + amount: amount, + ); + // Refuse a quote whose fees are scheduled to change during confirmation. + final next = RosenQuote.fromRegisters( + registers, + fromFiro: fromFiro, + height: height + (fromFiro ? 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..987cace37d --- /dev/null +++ b/lib/services/exchange/rosen/rosen_exchange.dart @@ -0,0 +1,438 @@ +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 '../../../utilities/default_eth_tokens.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: DefaultTokens.rsFiro.symbol, + name: DefaultTokens.rsFiro.name, + network: 'eth', + image: '', + isFiat: false, + rateType: SupportedRateType.estimated, + isStackCoin: false, + tokenContract: DefaultTokens.rsFiro.address, + isAvailable: true, + ), + ], + ); + + @override + Future> getRange( + String from, + String? fromNetwork, + String to, + String? toNetwork, + bool fixedRate, + ) => _response(() async { + _checkPair(from, fromNetwork, to, toNetwork, fixedRate, false); + final quote = await RosenApi.instance.quote( + fromFiro: from.toUpperCase() == 'FIRO', + amount: BigInt.zero, + ); + return Range( + min: Decimal.parse(RosenProtocol.formatAmount(quote.minimum)), + max: Decimal.parse(RosenProtocol.formatAmount(RosenProtocol.maxAmount)), + ); + }); + + @override + Future>> getEstimates( + String from, + String? fromNetwork, + String to, + String? toNetwork, + Decimal amount, + bool fixedRate, + bool reversed, + ) => _response(() async { + _checkPair(from, fromNetwork, to, toNetwork, fixedRate, reversed); + final rawAmount = RosenProtocol.parseAmount(amount.toString()); + final quote = await RosenApi.instance.quote( + fromFiro: from.toUpperCase() == 'FIRO', + amount: rawAmount, + ); + if (rawAmount < quote.minimum || quote.receiveAmount <= BigInt.zero) { + throw const FormatException('Amount is below the Rosen bridge minimum.'); + } + return [ + Estimate( + estimatedAmount: Decimal.parse( + RosenProtocol.formatAmount(quote.receiveAmount), + ), + fixedRate: false, + reversed: false, + rateId: '${from.toLowerCase()}:${quote.fingerprint}', + exchangeProvider: name, + 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': DefaultTokens.rsFiro.address, + }), + ); + }); + + 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'] != DefaultTokens.rsFiro.address || + amount <= bridgeFee + networkFee || + amount - bridgeFee - networkFee != + RosenProtocol.parseAmount(trade.payOutAmount) || + trade.payInAddress != + (isFiro(trade) + ? RosenApi.firoLockAddress + : RosenApi.ethereumLockAddress)) { + throw const FormatException('Invalid Rosen swap data.'); + } + 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' : DefaultTokens.rsFiro.address) || + event['bridgeFee'].toString() != data['bridgeFee'] || + event['networkFee'].toString() != data['networkFee'] || + BigInt.parse(event['amount'].toString()) != + RosenProtocol.parseAmount(trade.payInAmount)) { + throw const FormatException('Rosen event does not match this swap.'); + } + 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..00da82188a --- /dev/null +++ b/lib/services/exchange/rosen/rosen_funding.dart @@ -0,0 +1,340 @@ +import 'package:isar_community/isar.dart' show Isar; +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/default_eth_tokens.dart'; +import '../../../utilities/enums/fee_rate_type_enum.dart'; +import '../../../utilities/extensions/extensions.dart'; +import '../../../utilities/logger.dart'; +import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/models/tx_data.dart'; +import '../../../wallets/wallet/impl/ethereum_wallet.dart'; +import '../../../wallets/wallet/impl/firo_wallet.dart'; +import '../../../wallets/wallet/impl/sub_wallets/eth_token_wallet.dart'; +import '../../../wallets/wallet/wallet.dart'; +import '../../../wallets/wallet/wallet_mixin_interfaces/firo_op_return.dart'; +import 'rosen_api.dart'; +import 'rosen_exchange.dart'; +import 'rosen_protocol.dart'; + +/// 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 bool canFund(Wallet wallet, Trade trade) => + !wallet.info.isViewOnly && + wallet.cryptoCurrency == sourceCoin(trade) && + (RosenExchange.isFiro(trade) + ? wallet is FiroWallet + : wallet is EthereumWallet); + + static EthContract _tokenFor(Wallet wallet) => DefaultTokens.rsFiro.copyWith( + id: Isar.autoIncrement, + address: wallet.info.tokenContractAddresses.firstWhere( + (address) => address.toLowerCase() == DefaultTokens.rsFiro.address, + orElse: () => DefaultTokens.rsFiro.address, + ), + ); + + static Future registerToken(Wallet wallet) async { + if (wallet is! EthereumWallet || + wallet.cryptoCurrency.network != CryptoCurrencyNetwork.main) { + throw StateError('rsFIRO requires an Ethereum mainnet wallet.'); + } + final token = _tokenFor(wallet); + final stored = await wallet.mainDB.getEthContract(token.address); + if (stored == null) await wallet.mainDB.putEthContract(token); + if (!wallet.info.tokenContractAddresses.any( + (address) => address.toLowerCase() == DefaultTokens.rsFiro.address, + )) { + await wallet.updateTokenContracts([ + ...wallet.info.tokenContractAddresses, + DefaultTokens.rsFiro.address, + ]); + } + } + + 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: DefaultTokens.rsFiro.decimals, + ); + final data = TxData( + recipients: [ + TxRecipient( + address: trade.payInAddress, + amount: amount, + isChange: false, + addressType: wallet.cryptoCurrency.getAddressType( + trade.payInAddress, + )!, + ), + ], + feeRateType: FeeRateType.average, + ); + if (wallet is FiroWallet) { + await RosenExchange.validateFunding( + trade, + sourceHeight: await wallet.fetchChainHeight(), + ); + final prepared = await wallet.prepareSend( + txData: data.copyWith(opReturnData: metadata), + ); + // Transparent send-all subtracts the 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 = RosenProtocol.tokenContract; + final decimals = await client.call( + contract: contract, + function: contract.function('decimals'), + params: [], + ); + if (decimals.single != BigInt.from(DefaultTokens.rsFiro.decimals)) { + throw StateError('Unexpected rsFIRO token precision.'); + } + final 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.transferData( + lockAddress: RosenApi.ethereumLockAddress, + amount: amount.raw, + metadata: metadata, + ).toUint8ListFromHex; + final tokenAddress = eth.EthereumAddress.fromHex( + DefaultTokens.rsFiro.address, + ); + 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() != DefaultTokens.rsFiro.address || + (tx.value?.getInWei ?? BigInt.zero) != BigInt.zero || + tx.data?.toHex != 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..7b48c86be3 --- /dev/null +++ b/lib/services/exchange/rosen/rosen_protocol.dart @@ -0,0 +1,122 @@ +import 'package:coinlib/coinlib.dart' as coinlib; +import 'package:wallet/wallet.dart' show EthereumAddress; +import 'package:web3dart/web3dart.dart' as web3; + +import '../../../utilities/default_eth_tokens.dart'; +import '../../../utilities/extensions/extensions.dart'; +import '../../../wallets/crypto_currency/crypto_currency.dart'; + +/// Rosen's v1 metadata, shared by FIRO OP_RETURN and appended ERC-20 calldata. +/// https://github.com/rosen-bridge/ui/tree/dev/networks/firo/src/utils.ts +class RosenProtocol { + static final maxAmount = (BigInt.one << 64) - BigInt.one; + + static final tokenContract = web3.DeployedContract( + web3.ContractAbi.fromJson('''[ + {"type":"function","name":"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); + + 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 ethereumAddress(String address) { + if (!RegExp(r'^0x[0-9a-fA-F]{40}$').hasMatch(address) || + BigInt.parse(address.substring(2), radix: 16) == BigInt.zero) { + throw const FormatException('Enter a valid Ethereum mainnet address.'); + } + return EthereumAddress.fromHex(address, enforceEip55: true).without0x; + } + + /// address-codec encodes FIRO as its output script, not its Base58 payload. + static String firoScript(String address) { + try { + final program = coinlib.Address.fromString( + address, + Firo(CryptoCurrencyNetwork.main).networkParams, + ).program; + if (program is coinlib.P2PKH || program is coinlib.P2SH) { + return program.script.compiled.toHex; + } + } on Exception { + // Normalize malformed and wrong-network address errors. + } + throw const FormatException('Use a transparent FIRO mainnet address.'); + } + + static String metadata({ + required bool fromFiro, + required String destination, + required BigInt bridgeFee, + required BigInt networkFee, + }) { + final address = fromFiro + ? ethereumAddress(destination) + : firoScript(destination); + return '${fromFiro ? '03' : '07'}' + '${_uint(bridgeFee, 8)}${_uint(networkFee, 8)}' + '${_uint(BigInt.from(address.length ~/ 2), 1)}$address'; + } + + static String transferData({ + required String lockAddress, + required BigInt amount, + required String metadata, + }) { + if (amount <= BigInt.zero || amount > maxAmount) { + throw ArgumentError('Invalid rsFIRO amount.'); + } + if (metadata.length.isOdd || + !RegExp(r'^[0-9a-fA-F]*$').hasMatch(metadata)) { + throw const FormatException('Invalid hexadecimal data.'); + } + final encoded = tokenContract.function('transfer').encodeCall([ + EthereumAddress.fromHex('0x${ethereumAddress(lockAddress)}'), + amount, + ]); + return '${encoded.toHex}${metadata.toUint8ListFromHex.toHex}'; + } +} diff --git a/lib/services/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/utilities/default_eth_tokens.dart b/lib/utilities/default_eth_tokens.dart index 63cd7f62b3..390db18e76 100644 --- a/lib/utilities/default_eth_tokens.dart +++ b/lib/utilities/default_eth_tokens.dart @@ -11,6 +11,20 @@ import '../models/isar/models/ethereum/eth_contract.dart'; abstract class DefaultTokens { + static final rsFiro = EthContract( + address: "0x2744ea5ac9b11cb5e3cd63d3a88e858336aeddc2", + name: "rsFIRO", + symbol: "rsFIRO", + decimals: 8, + type: EthContractType.erc20, + ); + + static final _campfireTokenAddresses = { + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "0xdac17f958d2ee523a2206206994597c13d831ec7", + rsFiro.address, + }; + static List list = [ EthContract( address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", @@ -47,5 +61,13 @@ abstract class DefaultTokens { decimals: 18, type: EthContractType.erc20, ), + rsFiro, ]; + + 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/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/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..953d57bf81 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); @@ -2491,11 +2457,11 @@ mixin ElectrumXInterface canBatch ? checkGapsBatched( txCountBatchSize, - root, + root!, type, receiveChain, ) - : checkGapsLinearly(root, type, receiveChain), + : checkGapsLinearly(root!, type, receiveChain), ); } } @@ -2515,11 +2481,11 @@ mixin ElectrumXInterface canBatch ? checkGapsBatched( txCountBatchSize, - root, + root!, type, changeChain, ) - : checkGapsLinearly(root, type, changeChain), + : checkGapsLinearly(root!, type, changeChain), ); } } 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..90e8775b34 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); } } diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index 193bb133f1..c4c9c8422e 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -622,6 +622,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"); } @@ -2711,6 +2716,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/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/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 new file mode 100644 index 0000000000..5c9558c276 --- /dev/null +++ b/test/services/exchange/rosen/rosen_protocol_test.dart @@ -0,0 +1,246 @@ +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'; +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; + dynamic read() { + final prefix = bytes[offset++]; + if (prefix < 0x80) return [prefix]; + final isList = prefix >= 0xc0; + var length = prefix - (isList ? 0xc0 : 0x80); + if (length > 55) { + final lengthBytes = length - 55; + length = 0; + for (var i = 0; i < lengthBytes; i++) { + length = length * 256 + bytes[offset++]; + } + } + final end = offset + length; + expect(end, lessThanOrEqualTo(bytes.length)); + if (!isList) { + final value = bytes.sublist(offset, end); + offset = end; + return value; + } + final values = []; + while (offset < end) { + values.add(read()); + } + expect(offset, end); + return values; + } + + final value = read(); + expect(offset, bytes.length); + return value; +} + +void main() { + const ethereum = '0x00112233445566778899aabbccddeeff00112233'; + final firo = bs58check.encode('52${'11' * 20}'.toUint8ListFromHex); + + test('Rosen FIRO metadata matches upstream uint64 BE layout', () { + expect( + RosenProtocol.metadata( + fromFiro: true, + destination: ethereum, + bridgeFee: BigInt.from(123), + networkFee: BigInt.from(456), + ), + '03000000000000007b00000000000001c81400112233445566778899aabbccddeeff00112233', + ); + expect( + RosenProtocol.metadata( + fromFiro: false, + destination: firo, + bridgeFee: BigInt.from(123), + networkFee: BigInt.from(456), + ), + '07000000000000007b00000000000001c81976a914${'11' * 20}88ac', + ); + final p2sh = bs58check.encode('07${'22' * 20}'.toUint8ListFromHex); + expect(RosenProtocol.firoScript(p2sh), 'a914${'22' * 20}87'); + }); + + for (final version in ['52', '07']) { + test('signed rsFIRO broadcast preserves FIRO $version metadata', () async { + const token = '0x2744ea5ac9b11cb5e3cd63d3a88e858336aeddc2'; + const lock = '0x451698faa07fc68301af622a3ad42205f13c6e4b'; + final destination = bs58check.encode( + '$version${'11' * 20}'.toUint8ListFromHex, + ); + final metadata = RosenProtocol.metadata( + fromFiro: false, + destination: destination, + bridgeFee: BigInt.from(123), + networkFee: BigInt.from(456), + ); + final expectedMetadata = + '07000000000000007b00000000000001c8' + '${version == '52' ? '1976a914${'11' * 20}88ac' : '17a914${'11' * 20}87'}'; + final amount = BigInt.parse('9007199254740993'); + final calldata = RosenProtocol.transferData( + lockAddress: lock, + amount: amount, + metadata: metadata, + ); + final expectedCalldata = + 'a9059cbb${lock.substring(2).padLeft(64, '0')}' + '${amount.toRadixString(16).padLeft(64, '0')}$expectedMetadata'; + final txid = '0x${'22' * 32}'; + final client = web3.Web3Client( + 'https://ethereum.invalid', + MockClient( + expectAsync1((request) async { + final rpc = jsonDecode(request.body) as Map; + expect(rpc['method'], 'eth_sendRawTransaction'); + final raw = + ((rpc['params'] as List).single as String).toUint8ListFromHex; + expect(raw.first, 2); // EIP-1559 transaction envelope. + final fields = _decodeRlp(raw.sublist(1)) as List; + expect(fields, hasLength(12)); + expect(fields[0], [1]); // Ethereum mainnet. + expect(fields[5], token.toUint8ListFromHex); + expect(fields[6], isEmpty); // No native ETH is sent. + expect(fields[7], expectedCalldata.toUint8ListFromHex); + expect(fields[8], isEmpty); // Access list. + for (final signature in fields.sublist(10)) { + expect((signature as List).any((byte) => byte != 0), isTrue); + } + return http.Response( + jsonEncode({'jsonrpc': '2.0', 'id': rpc['id'], 'result': txid}), + 200, + ); + }), + ), + ); + addTearDown(client.dispose); + expect( + await client.sendTransaction( + web3.EthPrivateKey.fromHex('01'.padLeft(64, '0')), + web3.Transaction( + to: eth.EthereumAddress.fromHex(token), + value: eth.EtherAmount.zero(), + data: calldata.toUint8ListFromHex, + nonce: 7, + maxGas: 100000, + maxFeePerGas: eth.EtherAmount.inWei(BigInt.from(3000000000)), + maxPriorityFeePerGas: eth.EtherAmount.inWei( + BigInt.from(1000000000), + ), + ), + chainId: 1, + ), + txid, + ); + }); + } + + test('bridge completion requires a payout transaction', () { + expect(RosenProtocol.swapStatus('processing', null), 'Exchanging'); + expect(RosenProtocol.swapStatus('COMPLETED', null), 'Sending'); + expect(RosenProtocol.swapStatus('COMPLETED', 'invalid'), 'Sending'); + expect(RosenProtocol.swapStatus('COMPLETED', '0x${'11' * 32}'), 'Finished'); + expect(RosenProtocol.swapStatus('successful', '11' * 32), 'Finished'); + expect(RosenProtocol.swapStatus('FRAUD', null), '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('41${'11' * 20}'.toUint8ListFromHex); + expect(() => RosenProtocol.firoScript(testnet), throwsFormatException); + final witness = coinlib.P2WPKHAddress.fromHash( + ('11' * 20).toUint8ListFromHex, + hrp: 'bc', + ); + expect( + () => RosenProtocol.firoScript(witness.toString()), + throwsFormatException, + ); + expect( + () => RosenProtocol.firoScript('spark1notatransparentaddress'), + throwsA(anything), + ); + expect( + () => RosenProtocol.ethereumAddress('0x${'00' * 20}'), + throwsFormatException, + ); + for (final metadata in ['zz', 'a', 'aa bb', '0xaa', 'aa\n']) { + expect( + () => RosenProtocol.transferData( + lockAddress: ethereum, + amount: BigInt.one, + metadata: metadata, + ), + throwsFormatException, + ); + } + for (final amount in [BigInt.zero, -BigInt.one, BigInt.one << 64]) { + expect( + () => RosenProtocol.transferData( + lockAddress: ethereum, + amount: amount, + metadata: '00', + ), + throwsArgumentError, + ); + } + expect( + () => RosenProtocol.ethereumAddress( + '0x52908400098527886E0F7030069857D2E4169Ee7', + ), + throwsArgumentError, + ); + expect( + () => RosenProtocol.metadata( + fromFiro: true, + destination: ethereum, + bridgeFee: BigInt.one << 64, + networkFee: BigInt.one, + ), + throwsArgumentError, + ); + expect( + () => RosenProtocol.metadata( + fromFiro: true, + destination: ethereum, + bridgeFee: BigInt.one, + networkFee: -BigInt.one, + ), + throwsArgumentError, + ); + }); +} diff --git a/test/services/exchange/rosen_registration_test.dart b/test/services/exchange/rosen_registration_test.dart new file mode 100644 index 0000000000..5383a7c32a --- /dev/null +++ b/test/services/exchange/rosen_registration_test.dart @@ -0,0 +1,295 @@ +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/utilities/default_eth_tokens.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, DefaultTokens.rsFiro.address); + 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': DefaultTokens.rsFiro.address, + 'metadata': RosenProtocol.metadata( + fromFiro: fromFiro, + destination: destination, + bridgeFee: BigInt.from(123), + networkFee: BigInt.from(456), + ), + }), + ); +} 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'), + ]), + ); + }); +} diff --git a/test/wallets/firo_op_return_test.dart b/test/wallets/firo_op_return_test.dart new file mode 100644 index 0000000000..e510f69c55 --- /dev/null +++ b/test/wallets/firo_op_return_test.dart @@ -0,0 +1,179 @@ +import 'dart:io'; +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); + }, + skip: Platform.isLinux + ? 'Requires build/libsecp256k1.so for coinlib-backed signing checks on Ubuntu.' + : false, + ); +} 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', () { 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;