diff --git a/assets/images/crypto/paxg.webp b/assets/images/crypto/paxg.webp new file mode 100644 index 000000000..9b89a14fa Binary files /dev/null and b/assets/images/crypto/paxg.webp differ diff --git a/assets/images/crypto/usdt0.webp b/assets/images/crypto/usdt0.webp new file mode 100644 index 000000000..d9afd7eae Binary files /dev/null and b/assets/images/crypto/usdt0.webp differ diff --git a/assets/images/crypto/xaut.webp b/assets/images/crypto/xaut.webp new file mode 100644 index 000000000..74f514d2e Binary files /dev/null and b/assets/images/crypto/xaut.webp differ diff --git a/assets/new-ui/bridge.svg.vec b/assets/new-ui/bridge.svg.vec new file mode 100644 index 000000000..a0435fe79 Binary files /dev/null and b/assets/new-ui/bridge.svg.vec differ diff --git a/assets/new-ui/tor.svg.vec b/assets/new-ui/tor.svg.vec new file mode 100644 index 000000000..3acf0752b Binary files /dev/null and b/assets/new-ui/tor.svg.vec differ 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) =>