From 586a040e4a2b46719bd4400b83df860dbedec587 Mon Sep 17 00:00:00 2001 From: David Adegoke <64401859+Blazebrain@users.noreply.github.com> Date: Wed, 15 Apr 2026 04:06:05 +0100 Subject: [PATCH] CW-1273: Implement USDT0 Bridge (#2855) * feat: Implement USDT0 Bridging using OFT Standard by Layer Zero. POC available in apps menu. * feat: Implement USDT0 Bridging using OFT Standard by Layer Zero. POC available in apps menu. * feat: Implement USDT0 Bridging using OFT Standard by Layer Zero. POC available in apps menu. * Add transaction history and status polling for USDT0 bridging * Add Transaction history and status polling for USDT0 Bridging * USDT0 Bridging Implementation * Update Arbitrum USDT token symbol * fix: Merge conflicts * fix: Merge conflicts * chore: Rever formatting * feat: Implement new flow * feat: Implement new ui flow * Add currency to configure * feat: new ui flow * Update USDT0 implementation - Switch to sqlite for storage instead of hive - Switch bridge history and details to modals - Switch bridge transfer details page to new tx details ui * refactor: rearrange modal action buttons * enhance usdt0 bridge flows * Update lib/entities/wallet_manager.dart [skip ci] --------- Co-authored-by: Omar Hatem --- assets/images/crypto/paxg.webp | Bin 0 -> 5020 bytes assets/images/crypto/usdt0.webp | Bin 0 -> 8716 bytes assets/images/crypto/xaut.webp | Bin 0 -> 9610 bytes assets/new-ui/bridge.svg.vec | Bin 0 -> 1392 bytes assets/new-ui/tor.svg.vec | Bin 0 -> 564 bytes cw_core/lib/db/sqlite.dart | 35 +- cw_core/lib/hive_type_ids.dart | 2 +- cw_evm/lib/contract/oft.dart | 174 +++++ cw_evm/lib/evm_chain_wallet.dart | 13 + cw_evm/lib/tokens/arbitrum_tokens.dart | 11 +- cw_evm/lib/usdt0/usdt0_config.dart | 58 ++ cw_evm/lib/usdt0/usdt0_quote.dart | 15 + cw_evm/lib/usdt0/usdt0_service.dart | 146 ++++ lib/core/layerzero_scan_service.dart | 318 ++++++++ lib/di.dart | 46 +- lib/entities/bridge_transfer.dart | 127 ++++ lib/entities/wallet_manager.dart | 11 +- lib/evm/cw_evm.dart | 110 +++ lib/main.dart | 2 +- .../pages/bridge/bridge_amount_page.dart | 305 ++++++++ .../pages/bridge/bridge_confirm_sheet.dart | 227 ++++++ .../pages/bridge/bridge_detail_page.dart | 160 ++++ .../pages/bridge/bridge_history_page.dart | 117 +++ .../pages/bridge/bridge_network_page.dart | 98 +++ .../bridge_receive_address_input_page.dart | 160 ++++ .../bridge/bridge_receiving_wallet_page.dart | 264 +++++++ .../widgets/bridge/confirm_details_card.dart | 62 ++ .../bridge/expandable_details_card.dart | 113 +++ .../widgets/bridge/network_path_pill.dart | 89 +++ .../widgets/bridge/transfer_history_row.dart | 180 +++++ .../assets_history/asset_details_modal.dart | 64 +- .../coins_page/assets_history/asset_tile.dart | 3 + .../assets_history/assets_section.dart | 1 + .../widgets/send_page/send_address_input.dart | 6 +- lib/router.dart | 22 +- lib/routes.dart | 4 + .../new_list_row/list_Item_style_wrapper.dart | 12 +- lib/store/bridge_transfers_store.dart | 46 ++ .../bridge_receiving_wallet_option.dart | 17 + lib/view_model/bridge/bridge_view_model.dart | 719 ++++++++++++++++++ lib/view_model/bridge_details_view_model.dart | 181 +++++ lib/view_model/bridge_history_view_model.dart | 37 + .../dashboard/dashboard_view_model.dart | 8 + res/pictures/bridge.svg | 3 + tool/configure.dart | 35 +- 45 files changed, 3958 insertions(+), 43 deletions(-) create mode 100644 assets/images/crypto/paxg.webp create mode 100644 assets/images/crypto/usdt0.webp create mode 100644 assets/images/crypto/xaut.webp create mode 100644 assets/new-ui/bridge.svg.vec create mode 100644 assets/new-ui/tor.svg.vec create mode 100644 cw_evm/lib/contract/oft.dart create mode 100644 cw_evm/lib/usdt0/usdt0_config.dart create mode 100644 cw_evm/lib/usdt0/usdt0_quote.dart create mode 100644 cw_evm/lib/usdt0/usdt0_service.dart create mode 100644 lib/core/layerzero_scan_service.dart create mode 100644 lib/entities/bridge_transfer.dart create mode 100644 lib/new-ui/pages/bridge/bridge_amount_page.dart create mode 100644 lib/new-ui/pages/bridge/bridge_confirm_sheet.dart create mode 100644 lib/new-ui/pages/bridge/bridge_detail_page.dart create mode 100644 lib/new-ui/pages/bridge/bridge_history_page.dart create mode 100644 lib/new-ui/pages/bridge/bridge_network_page.dart create mode 100644 lib/new-ui/pages/bridge/bridge_receive_address_input_page.dart create mode 100644 lib/new-ui/pages/bridge/bridge_receiving_wallet_page.dart create mode 100644 lib/new-ui/widgets/bridge/confirm_details_card.dart create mode 100644 lib/new-ui/widgets/bridge/expandable_details_card.dart create mode 100644 lib/new-ui/widgets/bridge/network_path_pill.dart create mode 100644 lib/new-ui/widgets/bridge/transfer_history_row.dart create mode 100644 lib/store/bridge_transfers_store.dart create mode 100644 lib/view_model/bridge/bridge_receiving_wallet_option.dart create mode 100644 lib/view_model/bridge/bridge_view_model.dart create mode 100644 lib/view_model/bridge_details_view_model.dart create mode 100644 lib/view_model/bridge_history_view_model.dart create mode 100644 res/pictures/bridge.svg diff --git a/assets/images/crypto/paxg.webp b/assets/images/crypto/paxg.webp new file mode 100644 index 0000000000000000000000000000000000000000..9b89a14faad0b7c295c7bf6733ed1099b5d66152 GIT binary patch literal 5020 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 literal 0 HcmV?d00001 diff --git a/assets/images/crypto/usdt0.webp b/assets/images/crypto/usdt0.webp new file mode 100644 index 0000000000000000000000000000000000000000..d9afd7eae21aa689819dfb93a16fc7e6a3d4fa3c GIT binary patch literal 8716 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 literal 0 HcmV?d00001 diff --git a/assets/images/crypto/xaut.webp b/assets/images/crypto/xaut.webp new file mode 100644 index 0000000000000000000000000000000000000000..74f514d2eacd0d74dec674742eb200f137af9070 GIT binary patch literal 9610 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 AH2?qr literal 0 HcmV?d00001 diff --git a/assets/new-ui/bridge.svg.vec b/assets/new-ui/bridge.svg.vec new file mode 100644 index 0000000000000000000000000000000000000000..a0435fe791b7aa8c1096b05a3d8afc9c1c4cba03 GIT binary patch literal 1392 zcmZ{kdrXs87{*WAxG2i}!^CBCxH&|_Os5IX7?XbAdFnsZctsH+!3kob#x`k~mxvfl z7L{GXTC4FE5;K{g5|j;e%qfavl4V1QQ)b;lCK5>$jm~B=Mm^8lRsXU{`|CO9y_`Jn z^L@qc-O7=vl=AZXcKlQ*?ouiqpJ0|g^HmOq^Oc{(S@Kr`6APC~b2wefX<+^vxGV>& zsk~?muZ52y&DZsUp|NwJ$3@X|J|FYS`tZ^Ib!hiL!O{^Q~SJ&a>XyvV5xd!&~?ZtML8Yv0?a*M@$EGdry7CU*$?&$J7xN-oR! z=K4z#ABnX}zEZ7VJ(BgwT^9!Rd3=Y!KpJvUkLrgX;;cS`?net?J^K=WZzzCL6~a$a zR|-ShJ#xOhJVW9OwxvqGkzDGLiTXXYo3 zWPf&-tZSRDgF3oK-2`>*yU-2l+_C8vsC%J53eJJ#Tu9D|=bRhkIY;JmzN|;GKFNOc zQ<91OR*9qkm`6Xd4*kix^eg+&za;%k(%(F%-x*K;GoQ}|`;x3rvLByw@{R0g!r-BQ zL0u|y{sVQ|Fe;9S(aqe`OC8Dob@ftbIXAj9o^xP6=fZlN*UR@v7;pYnX8ML@zujN( zg4~~3QHn(k73i~f3K_?J>QnQB7w-G7qsN_#;vFo?)(ewuM-YCIum1l7{E7!!gqNvR)OvaHjCYnm-z4{#Hsx~$N literal 0 HcmV?d00001 diff --git a/assets/new-ui/tor.svg.vec b/assets/new-ui/tor.svg.vec new file mode 100644 index 0000000000000000000000000000000000000000..3acf0752bf1325dcfc1458e474c02e20a261e572 GIT binary patch literal 564 zcmYk4JxBvV5QXQwMl2LLB*ppD2ofY1lg1!&GYFRck;cMC2u2GFu@FJdMi9{yR)R^9 z!a}f!l^769J+!c}6cN$NPSi&9+_#%Wmz(`&-kX_iZZ5S&PLgOt5S<@G+p$X&$37AD zsGuesSHp2YM}02gR{9!R8b@s)daI2|X}G{smiG=uAcqFe_NWvy@^TyTdb{CtpJ8Ui@Fd%&{foRWj`zeMX29^XRp9R?d_g~m zdGN?<@GEB2n6borsPiis$=>^#$QK_>ZoSm#6+_!k9O!<_rKwe}&i4wq`6}|AKGnn= zYIq3bRu4s8$ai%S9Pj*kKSo UAiS<9C(Xx3!+Ys&<*I+<7up7(NdN!< literal 0 HcmV?d00001 diff --git a/cw_core/lib/db/sqlite.dart b/cw_core/lib/db/sqlite.dart index b32c1e82b..8d716c449 100644 --- a/cw_core/lib/db/sqlite.dart +++ b/cw_core/lib/db/sqlite.dart @@ -7,6 +7,8 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart'; Database? db; + + Future _addColumnIfNotExists( Database db, { required String table, @@ -39,7 +41,7 @@ Future initDb({String? pathOverride}) async { } } await db?.close(); - db = await openDatabase(dbFile.path, version: 4, + db = await openDatabase(dbFile.path, version: 5, onUpgrade: (Database db, int oldVersion, int newVersion) async { printV("migrating: $oldVersion, $newVersion"); if (oldVersion <= 1) { @@ -88,6 +90,9 @@ CREATE TABLE IF NOT EXISTS BalanceCardStyleSettings ( // if address doesn't correspond to a valid token, fallback to primary token await _addColumnIfNotExists(db, table: "WalletInfo", column: "favoriteTokenAddress", definition: "TEXT DEFAULT NULL"); } + if (oldVersion <= 4) { + await _createBridgeTransferTable(db); + } }, onCreate: (Database db, int version) async { await db.execute( @@ -185,6 +190,7 @@ CREATE TABLE BalanceCardStyleSettings ( FOREIGN KEY (walletInfoId) REFERENCES WalletInfo(walletInfoId) ); '''); + await _createBridgeTransferTable(db); } ); } @@ -222,4 +228,31 @@ Future> dumpCustomDb(String path) async { ret[tableName] = await db.query(tableName); } return ret; +} + +Future _createBridgeTransferTable(Database db) async { + await db.execute(''' +CREATE TABLE IF NOT EXISTS BridgeTransfer ( + id TEXT NOT NULL PRIMARY KEY, + wallet_id TEXT NOT NULL, + source_chain_id INTEGER NOT NULL, + destination_chain_id INTEGER NOT NULL, + token_symbol TEXT NOT NULL, + token_contract TEXT NOT NULL, + amount TEXT NOT NULL, + recipient_address TEXT NOT NULL, + source_tx_hash TEXT NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER, + confirmed_at INTEGER, + amount_raw TEXT, + error_message TEXT, + status_message TEXT +); +'''); + await db.execute(''' +CREATE INDEX IF NOT EXISTS idx_bridgetransfer_wallet_id +ON BridgeTransfer(wallet_id); +'''); } \ No newline at end of file diff --git a/cw_core/lib/hive_type_ids.dart b/cw_core/lib/hive_type_ids.dart index e7c20a444..72139ca75 100644 --- a/cw_core/lib/hive_type_ids.dart +++ b/cw_core/lib/hive_type_ids.dart @@ -21,4 +21,4 @@ const HARDWARE_WALLET_TYPE_TYPE_ID = 19; const MWEB_UTXO_TYPE_ID = 20; const HAVEN_SEED_STORE_TYPE_ID = 21; const ZANO_ASSET_TYPE_ID = 22; -const PAYJOIN_SESSION_TYPE_ID = 23; +const PAYJOIN_SESSION_TYPE_ID = 23; \ No newline at end of file diff --git a/cw_evm/lib/contract/oft.dart b/cw_evm/lib/contract/oft.dart new file mode 100644 index 000000000..cb0b355f1 --- /dev/null +++ b/cw_evm/lib/contract/oft.dart @@ -0,0 +1,174 @@ +import 'dart:typed_data'; + +import 'package:cw_evm/usdt0/usdt0_quote.dart'; +import 'package:hex/hex.dart' as hex; +import 'package:web3dart/web3dart.dart' as web3; + +/// Minimal OFT ABI for USDT0 (per docs.usdt0.to). +/// quoteSend(SendParam, bool payInLzToken) returns MessagingFee. +/// send(SendParam, MessagingFee, address refundAddress) payable. +/// SendParam: dstEid, to (bytes32), amountLD (amount to send, token units), +/// minAmountLD (min receive on destination), extraOptions, composeMsg, oftCmd. +const String _oftAbiJson = ''' +[ + { + "inputs": [ + { + "components": [ + {"internalType": "uint32", "name": "dstEid", "type": "uint32"}, + {"internalType": "bytes32", "name": "to", "type": "bytes32"}, + {"internalType": "uint256", "name": "amountLD", "type": "uint256"}, + {"internalType": "uint256", "name": "minAmountLD", "type": "uint256"}, + {"internalType": "bytes", "name": "extraOptions", "type": "bytes"}, + {"internalType": "bytes", "name": "composeMsg", "type": "bytes"}, + {"internalType": "bytes", "name": "oftCmd", "type": "bytes"} + ], + "internalType": "struct SendParam", + "name": "sendParam", + "type": "tuple" + }, + {"internalType": "bool", "name": "payInLzToken", "type": "bool"} + ], + "name": "quoteSend", + "outputs": [ + { + "components": [ + {"internalType": "uint256", "name": "nativeFee", "type": "uint256"}, + {"internalType": "uint256", "name": "lzTokenFee", "type": "uint256"} + ], + "internalType": "struct MessagingFee", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + {"internalType": "uint32", "name": "dstEid", "type": "uint32"}, + {"internalType": "bytes32", "name": "to", "type": "bytes32"}, + {"internalType": "uint256", "name": "amountLD", "type": "uint256"}, + {"internalType": "uint256", "name": "minAmountLD", "type": "uint256"}, + {"internalType": "bytes", "name": "extraOptions", "type": "bytes"}, + {"internalType": "bytes", "name": "composeMsg", "type": "bytes"}, + {"internalType": "bytes", "name": "oftCmd", "type": "bytes"} + ], + "internalType": "struct SendParam", + "name": "_sendParam", + "type": "tuple" + }, + { + "components": [ + {"internalType": "uint256", "name": "nativeFee", "type": "uint256"}, + {"internalType": "uint256", "name": "lzTokenFee", "type": "uint256"} + ], + "internalType": "struct MessagingFee", + "name": "_fee", + "type": "tuple" + }, + {"internalType": "address", "name": "_refundAddress", "type": "address"} + ], + "name": "send", + "outputs": [], + "stateMutability": "payable", + "type": "function" + } +] +'''; + +final web3.ContractAbi oftContractAbi = web3.ContractAbi.fromJson(_oftAbiJson, 'OFT'); + +/// OFT contract wrapper for quoteSend (view) and send (payable). +class OFT { + OFT({ + required web3.EthereumAddress address, + required web3.Web3Client client, + }) : _address = address, + _client = client; + + final web3.EthereumAddress _address; + final web3.Web3Client _client; + + /// Calls quoteSend and returns MessagingFee (nativeFee, lzTokenFee). + Future quoteSend({ + required int dstEid, + required List toBytes32, + required BigInt amountLD, + required BigInt minAmountLD, + Uint8List? extraOptions, + Uint8List? composeMsg, + Uint8List? oftCmd, + bool payInLzToken = false, + }) async { + final contract = web3.DeployedContract( + oftContractAbi, + _address, + ); + final fn = contract.function('quoteSend'); + final sendParam = [ + BigInt.from(dstEid), + Uint8List.fromList(toBytes32), + amountLD, + minAmountLD, + extraOptions ?? Uint8List(0), + composeMsg ?? Uint8List(0), + oftCmd ?? Uint8List(0), + ]; + final params = [sendParam, payInLzToken]; + + final result = await _client.call( + contract: contract, + function: fn, + params: params, + ); + + final msgFee = result.first as List; + final nativeFee = msgFee[0] as BigInt; + final lzTokenFee = msgFee[1] as BigInt; + return USDT0Quote(nativeFee: nativeFee, lzTokenFee: lzTokenFee); + } + + /// Encodes send(SendParam, MessagingFee, refundAddress) for transaction. + /// Returns (dataHex, valueWei) where valueWei is the native fee to send. + ({String dataHex, BigInt valueWei}) encodeSend({ + required int dstEid, + required List toBytes32, + required BigInt amountLD, + required BigInt minAmountLD, + required BigInt nativeFee, + required BigInt lzTokenFee, + required String refundAddress, + Uint8List? extraOptions, + Uint8List? composeMsg, + Uint8List? oftCmd, + }) { + final contract = web3.DeployedContract(oftContractAbi, _address); + final fn = contract.function('send'); + final sendParam = [ + BigInt.from(dstEid), + Uint8List.fromList(toBytes32), + amountLD, + minAmountLD, + extraOptions ?? Uint8List(0), + composeMsg ?? Uint8List(0), + oftCmd ?? Uint8List(0), + ]; + final fee = [nativeFee, lzTokenFee]; + final refund = web3.EthereumAddress.fromHex(refundAddress); + final encoded = fn.encodeCall([sendParam, fee, refund]); + final dataHex = '0x${hex.HEX.encode(encoded)}'; + return (dataHex: dataHex, valueWei: nativeFee); + } +} + +/// Converts EVM address (0x + 40 hex) to bytes32 (left-padded). +List addressToBytes32(String address) { + final clean = address.startsWith('0x') ? address.substring(2) : address; + + if (clean.length != 40) throw ArgumentError('Invalid address length'); + + return hex.HEX.decode('000000000000000000000000$clean'); +} diff --git a/cw_evm/lib/evm_chain_wallet.dart b/cw_evm/lib/evm_chain_wallet.dart index 991fd0718..e2e97a356 100644 --- a/cw_evm/lib/evm_chain_wallet.dart +++ b/cw_evm/lib/evm_chain_wallet.dart @@ -1281,12 +1281,25 @@ abstract class EVMChainWalletBase } else if (newTxInfo.direction == TransactionDirection.incoming && existingTxInfo.direction == TransactionDirection.outgoing) { result[transactionModel.hash] = newTxInfo; + } + + else if (newTxInfo.direction == TransactionDirection.outgoing && + existingTxInfo.direction == TransactionDirection.outgoing && + _hasEvmTokenContractAddress(newTxInfo) && + !_hasEvmTokenContractAddress(existingTxInfo)) { + result[transactionModel.hash] = newTxInfo; } } return result; } + + bool _hasEvmTokenContractAddress(EVMChainTransactionInfo info) { + final c = info.contractAddress; + return c != null && c.isNotEmpty; + } + String? analyzeTransaction(String? transactionInput) { if (transactionInput == '0x' || transactionInput == null || transactionInput.isEmpty) { return ''; diff --git a/cw_evm/lib/tokens/arbitrum_tokens.dart b/cw_evm/lib/tokens/arbitrum_tokens.dart index fb6aa7703..dec37d4a7 100644 --- a/cw_evm/lib/tokens/arbitrum_tokens.dart +++ b/cw_evm/lib/tokens/arbitrum_tokens.dart @@ -12,6 +12,13 @@ class ArbitrumTokens { decimal: 18, enabled: true, ), + Erc20Token( + name: "Tether USD (Omnichain)", + symbol: "USDT", + contractAddress: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + decimal: 6, + enabled: true, + ), Erc20Token( name: "USD Coin", symbol: "USDC", @@ -68,8 +75,7 @@ class ArbitrumTokens { if (token.iconPath?.isEmpty ?? true) { try { iconPath = CryptoCurrency.all - .firstWhere((element) => - element.title.toUpperCase() == token.symbol.toUpperCase()) + .firstWhere((element) => element.title.toUpperCase() == token.symbol.toUpperCase()) .iconPath; } catch (_) {} } else { @@ -80,4 +86,3 @@ class ArbitrumTokens { }).toList(); } } - diff --git a/cw_evm/lib/usdt0/usdt0_config.dart b/cw_evm/lib/usdt0/usdt0_config.dart new file mode 100644 index 000000000..e1978b7a5 --- /dev/null +++ b/cw_evm/lib/usdt0/usdt0_config.dart @@ -0,0 +1,58 @@ +import 'package:cw_core/erc20_token.dart'; + +/// USDT0 (Omnichain USDT) config. +/// Addresses and EIDs from https://docs.usdt0.to/technical-documentation/developer/usdt0-deployments +class USDT0Config { + USDT0Config._(); + + static const String ethereumOftAdapter = + '0x6C96dE32CEa08842dcc4058c14d3aaAD7Fa41dee'; + + static const String ethereumNativeUsdt = + '0xdac17f958d2ee523a2206206994597c13d831ec7'; + + static const Map usdt0TokenAddressByChainId = { + 1: ethereumNativeUsdt, + 137: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F', + 42161: '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', + }; + + static const Map oftContractAddressByChainId = { + 1: ethereumOftAdapter, + 137: '0x6BA10300f0DC58B7a1e4c0e41f5daBb7D7829e13', + 42161: '0x14E4A1B13bf7F943c8ff7C51fb60FA964A298D92', + }; + + static const Map endpointIdByChainId = { + 1: 30101, + 137: 30109, + 42161: 30110, + }; + + static const int ethereumChainId = 1; + + static List get supportedChainIds => + usdt0TokenAddressByChainId.keys.toList(growable: false); + + static String? getOftAdapterAddress(int chainId) { + if (chainId == ethereumChainId) return ethereumOftAdapter; + return null; + } + + static String? getUsdt0TokenAddress(int chainId) => + usdt0TokenAddressByChainId[chainId]; + + static String? getOftContractAddress(int chainId) => + oftContractAddressByChainId[chainId]; + + static int? getEndpointId(int chainId) => endpointIdByChainId[chainId]; + + static bool isChainSupported(int chainId) => + usdt0TokenAddressByChainId.containsKey(chainId); + + static bool isUSDT0Token(Erc20Token token, int chainId) { + final address = getUsdt0TokenAddress(chainId); + if (address == null) return false; + return token.contractAddress.toLowerCase() == address.toLowerCase(); + } +} diff --git a/cw_evm/lib/usdt0/usdt0_quote.dart b/cw_evm/lib/usdt0/usdt0_quote.dart new file mode 100644 index 000000000..2c2cbb856 --- /dev/null +++ b/cw_evm/lib/usdt0/usdt0_quote.dart @@ -0,0 +1,15 @@ +class USDT0Quote { + const USDT0Quote({ + required this.nativeFee, + required this.lzTokenFee, + }); + + /// Fee in native token (e.g. ETH, MATIC) in wei. + final BigInt nativeFee; + + /// Fee in LZ token (if paying with LZ token). + final BigInt lzTokenFee; + + @override + String toString() => 'USDT0Quote(nativeFee: $nativeFee, lzTokenFee: $lzTokenFee)'; +} diff --git a/cw_evm/lib/usdt0/usdt0_service.dart b/cw_evm/lib/usdt0/usdt0_service.dart new file mode 100644 index 000000000..963994b0c --- /dev/null +++ b/cw_evm/lib/usdt0/usdt0_service.dart @@ -0,0 +1,146 @@ +import 'package:cw_core/erc20_token.dart'; +import 'package:cw_core/pending_transaction.dart'; +import 'package:cw_evm/contract/erc20.dart'; +import 'package:cw_evm/contract/oft.dart'; +import 'package:cw_evm/evm_chain_transaction_priority.dart'; +import 'package:cw_evm/utils/evm_chain_utils.dart'; +import 'package:cw_evm/evm_chain_wallet.dart'; +import 'package:cw_evm/usdt0/usdt0_config.dart'; +import 'package:cw_evm/usdt0/usdt0_quote.dart'; +import 'package:web3dart/web3dart.dart' as web3; + +class USDT0Service { + static Future quoteCrossChainTransfer({ + required web3.Web3Client client, + required int sourceChainId, + required int destinationChainId, + required BigInt amount, + required String recipientAddress, + BigInt? minAmount, + }) async { + final min = minAmount ?? BigInt.zero; + final oftAddress = USDT0Config.getOftContractAddress(sourceChainId); + final dstEid = USDT0Config.getEndpointId(destinationChainId); + + if (oftAddress == null || dstEid == null) { + throw Exception( + 'USDT0 not supported for chain $sourceChainId -> $destinationChainId', + ); + } + + final toBytes32 = addressToBytes32(recipientAddress); + final oft = OFT( + address: web3.EthereumAddress.fromHex(oftAddress), + client: client, + ); + + return oft.quoteSend( + dstEid: dstEid, + toBytes32: toBytes32, + amountLD: amount, + minAmountLD: min, + payInLzToken: false, + ); + } + + static Future executeCrossChainTransfer({ + required EVMChainWallet wallet, + required int sourceChainId, + required int destinationChainId, + required BigInt amount, + required String recipientAddress, + required USDT0Quote quote, + required Erc20Token token, + required EVMChainTransactionPriority priority, + bool useBlinkProtection = true, + }) async { + final oftAddress = USDT0Config.getOftContractAddress(sourceChainId); + final dstEid = USDT0Config.getEndpointId(destinationChainId); + + if (oftAddress == null || dstEid == null) { + throw Exception( + 'USDT0 not supported for chain $sourceChainId -> $destinationChainId', + ); + } + + if (sourceChainId == USDT0Config.ethereumChainId) { + final adapter = USDT0Config.getOftAdapterAddress(sourceChainId); + if (adapter != null) { + final needsApproval = await _isApprovalRequired( + wallet: wallet, + token: token, + spender: adapter, + amount: amount, + ); + + if (needsApproval) { + final maxUint = BigInt.parse( + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + radix: 16, + ); + + final pendingApproval = await wallet.createApprovalTransaction( + maxUint, + adapter, + token, + priority, + EVMChainUtils.getFeeCurrency(wallet.selectedChainId), + useBlinkProtection: useBlinkProtection, + ); + await pendingApproval.commit(); + } + } + } + + final client = wallet.getWeb3Client(); + if (client == null) { + throw StateError('Wallet not connected to node'); + } + + final toBytes32 = addressToBytes32(recipientAddress); + final oft = OFT( + address: web3.EthereumAddress.fromHex(oftAddress), + client: client, + ); + + final encoded = oft.encodeSend( + dstEid: dstEid, + toBytes32: toBytes32, + amountLD: amount, + minAmountLD: BigInt.zero, + nativeFee: quote.nativeFee, + lzTokenFee: quote.lzTokenFee, + refundAddress: wallet.walletAddresses.primaryAddress, + ); + + return wallet.createCallDataTransaction( + oftAddress, + encoded.dataHex, + encoded.valueWei, + priority, + token.contractAddress, + amount, + useBlinkProtection: useBlinkProtection, + ); + } + + static Future _isApprovalRequired({ + required EVMChainWallet wallet, + required Erc20Token token, + required String spender, + required BigInt amount, + }) async { + final client = wallet.getWeb3Client(); + if (client == null) return true; + + final erc20 = ERC20( + address: web3.EthereumAddress.fromHex(token.contractAddress), + client: client, + ); + final current = await erc20.allowance( + web3.EthereumAddress.fromHex(wallet.walletAddresses.primaryAddress), + web3.EthereumAddress.fromHex(spender), + ); + return current < amount; + } +} diff --git a/lib/core/layerzero_scan_service.dart b/lib/core/layerzero_scan_service.dart new file mode 100644 index 000000000..6bb572cf2 --- /dev/null +++ b/lib/core/layerzero_scan_service.dart @@ -0,0 +1,318 @@ +import 'dart:convert'; + +import 'package:cw_core/utils/proxy_wrapper.dart'; + +class LayerZeroScanService { + static const String _baseUrl = 'https://scan.layerzero-api.com/v1'; + + static Future getMessageStatus( + String sourceTxHash, + ) async { + try { + final uri = Uri.parse('$_baseUrl/messages/tx/$sourceTxHash'); + + final response = await ProxyWrapper().get(clearnetUri: uri); + + if (response.statusCode != 200) return null; + + final decoded = json.decode(response.body); + + Map? data; + if (decoded is List && decoded.isNotEmpty) { + data = decoded.first as Map?; + } else if (decoded is Map) { + if (decoded.containsKey('data') && decoded['data'] is List) { + final dataList = decoded['data'] as List; + if (dataList.isNotEmpty) { + data = dataList.first as Map?; + } + } else { + data = decoded; + } + } + if (data == null) return null; + return LayerZeroMessageStatus.fromJson(data); + } catch (_) { + return null; + } + } +} + +class LayerZeroMessageStatus { + final String? guid; + final LayerZeroStatus? status; + final LayerZeroSource? source; + final LayerZeroDestination? destination; + final LayerZeroVerification? verification; + + LayerZeroMessageStatus({ + this.guid, + this.status, + this.source, + this.destination, + this.verification, + }); + + factory LayerZeroMessageStatus.fromJson(Map json) { + return LayerZeroMessageStatus( + guid: json['guid'] as String?, + status: json['status'] != null + ? LayerZeroStatus.fromJson( + json['status'] as Map, + ) + : null, + source: json['source'] != null + ? LayerZeroSource.fromJson( + json['source'] as Map, + ) + : null, + destination: json['destination'] != null + ? LayerZeroDestination.fromJson( + json['destination'] as Map, + ) + : null, + verification: json['verification'] != null + ? LayerZeroVerification.fromJson( + json['verification'] as Map, + ) + : null, + ); + } + + bool get isDelivered => + status?.name == 'DELIVERED' || + destination?.status == 'DELIVERED' || + destination?.status == 'SUCCEEDED'; + + bool get isFailed => status?.name == 'FAILED' || destination?.status == 'FAILED'; + + bool get isInflight => + status?.name == 'INFLIGHT' || + status?.name == 'CONFIRMING' || + destination?.status == 'WAITING' || + destination?.status == 'VALIDATING_TX' || + (destination?.status != 'SUCCEEDED' && + destination?.status != 'DELIVERED' && + destination?.status != 'FAILED' && + destination?.tx == null); +} + +class LayerZeroStatus { + final String? name; + final String? message; + + LayerZeroStatus({this.name, this.message}); + + factory LayerZeroStatus.fromJson(Map json) { + return LayerZeroStatus( + name: json['name'] as String?, + message: json['message'] as String?, + ); + } +} + +class LayerZeroSource { + final String? status; + final LayerZeroTransaction? tx; + + LayerZeroSource({ + this.status, + this.tx, + }); + + factory LayerZeroSource.fromJson(Map json) { + return LayerZeroSource( + status: json['status'] as String?, + tx: json['tx'] != null + ? LayerZeroTransaction.fromJson( + json['tx'] as Map, + ) + : null, + ); + } +} + +class LayerZeroDestination { + final String? status; + final LayerZeroTransaction? tx; + final LayerZeroNativeDrop? nativeDrop; + final LayerZeroLzCompose? lzCompose; + + LayerZeroDestination({ + this.status, + this.tx, + this.nativeDrop, + this.lzCompose, + }); + + factory LayerZeroDestination.fromJson(Map json) { + return LayerZeroDestination( + status: json['status'] as String?, + tx: json['tx'] != null + ? LayerZeroTransaction.fromJson( + json['tx'] as Map, + ) + : null, + nativeDrop: json['nativeDrop'] != null + ? LayerZeroNativeDrop.fromJson( + json['nativeDrop'] as Map, + ) + : null, + lzCompose: json['lzCompose'] != null + ? LayerZeroLzCompose.fromJson( + json['lzCompose'] as Map, + ) + : null, + ); + } +} + +class LayerZeroNativeDrop { + final String? status; + + LayerZeroNativeDrop({this.status}); + + factory LayerZeroNativeDrop.fromJson(Map json) { + return LayerZeroNativeDrop( + status: json['status'] as String?, + ); + } +} + +class LayerZeroLzCompose { + final String? status; + + LayerZeroLzCompose({this.status}); + + factory LayerZeroLzCompose.fromJson(Map json) { + return LayerZeroLzCompose( + status: json['status'] as String?, + ); + } +} + +class LayerZeroTransaction { + final String? txHash; + final String? blockHash; + final String? blockNumber; + final int? blockTimestamp; + final String? from; + + LayerZeroTransaction({ + this.txHash, + this.blockHash, + this.blockNumber, + this.blockTimestamp, + this.from, + }); + + factory LayerZeroTransaction.fromJson(Map json) { + final blockNumberValue = json['blockNumber']; + final blockNumberStr = blockNumberValue != null + ? (blockNumberValue is int ? blockNumberValue.toString() : blockNumberValue as String?) + : null; + return LayerZeroTransaction( + txHash: json['txHash'] as String?, + blockHash: json['blockHash'] as String?, + blockNumber: blockNumberStr, + blockTimestamp: json['blockTimestamp'] as int?, + from: json['from'] as String?, + ); + } +} + +class LayerZeroVerification { + final LayerZeroDvn? dvn; + final LayerZeroSealer? sealer; + + LayerZeroVerification({ + this.dvn, + this.sealer, + }); + + factory LayerZeroVerification.fromJson(Map json) { + return LayerZeroVerification( + dvn: json['dvn'] != null + ? LayerZeroDvn.fromJson( + json['dvn'] as Map, + ) + : null, + sealer: json['sealer'] != null + ? LayerZeroSealer.fromJson( + json['sealer'] as Map, + ) + : null, + ); + } +} + +class LayerZeroDvn { + final Map? dvns; + final String? status; + + LayerZeroDvn({ + this.dvns, + this.status, + }); + + factory LayerZeroDvn.fromJson(Map json) { + final dvnsMap = json['dvns'] as Map?; + final parsedDvns = dvnsMap?.map( + (key, value) => MapEntry( + key, + LayerZeroDvnStatus.fromJson(value as Map), + ), + ); + return LayerZeroDvn( + dvns: parsedDvns, + status: json['status'] as String?, + ); + } +} + +class LayerZeroDvnStatus { + final String? status; + final String? txHash; + final String? blockHash; + final int? blockNumber; + final int? blockTimestamp; + + LayerZeroDvnStatus({ + this.status, + this.txHash, + this.blockHash, + this.blockNumber, + this.blockTimestamp, + }); + + factory LayerZeroDvnStatus.fromJson(Map json) { + return LayerZeroDvnStatus( + status: json['status'] as String?, + txHash: json['txHash'] as String?, + blockHash: json['blockHash'] as String?, + blockNumber: json['blockNumber'] as int?, + blockTimestamp: json['blockTimestamp'] as int?, + ); + } +} + +class LayerZeroSealer { + final String? status; + final LayerZeroTransaction? tx; + + LayerZeroSealer({ + this.status, + this.tx, + }); + + factory LayerZeroSealer.fromJson(Map json) { + return LayerZeroSealer( + status: json['status'] as String?, + tx: json['tx'] != null + ? LayerZeroTransaction.fromJson( + json['tx'] as Map, + ) + : null, + ); + } +} diff --git a/lib/di.dart b/lib/di.dart index 7a8a8185a..7d386e58c 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -32,6 +32,7 @@ import 'package:cake_wallet/core/wallet_loading_service.dart'; import 'package:cake_wallet/core/yat_service.dart'; import 'package:cake_wallet/decred/decred.dart'; import 'package:cake_wallet/entities/biometric_auth.dart'; +import 'package:cake_wallet/entities/bridge_transfer.dart'; import 'package:cake_wallet/entities/contact.dart'; import 'package:cake_wallet/entities/contact_record.dart'; import 'package:cake_wallet/entities/exchange_api_mode.dart'; @@ -52,6 +53,9 @@ import 'package:cake_wallet/nano/nano.dart'; import 'package:cake_wallet/new-ui/new_dashboard.dart'; import 'package:cake_wallet/new-ui/pages/about_page.dart'; import 'package:cake_wallet/new-ui/pages/account_customizer.dart'; +import 'package:cake_wallet/new-ui/pages/bridge/bridge_amount_page.dart'; +import 'package:cake_wallet/new-ui/pages/bridge/bridge_network_page.dart'; +import 'package:cake_wallet/new-ui/pages/bridge/bridge_receiving_wallet_page.dart'; import 'package:cake_wallet/new-ui/pages/coin_control_page.dart'; import 'package:cake_wallet/new-ui/pages/addresses_page.dart'; import 'package:cake_wallet/new-ui/pages/home_page.dart'; @@ -163,6 +167,8 @@ import 'package:cake_wallet/src/screens/transaction_details/transaction_details_ import 'package:cake_wallet/src/screens/unspent_coins/unspent_coins_details_page.dart'; import 'package:cake_wallet/src/screens/unspent_coins/unspent_coins_list_page.dart'; import 'package:cake_wallet/src/screens/ur/animated_ur_page.dart'; +import 'package:cake_wallet/new-ui/pages/bridge/bridge_detail_page.dart'; +import 'package:cake_wallet/new-ui/pages/bridge/bridge_history_page.dart'; import 'package:cake_wallet/src/screens/wallet/wallet_edit_page.dart'; import 'package:cake_wallet/src/screens/wallet_connect/services/bottom_sheet_service.dart'; import 'package:cake_wallet/src/screens/wallet_connect/services/key_service/wallet_connect_key_service.dart'; @@ -183,6 +189,7 @@ import 'package:cake_wallet/store/dashboard/order_filter_store.dart'; import 'package:cake_wallet/store/dashboard/orders_store.dart'; import 'package:cake_wallet/store/dashboard/payjoin_transactions_store.dart'; import 'package:cake_wallet/store/dashboard/trade_filter_store.dart'; +import 'package:cake_wallet/store/bridge_transfers_store.dart'; import 'package:cake_wallet/store/dashboard/trades_store.dart'; import 'package:cake_wallet/store/dashboard/transaction_filter_store.dart'; import 'package:cake_wallet/store/node_list_store.dart'; @@ -202,6 +209,8 @@ import 'package:cake_wallet/view_model/anon_invoice_page_view_model.dart'; import 'package:cake_wallet/view_model/anonpay_details_view_model.dart'; import 'package:cake_wallet/view_model/auth_view_model.dart'; import 'package:cake_wallet/view_model/backup_view_model.dart'; +import 'package:cake_wallet/view_model/bridge_details_view_model.dart'; +import 'package:cake_wallet/view_model/bridge_history_view_model.dart'; import 'package:cake_wallet/view_model/buy/buy_amount_view_model.dart'; import 'package:cake_wallet/view_model/buy/buy_sell_view_model.dart'; import 'package:cake_wallet/view_model/buy/buy_view_model.dart'; @@ -269,6 +278,7 @@ import 'package:cake_wallet/view_model/transaction_details_view_model.dart'; import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_details_view_model.dart'; import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_item.dart'; import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart'; +import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart'; import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart'; import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_item.dart'; import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart'; @@ -327,7 +337,6 @@ late Box _ordersSource; late Box _unspentCoinsInfoSource; late Box _payjoinSessionSource; late Box _anonpayInvoiceInfoSource; - Future setup({ required Box nodeSource, required Box powNodeSource, @@ -396,6 +405,7 @@ Future setup({ TradesStore(tradesSource: _tradesSource, appStore: getIt.get())); getIt.registerSingleton( OrdersStore(ordersSource: _ordersSource, settingsStore: getIt.get())); + getIt.registerSingleton(BridgeTransfersStore()); getIt.registerFactory(() => PayjoinTransactionsStore(payjoinSessionSource: _payjoinSessionSource)); getIt.registerSingleton(TradeFilterStore()); @@ -1761,11 +1771,45 @@ Future setup({ getIt.registerFactory(() => DEuroSavingsPage(getIt())); + getIt.registerFactory(() => BridgeViewModel( + appStore: getIt.get(), + bridgeTransfersStore: getIt.get(), + walletManager: getIt.get(), + fiatConversionStore: getIt.get(), + settingsStore: getIt.get(), + )); + + getIt.registerFactory(() => BridgeHistoryViewModel( + bridgeTransfersStore: getIt.get(), + appStore: getIt.get(), + )); + getIt.registerFactoryParam( + (BridgeTransfer transfer, _) { + final appStore = getIt.get(); + return BridgeDetailsViewModel( + transferForDetails: transfer, + bridgeTransfersStore: getIt.get(), + walletId: appStore.wallet?.name ?? transfer.walletId, + ); + }); + getIt.registerFactoryParam( + (BridgeTransfer transfer, _) => BridgeDetailPage( + viewModel: getIt.get(param1: transfer), + )); + getIt.registerLazySingleton(() => NodeSwitchingService( appStore: getIt.get(), settingsStore: getIt.get(), nodeSource: _nodeSource, )); + getIt.registerFactoryParam( + (CryptoCurrency initialToken, _) => BridgeAmountPage( + bridgeViewModel: getIt.get(), + bridgeHistoryViewModel: getIt.get(), + initialToken: initialToken, + ), + ); + _isSetupFinished = true; } diff --git a/lib/entities/bridge_transfer.dart b/lib/entities/bridge_transfer.dart new file mode 100644 index 000000000..893f56832 --- /dev/null +++ b/lib/entities/bridge_transfer.dart @@ -0,0 +1,127 @@ +import 'package:cw_core/db/sqlite.dart'; + +class BridgeTransfer { + BridgeTransfer({ + required this.id, + required this.walletId, + required this.sourceChainId, + required this.destinationChainId, + required this.tokenSymbol, + required this.tokenContract, + required this.amount, + required this.recipientAddress, + required this.sourceTxHash, + required this.status, + required this.createdAt, + this.updatedAt, + this.confirmedAt, + this.amountRaw, + this.errorMessage, + this.statusMessage, + }); + + static const tableName = 'BridgeTransfer'; + + static Future> selectAll() async { + final database = db; + if (database == null) return []; + + final rows = await database.query( + tableName, + orderBy: 'created_at DESC', + ); + return rows.map(fromRow).toList(); + } + + static Future insert(BridgeTransfer transfer) async { + final database = db; + if (database == null) return; + + await database.insert(tableName, transfer.toRow()); + } + + static Future update(BridgeTransfer transfer) async { + final database = db; + if (database == null) return; + + await database.update( + tableName, + transfer.toRow(), + where: 'id = ?', + whereArgs: [transfer.id], + ); + } + + String id; + String walletId; + int sourceChainId; + int destinationChainId; + String tokenSymbol; + String tokenContract; + String amount; + String recipientAddress; + String sourceTxHash; + String status; + DateTime createdAt; + DateTime? updatedAt; + DateTime? confirmedAt; + String? amountRaw; + String? errorMessage; + String? statusMessage; + + bool get isActive => status == 'submitted' || status == 'confirming' || status == 'initiated'; + + Map toRow() { + return { + 'id': id, + 'wallet_id': walletId, + 'source_chain_id': sourceChainId, + 'destination_chain_id': destinationChainId, + 'token_symbol': tokenSymbol, + 'token_contract': tokenContract, + 'amount': amount, + 'recipient_address': recipientAddress, + 'source_tx_hash': sourceTxHash, + 'status': status, + 'created_at': createdAt.millisecondsSinceEpoch, + 'updated_at': updatedAt?.millisecondsSinceEpoch, + 'confirmed_at': confirmedAt?.millisecondsSinceEpoch, + 'amount_raw': amountRaw, + 'error_message': errorMessage, + 'status_message': statusMessage, + }; + } + + static int? _nullableInt(Object? v) { + if (v == null) return null; + if (v is int) return v; + if (v is num) return v.toInt(); + return int.tryParse(v.toString()); + } + + static int _parseInt(Object? v) => _nullableInt(v) ?? 0; + static DateTime _parseDateTime(Object? v) => DateTime.fromMillisecondsSinceEpoch(_parseInt(v)); + + static BridgeTransfer fromRow(Map m) { + String? asStr(Object? v) => v as String?; + + return BridgeTransfer( + id: m['id'] as String, + walletId: m['wallet_id'] as String, + sourceChainId: _parseInt(m['source_chain_id']), + destinationChainId: _parseInt(m['destination_chain_id']), + tokenSymbol: m['token_symbol'] as String, + tokenContract: m['token_contract'] as String, + amount: m['amount'] as String, + recipientAddress: m['recipient_address'] as String, + sourceTxHash: m['source_tx_hash'] as String, + status: m['status'] as String, + createdAt: _parseDateTime(m['created_at']), + updatedAt: _parseDateTime(m['updated_at']), + confirmedAt: _parseDateTime(m['confirmed_at']), + amountRaw: asStr(m['amount_raw']), + errorMessage: asStr(m['error_message']), + statusMessage: asStr(m['status_message']), + ); + } +} diff --git a/lib/entities/wallet_manager.dart b/lib/entities/wallet_manager.dart index dd88c9363..bb20a713c 100644 --- a/lib/entities/wallet_manager.dart +++ b/lib/entities/wallet_manager.dart @@ -4,7 +4,6 @@ import 'package:cake_wallet/entities/hash_wallet_identifier.dart'; import 'package:cake_wallet/entities/wallet_group.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; -import 'package:hive/hive.dart'; import 'package:shared_preferences/shared_preferences.dart'; class WalletManager { @@ -163,4 +162,14 @@ class WalletManager { await _sharedPreferences.remove(oldNameKey); } } + + String? getGroupName(WalletInfo walletInfo) { + try { + final groupKey = _resolveGroupKey(walletInfo); + final group = walletGroups.firstWhere((g) => g.groupKey == groupKey); + return group.groupName; + } catch (_) { + return null; + } + } } diff --git a/lib/evm/cw_evm.dart b/lib/evm/cw_evm.dart index ec27c930b..1462dbe3c 100644 --- a/lib/evm/cw_evm.dart +++ b/lib/evm/cw_evm.dart @@ -226,6 +226,22 @@ class CWEVM extends EVM { @override Web3Client? getWeb3Client(WalletBase wallet) => (wallet as EVMChainWallet).getWeb3Client(); + @override + Future getTransactionReceipt(WalletBase wallet, String txHash) async { + final client = getWeb3Client(wallet); + if (client == null) return null; + + try { + final receipt = await client.getTransactionReceipt(txHash); + + if (receipt == null) return null; + + return receipt.status; + } catch (_) { + return null; + } + } + @override String getTokenAddress(CryptoCurrency asset) => (asset as Erc20Token).contractAddress; @@ -492,10 +508,24 @@ class CWEVM extends EVM { chainId: config.chainId, name: config.name, shortCode: config.shortCode, + currency: config.nativeCurrency, )) .toList(); } + @override + ChainInfo? getChainInfoByChainId(int chainId) { + final config = _registry.getChainConfig(chainId); + if (config == null) return null; + + return ChainInfo( + chainId: config.chainId, + name: config.name, + shortCode: config.shortCode, + currency: config.nativeCurrency, + ); + } + @override ChainInfo? getCurrentChain(WalletBase wallet) { if (wallet is EVMChainWallet) { @@ -505,6 +535,7 @@ class CWEVM extends EVM { chainId: config.chainId, name: config.name, shortCode: config.shortCode, + currency: config.nativeCurrency, ); } return null; @@ -542,6 +573,85 @@ class CWEVM extends EVM { bool hasPriorityFee(int chainId) => EVMChainUtils.hasPriorityFee(chainId); @override + bool isUSDT0Token(WalletBase wallet, CryptoCurrency token) { + if (token is! Erc20Token) return false; + + final chainId = getSelectedChainId(wallet); + if (chainId == null) return false; + + return USDT0Config.isUSDT0Token(token, chainId); + } + + @override + List getUSDT0DestinationChains(WalletBase wallet) { + final currentChainId = getSelectedChainId(wallet); + if (currentChainId == null) return []; + + final result = []; + for (final config in _registry.getAllChains()) { + if (USDT0Config.isChainSupported(config.chainId) && config.chainId != currentChainId) { + result.add(ChainInfo( + chainId: config.chainId, + name: config.name, + shortCode: config.shortCode, + currency: config.nativeCurrency, + )); + } + } + return result; + } + + @override + Future quoteUSDT0Transfer({ + required WalletBase wallet, + required int sourceChainId, + required int destinationChainId, + required BigInt amount, + required String recipientAddress, + }) { + final evmWallet = wallet as EVMChainWallet; + final client = evmWallet.getWeb3Client(); + if (client == null) { + throw StateError('Wallet not connected'); + } + + return USDT0Service.quoteCrossChainTransfer( + client: client, + sourceChainId: sourceChainId, + destinationChainId: destinationChainId, + amount: amount, + recipientAddress: recipientAddress, + ); + } + + @override + Future executeUSDT0Transfer({ + required WalletBase wallet, + required CryptoCurrency token, + required int sourceChainId, + required int destinationChainId, + required BigInt amount, + required String recipientAddress, + required USDT0Quote quote, + required TransactionPriority priority, + bool useBlinkProtection = true, + }) { + final evmWallet = wallet as EVMChainWallet; + final tokenErc20 = token as Erc20Token; + + return USDT0Service.executeCrossChainTransfer( + wallet: evmWallet, + sourceChainId: sourceChainId, + destinationChainId: destinationChainId, + amount: amount, + recipientAddress: recipientAddress, + quote: quote, + token: tokenErc20, + priority: priority as EVMChainTransactionPriority, + useBlinkProtection: useBlinkProtection, + + ); + } Future getWCBufferedFeeQuote( WalletBase wallet, TransactionPriority priority, diff --git a/lib/main.dart b/lib/main.dart index 3d3615e4d..8a4690d56 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -362,10 +362,10 @@ Future initialSetup({ powNodeSource: powNodes, contactSource: contactSource, tradesSource: tradesSource, + ordersSource: ordersSource, templates: templates, exchangeTemplates: exchangeTemplates, transactionDescriptionBox: transactionDescriptions, - ordersSource: ordersSource, anonpayInvoiceInfoSource: anonpayInvoiceInfo, unspentCoinsInfoSource: unspentCoinsInfoSource, payjoinSessionSource: payjoinSessionSource, diff --git a/lib/new-ui/pages/bridge/bridge_amount_page.dart b/lib/new-ui/pages/bridge/bridge_amount_page.dart new file mode 100644 index 000000000..42dc27c2f --- /dev/null +++ b/lib/new-ui/pages/bridge/bridge_amount_page.dart @@ -0,0 +1,305 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/new-ui/pages/bridge/bridge_history_page.dart'; +import 'package:cake_wallet/routes.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart'; +import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart'; +import 'package:cake_wallet/utils/request_review_handler.dart'; +import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart'; +import 'package:cake_wallet/new-ui/widgets/modern_button.dart'; +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; +import 'package:cake_wallet/view_model/bridge_history_view_model.dart'; +import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; + +class BridgeAmountPage extends StatefulWidget { + const BridgeAmountPage({ + super.key, + required this.bridgeViewModel, + required this.bridgeHistoryViewModel, + required this.initialToken, + }); + + final BridgeViewModel bridgeViewModel; + final BridgeHistoryViewModel bridgeHistoryViewModel; + + final CryptoCurrency initialToken; + + @override + State createState() => _BridgeAmountPageState(); +} + +class _BridgeAmountPageState extends State { + late final TextEditingController _amountController; + BridgeViewModel get bridgeViewModel => widget.bridgeViewModel; + + @override + void initState() { + super.initState(); + bridgeViewModel.applyInitialBridgeToken(widget.initialToken); + bridgeViewModel.onBridgeSuccess = _showBridgeSuccessBottomSheet; + + _amountController = TextEditingController(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + bridgeViewModel.ensureFiatPriceForSelectedToken(); + }); + } + + void _showBridgeSuccessBottomSheet() { + if (!mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + final ctx = context; + if (!ctx.mounted) return; + + await showModalBottomSheet( + context: ctx, + isScrollControlled: true, + builder: (BuildContext bottomSheetContext) { + return InfoBottomSheet( + footerType: FooterType.doubleActionButton, + titleText: 'Bridge initiated!', + contentImage: 'assets/images/birthday_cake.png', + content: 'The bridging will take between 30 seconds and 3 ' + 'minutes to complete.', + doubleActionLeftButtonText: S.of(bottomSheetContext).close, + doubleActionRightButtonText: 'View history', + onLeftActionButtonPressed: () { + bridgeViewModel.clearBridgeSuccess(); + Navigator.of(context, rootNavigator: true).pop(); + RequestReviewHandler.requestReview(); + }, + onRightActionButtonPressed: () { + bridgeViewModel.clearBridgeSuccess(); + Navigator.of(context).popUntil((route) => route.isFirst); + showMaterialModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) => BridgeHistoryPage(widget.bridgeHistoryViewModel), + ); + }, + ); + }, + ); + }); + } + + @override + void dispose() { + bridgeViewModel.onBridgeSuccess = null; + bridgeViewModel.setAmount(''); + _amountController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return KeyboardHideOverlay( + unfocusOnTap: true, + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + ModalTopBar( + title: 'Enter Amount', + leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18), + onLeadingPressed: () => Navigator.of(context, rootNavigator: true).pop(), + trailingIcon: const Icon(Icons.calendar_month, size: 18), + onTrailingPressed: () { + Navigator.pushNamed(context, Routes.bridgeHistoryPage, arguments: widget.bridgeHistoryViewModel); + }, + ), + Expanded( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Observer( + builder: (_) { + final canNext = bridgeViewModel.canProceedToDestinationNetwork; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Spacer(flex: 2), + LayoutBuilder( + builder: (context, constraints) { + return Center( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: (constraints.maxWidth - 200) + .clamp(40.0, constraints.maxWidth), + minWidth: 40, + ), + child: TextFormField( + controller: _amountController, + maxLines: 1, + onChanged: bridgeViewModel.setAmount, + autovalidateMode: AutovalidateMode.always, + validator: bridgeViewModel.decimalAmountValidator, + keyboardType: TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'^\d*[.,]?\d*$'), + ), + ], + textAlign: TextAlign.center, + decoration: InputDecoration( + isDense: true, + hintText: '0.00', + hintStyle: theme.textTheme.displayMedium?.copyWith( + fontWeight: FontWeight.w400, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + style: theme.textTheme.displayMedium?.copyWith( + fontWeight: FontWeight.w400, + fontSize: 45, + color: theme.colorScheme.onSurface, + ), + ), + ), + const SizedBox(width: 8), + Text( + bridgeViewModel.selectedToken?.title ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.displayMedium?.copyWith( + fontWeight: FontWeight.w400, + fontSize: 45, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 12), + Center( + child: Text( + bridgeViewModel.fiatAmountFormatted.isEmpty + ? '' + : '~${bridgeViewModel.fiatAmountFormatted} ${bridgeViewModel.fiatCurrencyTitle}', + style: theme.textTheme.headlineSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + fontSize: 22, + letterSpacing: -0.11, + ), + ), + ), + if (bridgeViewModel.amountError != null) ...[ + const SizedBox(height: 10), + Text( + bridgeViewModel.amountError!, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + const Spacer(flex: 3), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: RichText( + maxLines: 2, + overflow: TextOverflow.ellipsis, + text: TextSpan( + children: [ + TextSpan( + text: 'Available ', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w400, + fontSize: 16, + letterSpacing: -0.08, + ), + ), + TextSpan( + text: '${bridgeViewModel.tokenBalanceFormatted} ' + '${bridgeViewModel.selectedToken?.title ?? ''}', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w400, + fontSize: 16, + letterSpacing: -0.08, + ), + ), + ], + ), + ), + ), + const SizedBox(width: 10), + TextButton( + style: TextButton.styleFrom( + textStyle: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w400, + fontSize: 14, + letterSpacing: -0.07, + ), + foregroundColor: theme.colorScheme.primary, + backgroundColor: theme.colorScheme.surfaceContainer, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(80), + ), + ), + onPressed: () { + bridgeViewModel.setMaxAmount(); + _amountController.text = bridgeViewModel.amount; + _amountController.selection = TextSelection.collapsed( + offset: _amountController.text.length, + ); + }, + child: Text(S.of(context).max), + ), + const SizedBox(width: 10), + IgnorePointer( + ignoring: !canNext, + child: ModernButton( + size: 48, + backgroundColor: theme.colorScheme.primary, + iconColor: theme.colorScheme.onPrimary, + icon: Icon(Icons.arrow_forward, size: 25), + onPressed: () { + _amountController.clear(); + Navigator.pushNamed( + context, Routes.bridgeDestinationNetworkPage, arguments: bridgeViewModel); + }, + ), + ), + ], + ), + ], + ); + }, + ), + ), + ), + ), + ], + ), + ), + ); + } +} + diff --git a/lib/new-ui/pages/bridge/bridge_confirm_sheet.dart b/lib/new-ui/pages/bridge/bridge_confirm_sheet.dart new file mode 100644 index 000000000..211e25733 --- /dev/null +++ b/lib/new-ui/pages/bridge/bridge_confirm_sheet.dart @@ -0,0 +1,227 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/new-ui/widgets/bridge/confirm_details_card.dart'; +import 'package:cake_wallet/new-ui/widgets/bridge/network_path_pill.dart'; +import 'package:cake_wallet/new-ui/widgets/confirm_swiper.dart'; +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; +import 'package:cake_wallet/new-ui/widgets/send_page/send_confirm_bottom_widget.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; +import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:mobx/mobx.dart'; + +class BridgeConfirmSheet extends StatefulWidget { + const BridgeConfirmSheet(this.bridgeViewModel); + + final BridgeViewModel bridgeViewModel; + + @override + State createState() => _BridgeConfirmSheetState(); +} + +class _BridgeConfirmSheetState extends State { + late final ReactionDisposer _successDisposer; + + BridgeViewModel get bridgeViewModel => widget.bridgeViewModel; + + @override + void initState() { + super.initState(); + _successDisposer = reaction( + (_) => bridgeViewModel.bridgeSuccess, + (bool success) { + if (success && mounted) { + Navigator.of(context).maybePop(); + } + }, + ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + bridgeViewModel.loadQuote(); + bridgeViewModel.ensureFiatPriceForSelectedToken(); + bridgeViewModel.ensureFiatPriceForNativeCurrency(); + }); + } + + @override + void dispose() { + _successDisposer(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Container( + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Observer( + builder: (_) { + return ModalTopBar( + title: '', + leadingWidget: Row( + spacing: 8, + children: [ + CakeImageWidget( + imageUrl: bridgeViewModel.selectedToken?.iconPath ?? '', + width: 36, + height: 36, + ), + Text( + 'Bridge', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 20, + letterSpacing: -0.1, + color: scheme.onSurface, + ), + ), + ], + ), + trailingIcon: const Icon(Icons.close), + onTrailingPressed: () => Navigator.of(context).maybePop(), + ); + }, + ), + SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Observer( + builder: (_) { + if (bridgeViewModel.isQuoteLoading && bridgeViewModel.quote == null) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 48), + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + if (bridgeViewModel.quoteError != null) { + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Column( + children: [ + Text( + bridgeViewModel.quoteError!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w400, + fontSize: 14, + letterSpacing: -0.07, + color: scheme.error, + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () => bridgeViewModel.loadQuote(), + child: Text( + S.of(context).try_again, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 16, + letterSpacing: -0.08, + color: scheme.onPrimary, + ), + ), + ), + ], + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 24, + children: [ + Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 4, + children: [ + Text( + bridgeViewModel.amountDisplayFormatted, + style: Theme.of(context).textTheme.displaySmall?.copyWith( + fontSize: 36, + fontWeight: FontWeight.w400, + color: scheme.onSurface, + ), + ), + Text( + bridgeViewModel.selectedToken?.title ?? '', + style: Theme.of(context).textTheme.displaySmall?.copyWith( + fontSize: 36, + fontWeight: FontWeight.w400, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + '${bridgeViewModel.fiatCurrencyTitle} ${bridgeViewModel.fiatAmountFormatted}', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontSize: 20, + fontWeight: FontWeight.w500, + color: scheme.onSurfaceVariant, + letterSpacing: -0.1, + ), + ), + ], + ), + NetworkPathPill( + sourceChainName: bridgeViewModel.wallet.type.name, + destChainName: bridgeViewModel.destinationChainInfo?.name ?? '', + ), + ConfirmDetailsCard(bridgeViewModel: bridgeViewModel), + if (bridgeViewModel.executeError != null) ...[ + Text( + bridgeViewModel.executeError!, + textAlign: TextAlign.center, + style: TextStyle(color: scheme.error), + ), + ], + const SizedBox(height: 8), + ], + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Observer( + builder: (_) { + if (bridgeViewModel.isExecuting) { + return const LoadingBottomWidget( + text: 'Bridging...', + ); + } + final canSwipe = bridgeViewModel.quote != null && !bridgeViewModel.isQuoteLoading; + return IgnorePointer( + ignoring: !canSwipe, + child: Opacity( + opacity: canSwipe ? 1 : 0.45, + child: ConfirmSwiper( + onConfirmed: () { + bridgeViewModel.executeBridge(); + }, + swiperText: 'Swipe to bridge', + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/new-ui/pages/bridge/bridge_detail_page.dart b/lib/new-ui/pages/bridge/bridge_detail_page.dart new file mode 100644 index 000000000..a5076ad14 --- /dev/null +++ b/lib/new-ui/pages/bridge/bridge_detail_page.dart @@ -0,0 +1,160 @@ +import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; +import 'package:cake_wallet/src/screens/trade_details/track_trade_list_item.dart'; +import 'package:cake_wallet/src/screens/transaction_details/address_list_item.dart'; +import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart'; +import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart'; +import 'package:cake_wallet/themes/core/custom_theme_colors.dart'; +import 'package:cake_wallet/utils/address_formatter.dart'; +import 'package:cake_wallet/utils/show_bar.dart'; +import 'package:cake_wallet/view_model/bridge_details_view_model.dart'; +import 'package:cw_core/generate_name.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class BridgeDetailPage extends StatelessWidget { + BridgeDetailPage({super.key, required this.viewModel}); + + final BridgeDetailsViewModel viewModel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.6, + minChildSize: 0.25, + maxChildSize: 0.9, + snap: true, + snapSizes: const [0.9], + builder: (context, controller) { + return SafeArea( + bottom: false, + child: Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + children: [ + ModalTopBar( + title: "Bridge Transfer Details", + leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18), + onLeadingPressed: () => Navigator.of(context).pop(), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + NewListSections( + sections: { + "": viewModel.items + .where((item) => item is! TrackTradeListItem) + .map((item) { + return ListItemRegularRow( + onTap: () { + Clipboard.setData(ClipboardData(text: item.value)); + showBar(context, S.of(context).copied_to_clipboard); + }, + showArrow: false, + keyValue: item.title, + label: item.title, + trailingWidget: _buildTrailingWidget(item, context), + bottomWidget: _buildBottomWidget(item, context), + ); + }).toList(), + }, + ), + SizedBox(height: 16), + NewListSections(sections: { + "": viewModel.items + .where((item) => item is TrackTradeListItem) + .map((item) { + return ListItemRegularRow( + keyValue: "view tx on", + label: item.title, + onTap: () => (item as TrackTradeListItem).onTap(), + foregroundColor: Theme.of(context).colorScheme.primary, + trailingIconPath: "assets/new-ui/link_arrow.svg", + trailingIconSize: 8, + ); + }).toList() + }), + ], + ), + ), + ), + SizedBox(height: MediaQuery.of(context).viewPadding.bottom) + ], + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildBottomWidget(TransactionDetailsListItem item, BuildContext context) { + if (item is AddressListItem) { + return AddressFormatter.buildSegmentedAddress( + address: item.value, + evenTextStyle: TextStyle( + fontSize: 12, + fontFamily: "IBM Plex Mono", + color: Theme.of(context).colorScheme.onSurface)); + } + + final isCompleted = item.value.contains("Completed"); + final isInitiated = item.value.contains("initiated"); + if (isCompleted || isInitiated) { + return Row( + children: [ + Container( + height: 8, + width: 8, + decoration: BoxDecoration( + color: isCompleted ? CustomThemeColors.syncGreen : CustomThemeColors.syncYellow, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.surface, + width: 1.5, + ), + ), + ), + SizedBox(width: 8), + Text( + item.value, + style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ); + } + + return SizedBox.shrink(); + } + + Widget _buildTrailingWidget(TransactionDetailsListItem item, BuildContext context) { + final isCompleted = item.value.contains("Completed"); + final isInitiated = item.value.contains("initiated"); + if (item is AddressListItem || isCompleted || isInitiated) { + return SizedBox.shrink(); + } + + return Text( + item.value.capitalized(), + style: TextStyle( + fontWeight: FontWeight.w400, + fontFamily: 'Wix Madefor Text', + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } +} diff --git a/lib/new-ui/pages/bridge/bridge_history_page.dart b/lib/new-ui/pages/bridge/bridge_history_page.dart new file mode 100644 index 000000000..a50cc5642 --- /dev/null +++ b/lib/new-ui/pages/bridge/bridge_history_page.dart @@ -0,0 +1,117 @@ +import 'package:cake_wallet/di.dart'; +import 'package:cake_wallet/new-ui/pages/bridge/bridge_detail_page.dart'; +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; +import 'package:cake_wallet/view_model/bridge_history_view_model.dart'; +import 'package:cake_wallet/new-ui/widgets/bridge/transfer_history_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; + +class BridgeHistoryPage extends StatelessWidget { + BridgeHistoryPage(this.viewModel); + + final BridgeHistoryViewModel viewModel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + children: [ + ModalTopBar( + title: "Bridge history", + leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18), + onLeadingPressed: () => Navigator.of(context).pop(), + ), + Expanded( + child: Observer( + builder: (_) { + if (viewModel.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + "No bridge transfers yet.", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ); + } + + final active = viewModel.activeTransfers; + final past = viewModel.pastTransfers; + final items = []; + + if (active.isNotEmpty) { + items.add( + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + "In progress", + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ); + for (final transfer in active) { + items.add( + TransferHistoryRow( + transfer: transfer, + onTap: () { + final page = getIt.get(param1: transfer); + showModalBottomSheet( + backgroundColor: Colors.transparent, + isScrollControlled: true, + context: context, + builder: (context) => page, + ); + }, + ), + ); + } + } + + if (past.isNotEmpty) { + items.add( + Padding( + padding: const EdgeInsets.only(bottom: 8.0, top: 16), + child: Text( + "Past", + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ); + for (final transfer in past) { + items.add( + TransferHistoryRow( + transfer: transfer, + onTap: () { + final page = getIt.get(param1: transfer); + showModalBottomSheet( + backgroundColor: Colors.transparent, + isScrollControlled: true, + context: context, + builder: (context) => page, + ); + }, + ), + ); + } + } + + return ListView( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), + children: items, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/new-ui/pages/bridge/bridge_network_page.dart b/lib/new-ui/pages/bridge/bridge_network_page.dart new file mode 100644 index 000000000..0c29cdee4 --- /dev/null +++ b/lib/new-ui/pages/bridge/bridge_network_page.dart @@ -0,0 +1,98 @@ +import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart'; +import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart'; +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; +import 'package:cake_wallet/routes.dart'; +import 'package:cake_wallet/src/widgets/new_list_row/list_item_regular_row_widget.dart'; +import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart'; +import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart'; +import 'package:cw_core/generate_name.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; + +class BridgeNetworkPage extends StatelessWidget { + const BridgeNetworkPage(this.bridgeViewModel); + + final BridgeViewModel bridgeViewModel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return KeyboardHideOverlay( + unfocusOnTap: true, + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + children: [ + ModalTopBar( + title: 'Destination Network', + leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18), + onLeadingPressed: () => Navigator.of(context).pop(), + ), + SizedBox(height: 48), + Padding( + padding: const EdgeInsets.fromLTRB(18, 8, 18, 16), + child: Text( + 'Select what Network to transfer your assets to', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + SizedBox(height: 24), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Column( + children: [ + Observer( + builder: (_) { + return NewListSections( + sections: { + '': bridgeViewModel.availableDestinationChains.map( + (chain) { + final chainName = chain.name; + return ListItemRegularRow( + iconPath: 'assets/images/crypto/${chainName.toLowerCase()}.webp', + keyValue: chain.chainId.toString(), + label: chainName, + onTap: () { + bridgeViewModel.setDestinationChain(chain.chainId); + Navigator.pushNamed(context, Routes.bridgeReceivingWalletPage, arguments: bridgeViewModel); + }, + ); + }, + ).toList(), + }, + ); + }, + ), + SizedBox(height: 24), + Observer( + builder: (_) { + final src = bridgeViewModel.wallet.type.name; + + return ListItemRegularRowWidget( + isFirstInSection: true, + isLastInSection: true, + keyValue: 'sending_from', + label: 'Sending from', + trailingIconPath: 'assets/new-ui/chain_badges/${src.toLowerCase()}.svg', + trailingText: src.capitalized(), + ); + }, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/new-ui/pages/bridge/bridge_receive_address_input_page.dart b/lib/new-ui/pages/bridge/bridge_receive_address_input_page.dart new file mode 100644 index 000000000..0f322ba7d --- /dev/null +++ b/lib/new-ui/pages/bridge/bridge_receive_address_input_page.dart @@ -0,0 +1,160 @@ +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/new-ui/pages/bridge/bridge_confirm_sheet.dart'; +import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart'; +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; +import 'package:cake_wallet/new-ui/widgets/send_page/send_address_input.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; +import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; + +class BridgeReceiveAddressInputPage extends StatefulWidget { + const BridgeReceiveAddressInputPage({super.key, required this.bridgeViewModel}); + + final BridgeViewModel bridgeViewModel; + + @override + State createState() => _BridgeReceiveAddressInputPageState(); +} + +class _BridgeReceiveAddressInputPageState extends State { + BridgeViewModel get bridgeViewModel => widget.bridgeViewModel; + + late final TextEditingController _controller; + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + _controller.addListener(() => setState(() {})); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return KeyboardHideOverlay( + unfocusOnTap: true, + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + children: [ + ModalTopBar( + title: 'Input Receive Address', + leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18), + onLeadingPressed: () => Navigator.of(context).pop(), + ), + SizedBox(height: 48), + Expanded( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Observer( + builder: (_) { + final chainName = bridgeViewModel.destinationChainInfo?.name; + + return Column( + children: [ + Text( + 'Make sure it is compatible with:', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + letterSpacing: -0.07, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CakeImageWidget( + imageUrl: + 'assets/new-ui/chain_badges/${chainName?.toLowerCase()}.svg', + width: 24, + height: 24, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + chainName ?? '', + style: theme.textTheme.bodyMedium?.copyWith( + fontSize: 14, + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w400, + letterSpacing: -0.07, + ), + ), + ], + ), + ], + ); + }, + ), + const SizedBox(height: 24), + NewSendAddressInput( + hintText: 'Enter Address', + addressController: _controller, + focusNode: _focusNode, + selectedCurrency: bridgeViewModel.wallet.currency, + onEditingComplete: () {}, + validator: AddressValidator(type: bridgeViewModel.destinationChainInfo!.currency), + ), + const Spacer(), + Observer( + builder: (_) { + return FilledButton( + onPressed: () { + final overlayCtx = Navigator.of(context).overlay?.context; + bridgeViewModel.setRecipientAddress(_controller.text.trim()); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (overlayCtx != null && overlayCtx.mounted) { + showMaterialModalBottomSheet( + context: overlayCtx, + backgroundColor: Colors.transparent, + builder: (ctx) => + BridgeConfirmSheet(bridgeViewModel), + ); + } + }); + }, + style: FilledButton.styleFrom( + padding: const EdgeInsets.all(16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + ), + child: Text(S.of(context).continue_text), + ); + }, + ), + const SizedBox(height: 12), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/new-ui/pages/bridge/bridge_receiving_wallet_page.dart b/lib/new-ui/pages/bridge/bridge_receiving_wallet_page.dart new file mode 100644 index 000000000..741763c00 --- /dev/null +++ b/lib/new-ui/pages/bridge/bridge_receiving_wallet_page.dart @@ -0,0 +1,264 @@ +import 'package:cake_wallet/view_model/bridge/bridge_receiving_wallet_option.dart'; +import 'package:cake_wallet/new-ui/pages/bridge/bridge_confirm_sheet.dart'; +import 'package:cake_wallet/new-ui/pages/bridge/bridge_receive_address_input_page.dart'; +import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart'; +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; +import 'package:cake_wallet/src/widgets/new_list_row/list_Item_style_wrapper.dart'; +import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; + +class BridgeReceivingWalletPage extends StatefulWidget { + const BridgeReceivingWalletPage(this.bridgeViewModel); + + final BridgeViewModel bridgeViewModel; + + @override + State createState() => _BridgeReceivingWalletPageState(); +} + +class _BridgeReceivingWalletPageState extends State { + BridgeViewModel get bridgeViewModel => widget.bridgeViewModel; + + @override + void initState() { + super.initState(); + bridgeViewModel.loadReceivingWalletOptions(); + } + + @override + Widget build(BuildContext context) { + return KeyboardHideOverlay( + unfocusOnTap: true, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + children: [ + ModalTopBar( + title: 'Receiving Wallet', + leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18), + onLeadingPressed: () => Navigator.of(context).pop(), + ), + const SizedBox(height: 48), + Expanded( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Observer( + builder: (_) { + final chain = bridgeViewModel.destinationChainInfo; + + final chainName = chain?.name ?? ''; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8), + Center( + child: CakeImageWidget( + borderRadius: 8, + imageUrl: chainName.isNotEmpty + ? 'assets/images/crypto/${chainName.toLowerCase()}.webp' + : null, + width: 72, + height: 72, + ), + ), + const SizedBox(height: 20), + Text( + 'Select a $chainName wallet or address to send ' + 'the bridged USDT0 to.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w400, + fontSize: 14, + letterSpacing: -0.07, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + if (bridgeViewModel.isBridgeReceivingWalletListLoading) + const Expanded( + child: Center( + child: CircularProgressIndicator(), + ), + ) + else + Expanded( + child: ListView( + padding: const EdgeInsets.only(bottom: 16), + children: _receivingListRows(context), + ), + ), + ], + ); + }, + ), + ), + ), + ), + ], + ), + ), + ); + } + + List _receivingListRows(BuildContext context) { + final options = bridgeViewModel.bridgeReceivingWalletOptions; + final rows = []; + + for (int i = 0; i < options.length; i++) { + final option = options[i]; + + if (i > 0) rows.add(const SizedBox(height: 12)); + + rows.add( + _BridgeReceivingWalletRow( + option: option, + onTap: () { + bridgeViewModel.setRecipientAddress(option.address, destWalletName: option.name); + + showMaterialModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) => BridgeConfirmSheet(bridgeViewModel), + ); + }, + ), + ); + } + + if (options.isNotEmpty) rows.add(const SizedBox(height: 12)); + + rows.add( + ListItemStyleWrapper( + isFirstInSection: true, + isLastInSection: true, + height: 48, + contentPadding: const EdgeInsets.all(12), + onTap: () { + showMaterialModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) => BridgeReceiveAddressInputPage(bridgeViewModel: bridgeViewModel), + ); + }, + builder: (ctx, textStyle, _) { + final scheme = Theme.of(ctx).colorScheme; + return Row( + children: [ + Icon( + Icons.edit_outlined, + size: 24, + color: scheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Input an Address', + style: textStyle.copyWith( + color: scheme.primary, + fontSize: 15, + fontWeight: FontWeight.w500, + letterSpacing: -0.3, + ), + ), + ), + ], + ); + }, + ), + ); + + return rows; + } +} + +class _BridgeReceivingWalletRow extends StatelessWidget { + const _BridgeReceivingWalletRow({ + required this.option, + required this.onTap, + }); + + final BridgeReceivingWalletOption option; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final hasGroupRow = option.groupLabel != null; + + return ListItemStyleWrapper( + onTap: onTap, + isFirstInSection: true, + isLastInSection: true, + height: hasGroupRow ? 62.0 : 48.0, + builder: (ctx, textStyle, labelStyle) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + option.name, + style: textStyle.copyWith(letterSpacing: -0.07), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (hasGroupRow) ...[ + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.account_balance_wallet_outlined, + size: 16, + color: Theme.of(ctx).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + option.groupLabel!, + style: labelStyle.copyWith( + fontSize: 12, + letterSpacing: -0.06, + color: Theme.of(ctx).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (option.isCurrent) ...[ + const SizedBox(width: 8), + Text( + 'Current', + style: textStyle.copyWith( + fontSize: 12, + color: Theme.of(ctx).colorScheme.primary, + letterSpacing: -0.06, + ), + ), + ], + ], + ), + ], + ], + ), + ), + CakeImageWidget( + imageUrl: 'assets/new-ui/arrow_forward.svg', + height: 16, + color: Theme.of(ctx).colorScheme.onSurfaceVariant, + ), + ], + ); + }, + ); + } +} diff --git a/lib/new-ui/widgets/bridge/confirm_details_card.dart b/lib/new-ui/widgets/bridge/confirm_details_card.dart new file mode 100644 index 000000000..2ef33f0fe --- /dev/null +++ b/lib/new-ui/widgets/bridge/confirm_details_card.dart @@ -0,0 +1,62 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/new-ui/widgets/bridge/expandable_details_card.dart'; +import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; + +class ConfirmDetailsCard extends StatelessWidget { + const ConfirmDetailsCard({required this.bridgeViewModel}); + + final BridgeViewModel bridgeViewModel; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Observer( + builder: (_) { + final feeParts = [ + bridgeViewModel.quoteNativeFeeFormattedForDisplay, + bridgeViewModel.quoteNativeFiatFeeFormattedForDisplay, + ]; + final feeLine = feeParts.join(' '); + + return Container( + decoration: BoxDecoration( + color: scheme.surfaceContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + ExpandableBridgeDetailRow( + label: S.of(context).from, + title: bridgeViewModel.wallet.name, + address: bridgeViewModel.sourceAddress, + showChevron: true, + ), + Divider( + height: 1, + color: scheme.surfaceContainerHigh, + ), + ExpandableBridgeDetailRow( + label: S.of(context).to, + title: bridgeViewModel.destinationWalletName ?? bridgeViewModel.recipientAddress.trim(), + address: bridgeViewModel.recipientAddress.trim(), + showChevron: bridgeViewModel.destinationWalletName != null, + ), + Divider( + height: 1, + color: scheme.surfaceContainerHigh, + ), + ExpandableBridgeDetailRow( + label: S.of(context).fee, + title: feeLine, + showChevron: false, + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/new-ui/widgets/bridge/expandable_details_card.dart b/lib/new-ui/widgets/bridge/expandable_details_card.dart new file mode 100644 index 000000000..22e81bee4 --- /dev/null +++ b/lib/new-ui/widgets/bridge/expandable_details_card.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; + +class ExpandableBridgeDetailRow extends StatefulWidget { + const ExpandableBridgeDetailRow({ + required this.label, + required this.title, + this.address, + this.showChevron = false, + }); + + final String label; + final String title; + final String? address; + final bool showChevron; + + @override + State createState() => ExpandableBridgeDetailRowState(); +} + +class ExpandableBridgeDetailRowState extends State { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final showChevron = widget.showChevron; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InkWell( + onTap: showChevron ? () => setState(() => _expanded = !_expanded) : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scheme.onSurface, + fontWeight: FontWeight.w400, + fontSize: 14, + letterSpacing: -0.07, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Text( + widget.title, + textAlign: TextAlign.end, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w400, + fontSize: 14, + letterSpacing: -0.07, + color: scheme.onSurfaceVariant, + ), + ), + ), + if (showChevron) ...[ + const SizedBox(width: 2), + AnimatedRotation( + turns: _expanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + child: Icon( + Icons.keyboard_arrow_down, + size: 22, + color: scheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: showChevron && _expanded + ? Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), + child: Align( + alignment: Alignment.centerRight, + child: SelectableText( + widget.address ?? '', + textAlign: TextAlign.end, + style: TextStyle( + fontSize: 13, + height: 1.35, + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w400, + ), + ), + ), + ) + : const SizedBox.shrink(), + ), + ], + ); + } +} diff --git a/lib/new-ui/widgets/bridge/network_path_pill.dart b/lib/new-ui/widgets/bridge/network_path_pill.dart new file mode 100644 index 000000000..762f5e2f4 --- /dev/null +++ b/lib/new-ui/widgets/bridge/network_path_pill.dart @@ -0,0 +1,89 @@ +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; +import 'package:cw_core/generate_name.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; + +class NetworkPathPill extends StatelessWidget { + const NetworkPathPill({ + required this.sourceChainName, + required this.destChainName, + this.showBackground = true, + }); + + final String sourceChainName; + final String destChainName; + final bool showBackground; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final body = Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + CakeImageWidget( + imageUrl: 'assets/new-ui/chain_badges/${sourceChainName.toLowerCase()}.svg', + width: 24, + height: 24, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + sourceChainName.capitalized(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: scheme.onSurface, + fontWeight: FontWeight.w400, + fontSize: 16, + letterSpacing: -0.08, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Icon( + Icons.arrow_forward, + size: 16, + color: scheme.primary, + ), + ), + CakeImageWidget( + imageUrl: 'assets/new-ui/chain_badges/${destChainName.toLowerCase()}.svg', + width: 24, + height: 24, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + destChainName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: scheme.onSurface, + fontWeight: FontWeight.w400, + fontSize: 16, + letterSpacing: -0.08, + ), + ), + ), + ], + ); + return Observer( + builder: (_) { + return showBackground + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: scheme.surfaceContainer, + borderRadius: BorderRadius.circular(80), + ), + child: body, + ) + : body; + }, + ); + } +} diff --git a/lib/new-ui/widgets/bridge/transfer_history_row.dart b/lib/new-ui/widgets/bridge/transfer_history_row.dart new file mode 100644 index 000000000..6a9b3e7e0 --- /dev/null +++ b/lib/new-ui/widgets/bridge/transfer_history_row.dart @@ -0,0 +1,180 @@ +import 'package:cake_wallet/entities/bridge_transfer.dart'; +import 'package:cake_wallet/evm/evm.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; +import 'package:cake_wallet/themes/core/custom_theme_colors.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class TransferHistoryRow extends StatelessWidget { + const TransferHistoryRow({ + required this.transfer, + required this.onTap, + super.key, + }); + + final BridgeTransfer transfer; + final VoidCallback onTap; + + String _statusLabel(String status) { + switch (status) { + case 'submitted': + return "Submitted"; + case 'confirming': + return "Confirming on source"; + case 'initiated': + return "Bridge initiated"; + case 'completed': + return "Completed"; + case 'failed': + return "Failed"; + default: + return status; + } + } + + Color _statusColor(BuildContext context, String status) { + switch (status) { + case 'completed': + return CustomThemeColors.syncGreen; + case 'failed': + return Theme.of(context).colorScheme.error; + case 'submitted': + case 'confirming': + case 'initiated': + default: + return CustomThemeColors.syncYellow; + } + } + + @override + Widget build(BuildContext context) { + final sourceChainName = evm?.getChainInfoByChainId(transfer.sourceChainId)?.name; + final destChainName = evm?.getChainInfoByChainId(transfer.destinationChainId)?.name; + final formattedDate = DateFormat('HH:mm').format(transfer.createdAt); + final statusText = _statusLabel(transfer.status); + + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + margin: const EdgeInsets.only(bottom: 16), + width: double.infinity, + height: 72, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.surfaceContainerHigh, + Theme.of(context).colorScheme.surfaceContainer, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + borderRadius: BorderRadius.all(Radius.circular(20)), + border: Border.all( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + height: 42, + width: 42, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + ), + child: Stack( + children: [ + CakeImageWidget( + imageUrl: 'assets/images/crypto/${sourceChainName?.toLowerCase()}.webp', + width: 28, + height: 28, + ), + Positioned( + top: 14, + left: 12, + child: CakeImageWidget( + imageUrl: 'assets/images/crypto/${destChainName?.toLowerCase()}.webp', + width: 28, + height: 28, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '$sourceChainName → $destChainName', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + Text( + '${transfer.amount} ${transfer.tokenSymbol}', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + height: 8, + width: 8, + decoration: BoxDecoration( + color: _statusColor(context, transfer.status), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.surface, + width: 1.5, + ), + ), + ), + SizedBox(width: 8), + Text( + statusText, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + Text( + formattedDate, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/new-ui/widgets/coins_page/assets_history/asset_details_modal.dart b/lib/new-ui/widgets/coins_page/assets_history/asset_details_modal.dart index 77fca71fe..154d574ea 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/asset_details_modal.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/asset_details_modal.dart @@ -2,6 +2,7 @@ import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/new-ui/modal_navigator.dart'; +import 'package:cake_wallet/new-ui/pages/bridge/bridge_amount_page.dart'; import 'package:cake_wallet/new-ui/pages/receive_page.dart'; import 'package:cake_wallet/new-ui/pages/send_page.dart'; import 'package:cake_wallet/new-ui/pages/swap_page.dart'; @@ -31,6 +32,7 @@ class AssetDetailsModal extends StatelessWidget { required this.mode, required this.wallet, required this.showSwap, + required this.showBridgeButton, this.asset}); final String title; @@ -44,6 +46,7 @@ class AssetDetailsModal extends StatelessWidget { final String chainIconPath; final WalletBase wallet; final bool showSwap; + final bool showBridgeButton; final AssetDetailsModalModes mode; @override @@ -59,7 +62,7 @@ class AssetDetailsModal extends StatelessWidget { title: "", trailingIcon: Icon(Icons.close), onTrailingPressed: Navigator.of(context).pop, - padding: EdgeInsets.only(top:12,right:18), + padding: EdgeInsets.only(top: 12, right: 18), ), SafeArea( child: Column( @@ -125,21 +128,20 @@ class AssetDetailsModal extends StatelessWidget { fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.onSurface), ), - if(asset != null) - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(999999999)), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4), - - child: Text( - asset?.title??"", - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant), + if (asset != null) + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(999999999)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4), + child: Text( + asset?.title ?? "", + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant), + ), ), - ), - ) + ) ], ), if (subtitle.isNotEmpty) @@ -147,11 +149,15 @@ class AssetDetailsModal extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, spacing: 4, children: [ - if(chainIconPath.isNotEmpty) - CakeImageWidget( - imageUrl: chainIconPath, - width:16,height:16,colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.onSurfaceVariant,BlendMode.srcIn), - ), + if (chainIconPath.isNotEmpty) + CakeImageWidget( + imageUrl: chainIconPath, + width: 16, + height: 16, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.onSurfaceVariant, + BlendMode.srcIn), + ), Text( subtitle, style: TextStyle( @@ -227,6 +233,18 @@ class AssetDetailsModal extends StatelessWidget { ), ), ), + if (showSwap && mode != AssetDetailsModalModes.ltcPrivate) + AssetDetailsModalBottomButton( + iconPath: "assets/new-ui/exchange.svg", + title: S.of(context).swap, + onPressed: () => openPage(context, param2: asset), + ), + if (showBridgeButton) + AssetDetailsModalBottomButton( + iconPath: "assets/new-ui/bridge.svg", + title: "Bridge", + onPressed: () => openPage(context, param1: asset), + ), AssetDetailsModalBottomButton( iconPath: "assets/new-ui/receive.svg", title: S.of(context).receive, @@ -240,12 +258,6 @@ class AssetDetailsModal extends StatelessWidget { openPage(context, param2: asset); }, ), - if (showSwap && mode != AssetDetailsModalModes.ltcPrivate) - AssetDetailsModalBottomButton( - iconPath: "assets/new-ui/exchange.svg", - title: S.of(context).swap, - onPressed: () => openPage(context, param2: asset), - ), ], ), SizedBox() diff --git a/lib/new-ui/widgets/coins_page/assets_history/asset_tile.dart b/lib/new-ui/widgets/coins_page/assets_history/asset_tile.dart index 170672c7b..1e018085b 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/asset_tile.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/asset_tile.dart @@ -14,6 +14,7 @@ class AssetTile extends StatelessWidget { required this.balance, required this.chainIconPath, this.showSecondary = false, + this.showBridgeButton = false, this.title, this.trailingText, this.modalMode = AssetDetailsModalModes.normal, @@ -22,6 +23,7 @@ class AssetTile extends StatelessWidget { final BalanceRecord balance; final bool showSecondary; final bool showSwap; + final bool showBridgeButton; final String chainIconPath; final String? title; final String? trailingText; @@ -42,6 +44,7 @@ class AssetTile extends StatelessWidget { builder: (context) { return AssetDetailsModal( showSwap: showSwap, + showBridgeButton: showBridgeButton, asset: balance.asset, title: title ?? balance.asset.fullName ?? balance.asset.name, chainTitle: "", diff --git a/lib/new-ui/widgets/coins_page/assets_history/assets_section.dart b/lib/new-ui/widgets/coins_page/assets_history/assets_section.dart index 6bd2661b0..e55a3c35f 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/assets_section.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/assets_section.dart @@ -59,6 +59,7 @@ class AssetsSection extends StatelessWidget { final balance = dashboardViewModel.balanceViewModel.formattedBalances.elementAt(index); return AssetTile( showSwap: dashboardViewModel.isEnabledSwapAction, + showBridgeButton: dashboardViewModel.showBridge(balance.asset), balance: balance, wallet: dashboardViewModel.wallet, isFirst: index == 0, diff --git a/lib/new-ui/widgets/send_page/send_address_input.dart b/lib/new-ui/widgets/send_page/send_address_input.dart index 405777c3d..feb0eaeaa 100644 --- a/lib/new-ui/widgets/send_page/send_address_input.dart +++ b/lib/new-ui/widgets/send_page/send_address_input.dart @@ -23,6 +23,7 @@ class NewSendAddressInput extends StatefulWidget { this.validator, this.focusNode, this.displayName, + this.hintText, }); final TextEditingController addressController; @@ -36,7 +37,8 @@ class NewSendAddressInput extends StatefulWidget { final bool bottomPadding; final FormFieldValidator? validator; final FocusNode? focusNode; - + final String? hintText; + @override State createState() => _NewSendAddressInputState(); } @@ -96,7 +98,7 @@ class _NewSendAddressInputState extends State { }, controller: widget.addressController, decoration: InputDecoration( - hintText: S.of(context).search_or_enter, + hintText: widget.hintText ?? S.of(context).search_or_enter, errorMaxLines: 3, ), ), diff --git a/lib/router.dart b/lib/router.dart index 6adfd313d..7af4f85fa 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -4,6 +4,10 @@ import 'package:cake_wallet/anonpay/anonpay_invoice_info.dart'; import 'package:cake_wallet/core/new_wallet_arguments.dart'; import 'package:cake_wallet/new-ui/new_dashboard.dart'; import 'package:cake_wallet/new-ui/pages/about_page.dart'; +import 'package:cake_wallet/new-ui/pages/bridge/bridge_confirm_sheet.dart'; +import 'package:cake_wallet/new-ui/pages/bridge/bridge_history_page.dart'; +import 'package:cake_wallet/new-ui/pages/bridge/bridge_network_page.dart'; +import 'package:cake_wallet/new-ui/pages/bridge/bridge_receiving_wallet_page.dart'; import 'package:cake_wallet/new-ui/pages/coin_control_page.dart'; import 'package:cake_wallet/new-ui/pages/addresses_page.dart'; import 'package:cake_wallet/new-ui/pages/lightning_username_page.dart'; @@ -89,7 +93,6 @@ import 'package:cake_wallet/src/screens/restore/wallet_restore_page.dart'; import 'package:cake_wallet/src/screens/seed/pre_seed_page.dart'; import 'package:cake_wallet/src/screens/seed/seed_verification/seed_verification_page.dart'; import 'package:cake_wallet/src/screens/seed/wallet_seed_page.dart'; -import 'package:cake_wallet/src/screens/send/send_page.dart'; import 'package:cake_wallet/src/screens/send/send_template_page.dart'; import 'package:cake_wallet/src/screens/send/transaction_success_info_page.dart'; import 'package:cake_wallet/src/screens/settings/background_sync_page.dart'; @@ -136,6 +139,8 @@ import 'package:cake_wallet/src/screens/welcome/welcome_page.dart'; import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/utils/payment_request.dart'; import 'package:cake_wallet/view_model/advanced_privacy_settings_view_model.dart'; +import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart'; +import 'package:cake_wallet/view_model/bridge_history_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/sign_view_model.dart'; @@ -1035,6 +1040,21 @@ Route createRoute(RouteSettings settings) { builder: (_) => getIt.get(), ); + case Routes.bridgeHistoryPage: + return handleRouteWithPlatformAwareness( + (context) => BridgeHistoryPage(settings.arguments as BridgeHistoryViewModel), + ); + + case Routes.bridgeDestinationNetworkPage: + return handleRouteWithPlatformAwareness( + (context) => BridgeNetworkPage(settings.arguments as BridgeViewModel), + ); + + case Routes.bridgeReceivingWalletPage: + return handleRouteWithPlatformAwareness( + (context) => BridgeReceivingWalletPage(settings.arguments as BridgeViewModel), + ); + default: return MaterialPageRoute( builder: (_) => Scaffold( diff --git a/lib/routes.dart b/lib/routes.dart index f61c35531..96ac8264d 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -144,4 +144,8 @@ class Routes { static const lightningUsernamePage = "/lightning_username_page"; static const aboutPage = "/about_page"; + + static const bridgeHistoryPage = '/bridge_history_page'; + static const bridgeDestinationNetworkPage = '/bridge_destination_network_page'; + static const bridgeReceivingWalletPage = '/bridge_receiving_wallet_page'; } diff --git a/lib/src/widgets/new_list_row/list_Item_style_wrapper.dart b/lib/src/widgets/new_list_row/list_Item_style_wrapper.dart index d8a1186c5..110bf01e0 100644 --- a/lib/src/widgets/new_list_row/list_Item_style_wrapper.dart +++ b/lib/src/widgets/new_list_row/list_Item_style_wrapper.dart @@ -9,6 +9,7 @@ class ListItemStyleWrapper extends StatelessWidget { this.backgroundColor, this.onTap, this.iconPath, + this.contentPadding, this.height, }); @@ -19,10 +20,10 @@ class ListItemStyleWrapper extends StatelessWidget { final VoidCallback? onTap; final Color? backgroundColor; final Widget Function(BuildContext context, TextStyle textStyle, TextStyle labelStyle) builder; + final EdgeInsets? contentPadding; @override Widget build(BuildContext context) { - final theme = Theme.of(context); final textStyle = TextStyle( @@ -70,15 +71,16 @@ class ListItemStyleWrapper extends StatelessWidget { child: Container(height: 1, color: theme.colorScheme.outlineVariant), ), ) - else if(!isLastInSection) Container( + else if (!isLastInSection) + Container( color: theme.colorScheme.surfaceContainer, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Container(height: 1, color: theme.colorScheme.outlineVariant), ), ) - ], - ), - ); + ], + ), + ); } } diff --git a/lib/store/bridge_transfers_store.dart b/lib/store/bridge_transfers_store.dart new file mode 100644 index 000000000..2ebeb82ae --- /dev/null +++ b/lib/store/bridge_transfers_store.dart @@ -0,0 +1,46 @@ +import 'package:cake_wallet/entities/bridge_transfer.dart'; +import 'package:mobx/mobx.dart'; + +part 'bridge_transfers_store.g.dart'; + +class BridgeTransfersStore = BridgeTransfersStoreBase with _$BridgeTransfersStore; + +abstract class BridgeTransfersStoreBase with Store { + BridgeTransfersStoreBase() : bridgeTransfers = [] { + updateList(); + } + + @observable + List bridgeTransfers; + + @action + Future updateList() async { + bridgeTransfers = await BridgeTransfer.selectAll(); + } + + @action + Future addTransfer(BridgeTransfer transfer) async { + try { + await BridgeTransfer.insert(transfer); + await updateList(); + } catch (_) {} + } + + @action + Future updateTransfer(BridgeTransfer transfer) async { + try { + await BridgeTransfer.update(transfer); + await updateList(); + } catch (_) {} + } + + @computed + List get activeTransfers => + bridgeTransfers.where((b) => b.isActive).toList(growable: false); + + @computed + List get pastTransfers => + bridgeTransfers.where((b) => !b.isActive).toList(growable: false); + + void dispose() {} +} diff --git a/lib/view_model/bridge/bridge_receiving_wallet_option.dart b/lib/view_model/bridge/bridge_receiving_wallet_option.dart new file mode 100644 index 000000000..2e08c061b --- /dev/null +++ b/lib/view_model/bridge/bridge_receiving_wallet_option.dart @@ -0,0 +1,17 @@ +import 'package:cw_core/wallet_info.dart'; + +class BridgeReceivingWalletOption { + const BridgeReceivingWalletOption({ + required this.walletInfo, + required this.isCurrent, + this.groupLabel, + }); + + final WalletInfo walletInfo; + final bool isCurrent; + + final String? groupLabel; + + String get name => walletInfo.name; + String get address => walletInfo.address; +} diff --git a/lib/view_model/bridge/bridge_view_model.dart b/lib/view_model/bridge/bridge_view_model.dart new file mode 100644 index 000000000..e84ccf6fc --- /dev/null +++ b/lib/view_model/bridge/bridge_view_model.dart @@ -0,0 +1,719 @@ +import 'dart:async'; + +import 'package:cake_wallet/core/amount_parsing_proxy.dart'; +import 'package:cake_wallet/core/amount_validator.dart'; +import 'package:cake_wallet/core/fiat_conversion_service.dart'; +import 'package:cake_wallet/core/utilities.dart'; +import 'package:cake_wallet/core/wallet_change_listener_view_model.dart'; +import 'package:cake_wallet/entities/calculate_fiat_amount.dart'; +import 'package:cake_wallet/entities/fiat_api_mode.dart'; +import 'package:cake_wallet/view_model/bridge/bridge_receiving_wallet_option.dart'; +import 'package:cake_wallet/entities/bridge_transfer.dart'; +import 'package:cake_wallet/entities/wallet_manager.dart'; +import 'package:cake_wallet/evm/evm.dart'; +import 'package:cake_wallet/reactions/wallet_connect.dart'; +import 'package:cake_wallet/core/layerzero_scan_service.dart'; +import 'package:cake_wallet/store/app_store.dart'; +import 'package:cake_wallet/store/bridge_transfers_store.dart'; +import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/erc20_token.dart'; +import 'package:cw_core/transaction_priority.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:cw_core/wallet_info.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:mobx/mobx.dart'; + +part 'bridge_view_model.g.dart'; + +class BridgeViewModel = BridgeViewModelBase with _$BridgeViewModel; + +abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with Store { + BridgeViewModelBase({ + required AppStore appStore, + required this.bridgeTransfersStore, + required this.walletManager, + required this.fiatConversionStore, + required this.settingsStore, + }) : _appStore = appStore, + super(appStore: appStore); + + final AppStore _appStore; + + AmountParsingProxy get amountParsingProxy => _appStore.amountParsingProxy; + + void Function()? onBridgeSuccess; + final Map> _pollingCancellers = {}; + final BridgeTransfersStore bridgeTransfersStore; + final WalletManager walletManager; + final FiatConversionStore fiatConversionStore; + final SettingsStore settingsStore; + + @observable + ObservableList bridgeReceivingWalletOptions = + ObservableList(); + + @observable + bool isBridgeReceivingWalletListLoading = false; + + @observable + CryptoCurrency? selectedToken; + + @observable + int? destinationChainId; + + @observable + String amount = ''; + + @observable + String recipientAddress = ''; + + @observable + String? destinationWalletName; + + @observable + USDT0Quote? quote; + + @observable + bool isQuoteLoading = false; + + @observable + String? quoteError; + + @observable + bool isExecuting = false; + + @observable + String? executeError; + + @observable + bool bridgeSuccess = false; + + @observable + BridgeTransfer? lastCreatedBridgeTransfer; + + @computed + int? get sourceChainId => evm!.getSelectedChainId(wallet); + + @computed + String get sourceAddress => wallet.walletAddresses.address; + + @computed + List get availableDestinationChains { + if (!isEVMCompatibleChain(wallet.type)) return []; + + return evm!.getUSDT0DestinationChains(wallet); + } + + @computed + List get availableUSDT0Tokens { + if (!isEVMCompatibleChain(wallet.type)) return []; + + final tokens = wallet.balance.keys.whereType(); + return tokens.where((token) => evm!.isUSDT0Token(wallet, token)).toList(growable: false); + } + + @computed + ChainInfo? get destinationChainInfo { + if (destinationChainId == null) return null; + + return availableDestinationChains.firstWhereOrNull((c) => c.chainId == destinationChainId); + } + + @computed + String get tokenBalanceFormatted { + final token = selectedToken; + if (token is! Erc20Token) return '0.00'; + + return amountParsingProxy.getDisplayCryptoStringFromBigInt( + selectedTokenBalance, + token, + ); + } + + @computed + String get amountDisplayFormatted { + if (amount.isEmpty) return ''; + final token = selectedToken; + if (token is! Erc20Token) return amount.replaceAll(',', '.'); + + return amountParsingProxy.getDisplayCryptoAmount( + amount.replaceAll(',', '.'), + token, + ); + } + + + DecimalAmountValidator get decimalAmountValidator => DecimalAmountValidator( + currency: selectedToken!, + isAutovalidate: true, + ); + + @computed + String get fiatAmountFormatted { + if (amount.isEmpty) return ''; + final token = selectedToken; + if (token is! Erc20Token) return ''; + + final price = fiatConversionStore.prices[token]; + if (price == null) return ''; + + final forFiat = amountParsingProxy.getDisplayCryptoAmount( + amount.replaceAll(',', '.'), + token, + ); + + return calculateFiatAmount( + price: price, + cryptoAmount: forFiat, + ); + } + + @computed + String get fiatCurrencyTitle => settingsStore.fiatCurrency.title; + + @computed + String get quoteNativeFee { + if (quote == null) return '—'; + + final cur = wallet.currency; + return amountParsingProxy.getDisplayCryptoStringFromBigInt( + quote!.nativeFee, + cur, + ); + } + + @computed + String get quoteNativeFeeFormattedForDisplay { + if (quoteNativeFee.isEmpty) return ''; + + return '${quoteNativeFee} ${wallet.currency.title}'; + } + + @computed + String get quoteNativeFiatFeeFormattedForDisplay { + if (quote == null || quoteNativeFee.isEmpty) return ''; + + final price = fiatConversionStore.prices[wallet.currency]; + if (price == null) return ''; + + final fiatFeeFormatted = calculateFiatAmount( + price: price, + cryptoAmount: amountParsingProxy.getDisplayCryptoAmount( + quoteNativeFee.replaceAll(',', '.'), + wallet.currency, + ), + ); + + return '(${settingsStore.fiatCurrency.title} $fiatFeeFormatted)'; + } + + @computed + bool get canProceedToDestinationNetwork { + if (amount.isEmpty) return false; + + if (selectedToken == null || selectedToken is! Erc20Token) return false; + + if (amountError != null) return false; + + final token = selectedToken as Erc20Token; + final validAmount = amountParsingProxy.tryParseCryptoString( + amount.replaceAll(',', '.'), + token, + ); + return validAmount != null && validAmount > BigInt.zero; + } + + @action + void applyInitialBridgeToken(CryptoCurrency asset) { + for (final t in availableUSDT0Tokens) { + if (t == asset) { + setSelectedToken(t); + break; + } + } + } + + @action + void setDestinationChain(int chainId) { + destinationChainId = chainId; + _clearQuoteState(); + } + + @action + void setSelectedToken(CryptoCurrency token) { + selectedToken = token; + _clearQuoteState(); + } + + @action + void setAmount(String value) { + amount = value; + _clearQuoteState(); + } + + @action + void setMaxAmount() { + final token = selectedToken; + if (token is! Erc20Token) return; + if (selectedTokenBalance == BigInt.zero) { + setAmount(''); + return; + } + setAmount( + amountParsingProxy.getDisplayCryptoStringFromBigInt( + selectedTokenBalance, + token, + ), + ); + } + + @action + void setRecipientAddress(String value, {String? destWalletName}) { + recipientAddress = value; + destinationWalletName = destWalletName; + _clearQuoteState(); + } + + void _clearQuoteState() { + quote = null; + quoteError = null; + executeError = null; + } + + Future _ensureFiatPriceFor(CryptoCurrency crypto) async { + if (fiatConversionStore.prices[crypto] != null) return; + + final p = await FiatConversionService.fetchPrice( + crypto: crypto, + fiat: settingsStore.fiatCurrency, + torOnly: settingsStore.fiatApiMode == FiatApiMode.torOnly, + ); + + runInAction(() { + fiatConversionStore.prices[crypto] = p; + }); + } + + @action + Future ensureFiatPriceForSelectedToken() async { + await _ensureFiatPriceFor(selectedToken!); + } + + @action + Future ensureFiatPriceForNativeCurrency() async { + await _ensureFiatPriceFor(wallet.currency); + } + + @computed + BigInt get selectedTokenBalance { + final bal = wallet.balance[selectedToken]; + + if (bal is EVMChainERC20Balance) return bal.balance; + return BigInt.zero; + } + + @computed + String? get amountError { + if (selectedToken == null || amount.isEmpty) return null; + if (selectedToken is! Erc20Token) return null; + + final token = selectedToken as Erc20Token; + final amountBigInt = amountParsingProxy.tryParseCryptoString( + amount.replaceAll(',', '.'), + token, + ); + if (amountBigInt == null || amountBigInt == BigInt.zero) return null; + if (amountBigInt > selectedTokenBalance) { + return 'Insufficient balance for ${token.title} token.'; + } + + return null; + } + + @action + Future loadReceivingWalletOptions() async { + if (!isEVMCompatibleChain(wallet.type)) return; + + final destWalletType = evm!.getWalletTypeByChainId(destinationChainId!); + + isBridgeReceivingWalletListLoading = true; + try { + await walletManager.updateWalletGroups(); + final all = await WalletInfo.getAll(); + + if (destWalletType == null) { + bridgeReceivingWalletOptions.clear(); + return; + } + + final filtered = + all.where((w) => w.type == destWalletType && w.hardwareWalletType == null).toList(); + + final options = []; + + for (final wi in filtered) { + final isCurrent = wi.name == wallet.name; + options.add( + BridgeReceivingWalletOption( + walletInfo: wi, + isCurrent: isCurrent, + groupLabel: walletManager.getGroupName(wi), + ), + ); + } + + bridgeReceivingWalletOptions + ..clear() + ..addAll(options); + } finally { + isBridgeReceivingWalletListLoading = false; + } + } + + String? missingBridgeFieldsMessage() { + final src = sourceChainId; + final dst = destinationChainId; + final token = selectedToken; + + if (src == null || + dst == null || + token == null || + amount.isEmpty || + recipientAddress.trim().isEmpty) { + return 'Fill all fields'; + } + return null; + } + + ({String? error, BigInt? parsedAmount}) _parseAndValidateAmount(Erc20Token token) { + final amountBigInt = amountParsingProxy.tryParseCryptoString( + amount.replaceAll(',', '.'), + token, + ); + + if (amountBigInt == null || amountBigInt == BigInt.zero) { + return (error: 'Invalid amount', parsedAmount: null); + } + + if (amountBigInt > selectedTokenBalance) { + return ( + error: 'Insufficient balance for ${token.title} token.', + parsedAmount: null, + ); + } + + return (error: null, parsedAmount: amountBigInt); + } + + @action + Future loadQuote() async { + final missing = missingBridgeFieldsMessage(); + if (missing != null) { + quoteError = missing; + return; + } + + final token = selectedToken!; + if (token is! Erc20Token) return; + + final check = _parseAndValidateAmount(token); + if (check.error != null) { + quoteError = check.error; + return; + } + + final src = sourceChainId!; + final dst = destinationChainId!; + final amountBigInt = check.parsedAmount!; + + isQuoteLoading = true; + quoteError = null; + quote = null; + executeError = null; + + try { + quote = await evm!.quoteUSDT0Transfer( + wallet: wallet, + sourceChainId: src, + destinationChainId: dst, + amount: amountBigInt, + recipientAddress: recipientAddress.trim(), + ); + } catch (e) { + quoteError = e.toString(); + } finally { + isQuoteLoading = false; + } + } + + @action + Future executeBridge() async { + if (quote == null) { + executeError = 'Get a quote first'; + return; + } + + final missing = missingBridgeFieldsMessage(); + if (missing != null) { + executeError = missing; + return; + } + + final token = selectedToken!; + if (token is! Erc20Token) return; + + final check = _parseAndValidateAmount(token); + if (check.error != null) { + executeError = check.error; + return; + } + + final src = sourceChainId!; + final dst = destinationChainId!; + final amountBigInt = check.parsedAmount!; + + isExecuting = true; + executeError = null; + try { + final priority = EVMChainTransactionPriority.medium; + final pending = await evm!.executeUSDT0Transfer( + wallet: wallet, + token: token, + sourceChainId: src, + destinationChainId: dst, + amount: amountBigInt, + recipientAddress: recipientAddress.trim(), + quote: quote!, + priority: priority as TransactionPriority, + useBlinkProtection: canSupportBlinkProtection(src), + ); + + final sourceTxHash = pending.evmTxHashFromRawHex ?? pending.id; + await pending.commit(); + + final record = BridgeTransfer( + id: '${sourceTxHash}_${DateTime.now().millisecondsSinceEpoch}', + walletId: wallet.name, + sourceChainId: src, + destinationChainId: dst, + tokenSymbol: token.title, + tokenContract: token.contractAddress, + amount: amount, + recipientAddress: recipientAddress.trim(), + sourceTxHash: sourceTxHash, + status: 'submitted', + createdAt: DateTime.now(), + ); + + await bridgeTransfersStore.addTransfer(record); + runInAction(() { + quote = null; + bridgeSuccess = true; + lastCreatedBridgeTransfer = record; + }); + onBridgeSuccess?.call(); + _pollForSourceConfirmation(record, wallet); + } catch (e) { + executeError = e.toString(); + } finally { + isExecuting = false; + } + } + + static const _pollInterval = Duration(seconds: 2); + static const _pollTimeout = Duration(minutes: 3); + static const _destinationPollInterval = Duration(seconds: 5); + static const _destinationPollTimeout = Duration(minutes: 10); + + @override + void onWalletChange(WalletBase wallet) { + _cancelAllPolling(); + _resumePollingForActiveTransfers(wallet); + } + + void _cancelAllPolling() { + for (final canceller in _pollingCancellers.values) { + if (!canceller.isCompleted) { + canceller.complete(); + } + } + _pollingCancellers.clear(); + } + + void _resumePollingForActiveTransfers(WalletBase wallet) { + if (!isEVMCompatibleChain(wallet.type)) return; + + final activeTransfers = bridgeTransfersStore.bridgeTransfers + .where((t) => t.walletId == wallet.name && t.isActive) + .toList(); + + for (final transfer in activeTransfers) { + if (transfer.status == 'submitted' || transfer.status == 'confirming') { + _pollForSourceConfirmation(transfer, wallet); + } else if (transfer.status == 'initiated') { + _pollForDestinationCompletion(transfer, wallet); + } + } + } + + bool _isValidWalletContext(String expectedWalletId) { + return wallet.name == expectedWalletId && + isEVMCompatibleChain(wallet.type) && + !_pollingCancellers.values.any((c) => c.isCompleted); + } + + Future _updateTransferStatus( + BridgeTransfer record, + String status, { + String? errorMessage, + String? statusMessage, + DateTime? confirmedAt, + }) async { + if (!_isValidWalletContext(record.walletId)) return; + + runInAction(() { + record.updatedAt = DateTime.now(); + record.status = status; + if (errorMessage != null) record.errorMessage = errorMessage; + if (statusMessage != null) record.statusMessage = statusMessage; + if (confirmedAt != null) record.confirmedAt = confirmedAt; + }); + await bridgeTransfersStore.updateTransfer(record); + } + + Future _pollForSourceConfirmation( + BridgeTransfer record, + WalletBase wallet, + ) async { + final canceller = Completer(); + _pollingCancellers[record.id] = canceller; + final walletId = wallet.name; + final deadline = DateTime.now().add(_pollTimeout); + + try { + while (DateTime.now().isBefore(deadline)) { + await Future.any([ + Future.delayed(_pollInterval), + canceller.future, + ]); + + if (canceller.isCompleted || !_isValidWalletContext(walletId)) return; + + bool? receipt; + try { + receipt = await evm!.getTransactionReceipt(wallet, record.sourceTxHash); + } catch (e) { + printV('USDT0 bridge: Error fetching receipt: $e'); + continue; + } + + if (receipt == null) continue; + + if (receipt == true) { + await _updateTransferStatus( + record, + 'confirming', + confirmedAt: DateTime.now(), + ); + + await Future.delayed(const Duration(seconds: 1)); + if (canceller.isCompleted || !_isValidWalletContext(walletId)) return; + + await _updateTransferStatus(record, 'initiated'); + _pollForDestinationCompletion(record, wallet); + + return; + } else if (receipt == false) { + await _updateTransferStatus( + record, + 'failed', + errorMessage: 'Transaction reverted', + ); + return; + } + } + + if (!_isValidWalletContext(walletId)) return; + + await _updateTransferStatus(record, 'initiated'); + _pollForDestinationCompletion(record, wallet); + } finally { + _pollingCancellers.remove(record.id); + } + } + + Future _pollForDestinationCompletion( + BridgeTransfer record, + WalletBase wallet, + ) async { + final canceller = Completer(); + _pollingCancellers['${record.id}_dest'] = canceller; + final walletId = wallet.name; + final deadline = DateTime.now().add(_destinationPollTimeout); + + try { + while (DateTime.now().isBefore(deadline)) { + await Future.any([ + Future.delayed(_destinationPollInterval), + canceller.future, + ]); + + if (canceller.isCompleted || !_isValidWalletContext(walletId)) return; + + LayerZeroMessageStatus? status; + try { + status = await LayerZeroScanService.getMessageStatus(record.sourceTxHash); + } catch (e) { + printV('USDT0 bridge: Error fetching LayerZero status: $e'); + continue; + } + + if (status == null) continue; + + if (status.isDelivered) { + await _updateTransferStatus( + record, + 'completed', + statusMessage: status.status?.message, + ); + return; + } + + if (status.isFailed) { + await _updateTransferStatus( + record, + 'failed', + errorMessage: status.status?.message ?? 'Bridge message failed', + statusMessage: status.status?.message, + ); + return; + } + + await _updateTransferStatus( + record, + record.status, + statusMessage: status.status?.message, + ); + } + } finally { + _pollingCancellers.remove('${record.id}_dest'); + } + } + + @action + void clearBridgeSuccess() { + amount = ''; + recipientAddress = ''; + destinationWalletName = null; + destinationChainId = null; + bridgeSuccess = false; + lastCreatedBridgeTransfer = null; + _clearQuoteState(); + } + + void dispose() { + _cancelAllPolling(); + } +} diff --git a/lib/view_model/bridge_details_view_model.dart b/lib/view_model/bridge_details_view_model.dart new file mode 100644 index 000000000..b401f754f --- /dev/null +++ b/lib/view_model/bridge_details_view_model.dart @@ -0,0 +1,181 @@ +import 'dart:async'; + +import 'package:cake_wallet/entities/bridge_transfer.dart'; +import 'package:cake_wallet/evm/evm.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/screens/transaction_details/address_list_item.dart'; +import 'package:cake_wallet/src/screens/transaction_details/standart_list_item.dart'; +import 'package:cake_wallet/src/screens/trade_details/trade_details_status_item.dart'; +import 'package:cake_wallet/src/screens/trade_details/track_trade_list_item.dart'; +import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart'; +import 'package:cake_wallet/store/bridge_transfers_store.dart'; +import 'package:mobx/mobx.dart'; +import 'package:url_launcher/url_launcher.dart'; + +part 'bridge_details_view_model.g.dart'; + +class BridgeDetailsViewModel = BridgeDetailsViewModelBase with _$BridgeDetailsViewModel; + +abstract class BridgeDetailsViewModelBase with Store { + BridgeDetailsViewModelBase({ + required BridgeTransfer transferForDetails, + required this.bridgeTransfersStore, + required this.walletId, + }) : items = ObservableList(), + transfer = _findTransferInStore( + bridgeTransfersStore.bridgeTransfers, transferForDetails.id, walletId) ?? + transferForDetails { + _updateItems(); + _setupReaction(); + } + + static BridgeTransfer? _findTransferInStore( + List transfers, + String transferId, + String walletId, + ) { + try { + return transfers.firstWhere( + (t) => t.id == transferId && t.walletId == walletId, + ); + } catch (_) { + return null; + } + } + + final BridgeTransfersStore bridgeTransfersStore; + final String walletId; + ReactionDisposer? _reactionDisposer; + + @observable + BridgeTransfer transfer; + + @observable + ObservableList items; + + Timer? timer; + + void _setupReaction() { + _reactionDisposer = reaction( + (_) => bridgeTransfersStore.bridgeTransfers, + (_) => updateTransfer(), + ); + updateTransfer(); + } + + @action + void updateTransfer() { + final updatedTransfer = _findTransferInStore( + bridgeTransfersStore.bridgeTransfers, + transfer.id, + walletId, + ); + if (updatedTransfer != null) { + transfer = updatedTransfer; + _updateItems(); + } + } + + void dispose() { + _reactionDisposer?.call(); + timer?.cancel(); + } + + void _updateItems() { + items.clear(); + + final statusText = transfer.statusMessage?.isNotEmpty == true + ? '${_statusLabel(transfer.status)} · ${transfer.statusMessage}' + : _statusLabel(transfer.status); + + items.add( + DetailsListStatusItem( + title: "Status", + value: statusText, + status: transfer.status, + ), + ); + + final sourceName = + evm?.getChainNameByChainId(transfer.sourceChainId) ?? '${transfer.sourceChainId}'; + final destName = + evm?.getChainNameByChainId(transfer.destinationChainId) ?? '${transfer.destinationChainId}'; + + items.add( + StandartListItem( + title: "Source chain", + value: sourceName, + ), + ); + + items.add( + StandartListItem( + title: "Destination chain", + value: destName, + ), + ); + + items.add( + StandartListItem( + title: "Amount", + value: '${transfer.amount} ${transfer.tokenSymbol}', + ), + ); + + items.add( + AddressListItem( + title: "Recipient", + value: transfer.recipientAddress, + ), + ); + + final sourceExplorerUrl = evm?.getExplorerUrlForChainId(transfer.sourceChainId); + final sourceTxUrl = sourceExplorerUrl != null && sourceExplorerUrl.isNotEmpty + ? '$sourceExplorerUrl/tx/${transfer.sourceTxHash}' + : null; + + if (sourceTxUrl != null) { + final explorerDescription = S.current.view_transaction_on + Uri.parse(sourceTxUrl).host; + items.add( + TrackTradeListItem( + title: explorerDescription, + value: sourceTxUrl, + onTap: () => _launchUrl(sourceTxUrl), + ), + ); + } + + if (transfer.errorMessage != null && transfer.errorMessage!.isNotEmpty) { + items.add( + StandartListItem( + title: "Error", + value: transfer.errorMessage!, + ), + ); + } + } + + String _statusLabel(String status) { + switch (status) { + case 'submitted': + return "Submitted"; + case 'confirming': + return "Confirming on source"; + case 'initiated': + return "Bridge initiated"; + case 'completed': + return "Completed"; + case 'failed': + return "Failed"; + default: + return status; + } + } + + void _launchUrl(String url) { + final uri = Uri.parse(url); + try { + launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (_) {} + } +} diff --git a/lib/view_model/bridge_history_view_model.dart b/lib/view_model/bridge_history_view_model.dart new file mode 100644 index 000000000..2d279804d --- /dev/null +++ b/lib/view_model/bridge_history_view_model.dart @@ -0,0 +1,37 @@ +import 'package:cake_wallet/entities/bridge_transfer.dart'; +import 'package:cake_wallet/store/app_store.dart'; +import 'package:cake_wallet/store/bridge_transfers_store.dart'; +import 'package:mobx/mobx.dart'; + +part 'bridge_history_view_model.g.dart'; + +class BridgeHistoryViewModel = BridgeHistoryViewModelBase with _$BridgeHistoryViewModel; + +abstract class BridgeHistoryViewModelBase with Store { + BridgeHistoryViewModelBase({ + required this.bridgeTransfersStore, + required this.appStore, + }); + + final BridgeTransfersStore bridgeTransfersStore; + final AppStore appStore; + + @computed + List get walletTransfers { + final wallet = appStore.wallet; + if (wallet == null) return []; + + return bridgeTransfersStore.bridgeTransfers.where((t) => t.walletId == wallet.name).toList(); + } + + @computed + List get activeTransfers => + walletTransfers.where((b) => b.isActive).toList(growable: false); + + @computed + List get pastTransfers => + walletTransfers.where((b) => !b.isActive).toList(growable: false); + + @computed + bool get isEmpty => walletTransfers.isEmpty; +} diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index 6dd41d480..1f2cb0faa 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -393,6 +393,14 @@ abstract class DashboardViewModelBase with Store { return false; } + bool showBridge(CryptoCurrency currency) { + if(!isEVMCompatibleChain(wallet.type)) return false; + + if(evm!.isUSDT0Token(wallet, currency)) return true; + + return false; + } + @action Future loadCardDesigns() async { final accountStyleSettings = diff --git a/res/pictures/bridge.svg b/res/pictures/bridge.svg new file mode 100644 index 000000000..ab7b0d557 --- /dev/null +++ b/res/pictures/bridge.svg @@ -0,0 +1,3 @@ + + + diff --git a/tool/configure.dart b/tool/configure.dart index c6ee96bc6..798febd61 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -1392,7 +1392,13 @@ import 'package:cw_evm/evm_chain_wallet_creation_credentials.dart'; import 'package:cw_evm/utils/evm_chain_utils.dart'; import 'package:cw_evm/evm_chain_default_tokens.dart'; import 'package:cw_evm/deuro/deuro_savings.dart'; +import 'package:cw_evm/usdt0/usdt0_config.dart'; +import 'package:cw_evm/usdt0/usdt0_quote.dart'; +import 'package:cw_evm/usdt0/usdt0_service.dart'; import 'package:eth_sig_util/util/utils.dart'; +export 'package:cw_evm/evm_chain_transaction_priority.dart'; +export 'package:cw_evm/evm_erc20_balance.dart'; +export 'package:cw_evm/usdt0/usdt0_quote.dart'; """; const evmCwPart = "part 'cw_evm.dart';"; @@ -1546,19 +1552,44 @@ abstract class EVM { WalletType? getWalletTypeByChainId(int chainId); String getChainNameByChainId(int chainId); String getTokenNameByChainId(int chainId); - // Chain selection methods List getAllChains(); ChainInfo? getCurrentChain(WalletBase wallet); + ChainInfo? getChainInfoByChainId(int chainId); + int? getSelectedChainId(WalletBase wallet); Future selectChain(WalletBase wallet, int chainId, {required Node node}); String? getExplorerUrlForChainId(int chainId, {bool showProtocol = true}); + Future getTransactionReceipt(WalletBase wallet, String txHash); + bool hasPriorityFee(int chainId); + bool isUSDT0Token(WalletBase wallet, CryptoCurrency token); + List getUSDT0DestinationChains(WalletBase wallet); + Future quoteUSDT0Transfer({ + required WalletBase wallet, + required int sourceChainId, + required int destinationChainId, + required BigInt amount, + required String recipientAddress, + }); + + Future executeUSDT0Transfer({ + required WalletBase wallet, + required CryptoCurrency token, + required int sourceChainId, + required int destinationChainId, + required BigInt amount, + required String recipientAddress, + required USDT0Quote quote, + required TransactionPriority priority, + bool useBlinkProtection = true, + }); + Future getWCBufferedFeeQuote( WalletBase wallet, TransactionPriority priority, @@ -1572,11 +1603,13 @@ class ChainInfo { required this.chainId, required this.name, required this.shortCode, + required this.currency, }); final int chainId; final String name; final String shortCode; + final CryptoCurrency currency; @override bool operator ==(Object other) =>