CW-1273: Implement USDT0 Bridge (#2855)

* feat: Implement USDT0 Bridging using OFT Standard by Layer Zero. POC available in apps menu. * feat: Implement USDT0 Bridging using OFT Standard by Layer Zero. POC available in apps menu. * feat: Implement USDT0 Bridging using OFT Standard by Layer Zero. POC available in apps menu. * Add transaction history and status polling for USDT0 bridging * Add Transaction history and status polling for USDT0 Bridging * USDT0 Bridging Implementation * Update Arbitrum USDT token symbol * fix: Merge conflicts * fix: Merge conflicts * chore: Rever formatting * feat: Implement new flow * feat: Implement new ui flow * Add currency to configure * feat: new ui flow * Update USDT0 implementation - Switch to sqlite for storage instead of hive - Switch bridge history and details to modals - Switch bridge transfer details page to new tx details ui * refactor: rearrange modal action buttons * enhance usdt0 bridge flows * Update lib/entities/wallet_manager.dart [skip ci] --------- Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>

David Adegoke committed Apr 15, 2026 at 04:06 UTC 586a040e4a2b46719bd4400b83df860dbedec587
45 files changed +3958 -43
assets/images/crypto/paxg.webp
Binary files /dev/null and b/assets/images/crypto/paxg.webp differ
assets/images/crypto/usdt0.webp
Binary files /dev/null and b/assets/images/crypto/usdt0.webp differ
assets/images/crypto/xaut.webp
Binary files /dev/null and b/assets/images/crypto/xaut.webp differ
assets/new-ui/bridge.svg.vec
Binary files /dev/null and b/assets/new-ui/bridge.svg.vec differ
assets/new-ui/tor.svg.vec
Binary files /dev/null and b/assets/new-ui/tor.svg.vec differ
cw_core/lib/db/sqlite.dart
+34 -1
@@ -7,6 +7,8 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart';
7
8 Database? db;
9
10 +
11 +
12 Future<void> _addColumnIfNotExists(
13 Database db, {
14 required String table,
@@ -39,7 +41,7 @@ Future<void> initDb({String? pathOverride}) async {
41 }
42 }
43 await db?.close();
42 - db = await openDatabase(dbFile.path, version: 4,
44 + db = await openDatabase(dbFile.path, version: 5,
45 onUpgrade: (Database db, int oldVersion, int newVersion) async {
46 printV("migrating: $oldVersion, $newVersion");
47 if (oldVersion <= 1) {
@@ -88,6 +90,9 @@ CREATE TABLE IF NOT EXISTS BalanceCardStyleSettings (
90 // if address doesn't correspond to a valid token, fallback to primary token
91 await _addColumnIfNotExists(db, table: "WalletInfo", column: "favoriteTokenAddress", definition: "TEXT DEFAULT NULL");
92 }
93 + if (oldVersion <= 4) {
94 + await _createBridgeTransferTable(db);
95 + }
96 },
97 onCreate: (Database db, int version) async {
98 await db.execute(
@@ -185,6 +190,7 @@ CREATE TABLE BalanceCardStyleSettings (
190 FOREIGN KEY (walletInfoId) REFERENCES WalletInfo(walletInfoId)
191 );
192 ''');
193 + await _createBridgeTransferTable(db);
194 }
195 );
196 }
@@ -222,4 +228,31 @@ Future<Map<String, dynamic>> dumpCustomDb(String path) async {
228 ret[tableName] = await db.query(tableName);
229 }
230 return ret;
231 +}
232 +
233 +Future<void> _createBridgeTransferTable(Database db) async {
234 + await db.execute('''
235 +CREATE TABLE IF NOT EXISTS BridgeTransfer (
236 + id TEXT NOT NULL PRIMARY KEY,
237 + wallet_id TEXT NOT NULL,
238 + source_chain_id INTEGER NOT NULL,
239 + destination_chain_id INTEGER NOT NULL,
240 + token_symbol TEXT NOT NULL,
241 + token_contract TEXT NOT NULL,
242 + amount TEXT NOT NULL,
243 + recipient_address TEXT NOT NULL,
244 + source_tx_hash TEXT NOT NULL,
245 + status TEXT NOT NULL,
246 + created_at INTEGER NOT NULL,
247 + updated_at INTEGER,
248 + confirmed_at INTEGER,
249 + amount_raw TEXT,
250 + error_message TEXT,
251 + status_message TEXT
252 +);
253 +''');
254 + await db.execute('''
255 +CREATE INDEX IF NOT EXISTS idx_bridgetransfer_wallet_id
256 +ON BridgeTransfer(wallet_id);
257 +''');
258 }
\ No newline at end of file
cw_core/lib/hive_type_ids.dart
+1 -1
@@ -21,4 +21,4 @@ const HARDWARE_WALLET_TYPE_TYPE_ID = 19;
21 const MWEB_UTXO_TYPE_ID = 20;
22 const HAVEN_SEED_STORE_TYPE_ID = 21;
23 const ZANO_ASSET_TYPE_ID = 22;
24 -const PAYJOIN_SESSION_TYPE_ID = 23;
24 +const PAYJOIN_SESSION_TYPE_ID = 23;
\ No newline at end of file
cw_evm/lib/contract/oft.dart new
+174
@@ -0,0 +1,174 @@
1 +import 'dart:typed_data';
2 +
3 +import 'package:cw_evm/usdt0/usdt0_quote.dart';
4 +import 'package:hex/hex.dart' as hex;
5 +import 'package:web3dart/web3dart.dart' as web3;
6 +
7 +/// Minimal OFT ABI for USDT0 (per docs.usdt0.to).
8 +/// quoteSend(SendParam, bool payInLzToken) returns MessagingFee.
9 +/// send(SendParam, MessagingFee, address refundAddress) payable.
10 +/// SendParam: dstEid, to (bytes32), amountLD (amount to send, token units),
11 +/// minAmountLD (min receive on destination), extraOptions, composeMsg, oftCmd.
12 +const String _oftAbiJson = '''
13 +[
14 + {
15 + "inputs": [
16 + {
17 + "components": [
18 + {"internalType": "uint32", "name": "dstEid", "type": "uint32"},
19 + {"internalType": "bytes32", "name": "to", "type": "bytes32"},
20 + {"internalType": "uint256", "name": "amountLD", "type": "uint256"},
21 + {"internalType": "uint256", "name": "minAmountLD", "type": "uint256"},
22 + {"internalType": "bytes", "name": "extraOptions", "type": "bytes"},
23 + {"internalType": "bytes", "name": "composeMsg", "type": "bytes"},
24 + {"internalType": "bytes", "name": "oftCmd", "type": "bytes"}
25 + ],
26 + "internalType": "struct SendParam",
27 + "name": "sendParam",
28 + "type": "tuple"
29 + },
30 + {"internalType": "bool", "name": "payInLzToken", "type": "bool"}
31 + ],
32 + "name": "quoteSend",
33 + "outputs": [
34 + {
35 + "components": [
36 + {"internalType": "uint256", "name": "nativeFee", "type": "uint256"},
37 + {"internalType": "uint256", "name": "lzTokenFee", "type": "uint256"}
38 + ],
39 + "internalType": "struct MessagingFee",
40 + "name": "",
41 + "type": "tuple"
42 + }
43 + ],
44 + "stateMutability": "view",
45 + "type": "function"
46 + },
47 + {
48 + "inputs": [
49 + {
50 + "components": [
51 + {"internalType": "uint32", "name": "dstEid", "type": "uint32"},
52 + {"internalType": "bytes32", "name": "to", "type": "bytes32"},
53 + {"internalType": "uint256", "name": "amountLD", "type": "uint256"},
54 + {"internalType": "uint256", "name": "minAmountLD", "type": "uint256"},
55 + {"internalType": "bytes", "name": "extraOptions", "type": "bytes"},
56 + {"internalType": "bytes", "name": "composeMsg", "type": "bytes"},
57 + {"internalType": "bytes", "name": "oftCmd", "type": "bytes"}
58 + ],
59 + "internalType": "struct SendParam",
60 + "name": "_sendParam",
61 + "type": "tuple"
62 + },
63 + {
64 + "components": [
65 + {"internalType": "uint256", "name": "nativeFee", "type": "uint256"},
66 + {"internalType": "uint256", "name": "lzTokenFee", "type": "uint256"}
67 + ],
68 + "internalType": "struct MessagingFee",
69 + "name": "_fee",
70 + "type": "tuple"
71 + },
72 + {"internalType": "address", "name": "_refundAddress", "type": "address"}
73 + ],
74 + "name": "send",
75 + "outputs": [],
76 + "stateMutability": "payable",
77 + "type": "function"
78 + }
79 +]
80 +''';
81 +
82 +final web3.ContractAbi oftContractAbi = web3.ContractAbi.fromJson(_oftAbiJson, 'OFT');
83 +
84 +/// OFT contract wrapper for quoteSend (view) and send (payable).
85 +class OFT {
86 + OFT({
87 + required web3.EthereumAddress address,
88 + required web3.Web3Client client,
89 + }) : _address = address,
90 + _client = client;
91 +
92 + final web3.EthereumAddress _address;
93 + final web3.Web3Client _client;
94 +
95 + /// Calls quoteSend and returns MessagingFee (nativeFee, lzTokenFee).
96 + Future<USDT0Quote> quoteSend({
97 + required int dstEid,
98 + required List<int> toBytes32,
99 + required BigInt amountLD,
100 + required BigInt minAmountLD,
101 + Uint8List? extraOptions,
102 + Uint8List? composeMsg,
103 + Uint8List? oftCmd,
104 + bool payInLzToken = false,
105 + }) async {
106 + final contract = web3.DeployedContract(
107 + oftContractAbi,
108 + _address,
109 + );
110 + final fn = contract.function('quoteSend');
111 + final sendParam = [
112 + BigInt.from(dstEid),
113 + Uint8List.fromList(toBytes32),
114 + amountLD,
115 + minAmountLD,
116 + extraOptions ?? Uint8List(0),
117 + composeMsg ?? Uint8List(0),
118 + oftCmd ?? Uint8List(0),
119 + ];
120 + final params = [sendParam, payInLzToken];
121 +
122 + final result = await _client.call(
123 + contract: contract,
124 + function: fn,
125 + params: params,
126 + );
127 +
128 + final msgFee = result.first as List<dynamic>;
129 + final nativeFee = msgFee[0] as BigInt;
130 + final lzTokenFee = msgFee[1] as BigInt;
131 + return USDT0Quote(nativeFee: nativeFee, lzTokenFee: lzTokenFee);
132 + }
133 +
134 + /// Encodes send(SendParam, MessagingFee, refundAddress) for transaction.
135 + /// Returns (dataHex, valueWei) where valueWei is the native fee to send.
136 + ({String dataHex, BigInt valueWei}) encodeSend({
137 + required int dstEid,
138 + required List<int> toBytes32,
139 + required BigInt amountLD,
140 + required BigInt minAmountLD,
141 + required BigInt nativeFee,
142 + required BigInt lzTokenFee,
143 + required String refundAddress,
144 + Uint8List? extraOptions,
145 + Uint8List? composeMsg,
146 + Uint8List? oftCmd,
147 + }) {
148 + final contract = web3.DeployedContract(oftContractAbi, _address);
149 + final fn = contract.function('send');
150 + final sendParam = [
151 + BigInt.from(dstEid),
152 + Uint8List.fromList(toBytes32),
153 + amountLD,
154 + minAmountLD,
155 + extraOptions ?? Uint8List(0),
156 + composeMsg ?? Uint8List(0),
157 + oftCmd ?? Uint8List(0),
158 + ];
159 + final fee = [nativeFee, lzTokenFee];
160 + final refund = web3.EthereumAddress.fromHex(refundAddress);
161 + final encoded = fn.encodeCall([sendParam, fee, refund]);
162 + final dataHex = '0x${hex.HEX.encode(encoded)}';
163 + return (dataHex: dataHex, valueWei: nativeFee);
164 + }
165 +}
166 +
167 +/// Converts EVM address (0x + 40 hex) to bytes32 (left-padded).
168 +List<int> addressToBytes32(String address) {
169 + final clean = address.startsWith('0x') ? address.substring(2) : address;
170 +
171 + if (clean.length != 40) throw ArgumentError('Invalid address length');
172 +
173 + return hex.HEX.decode('000000000000000000000000$clean');
174 +}
cw_evm/lib/evm_chain_wallet.dart
+13
@@ -1281,12 +1281,25 @@ abstract class EVMChainWalletBase
1281 } else if (newTxInfo.direction == TransactionDirection.incoming &&
1282 existingTxInfo.direction == TransactionDirection.outgoing) {
1283 result[transactionModel.hash] = newTxInfo;
1284 + }
1285 +
1286 + else if (newTxInfo.direction == TransactionDirection.outgoing &&
1287 + existingTxInfo.direction == TransactionDirection.outgoing &&
1288 + _hasEvmTokenContractAddress(newTxInfo) &&
1289 + !_hasEvmTokenContractAddress(existingTxInfo)) {
1290 + result[transactionModel.hash] = newTxInfo;
1291 }
1292 }
1293
1294 return result;
1295 }
1296
1297 +
1298 + bool _hasEvmTokenContractAddress(EVMChainTransactionInfo info) {
1299 + final c = info.contractAddress;
1300 + return c != null && c.isNotEmpty;
1301 + }
1302 +
1303 String? analyzeTransaction(String? transactionInput) {
1304 if (transactionInput == '0x' || transactionInput == null || transactionInput.isEmpty) {
1305 return '';
cw_evm/lib/tokens/arbitrum_tokens.dart
+8 -3
@@ -12,6 +12,13 @@ class ArbitrumTokens {
12 decimal: 18,
13 enabled: true,
14 ),
15 + Erc20Token(
16 + name: "Tether USD (Omnichain)",
17 + symbol: "USDT",
18 + contractAddress: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9",
19 + decimal: 6,
20 + enabled: true,
21 + ),
22 Erc20Token(
23 name: "USD Coin",
24 symbol: "USDC",
@@ -68,8 +75,7 @@ class ArbitrumTokens {
75 if (token.iconPath?.isEmpty ?? true) {
76 try {
77 iconPath = CryptoCurrency.all
71 - .firstWhere((element) =>
72 - element.title.toUpperCase() == token.symbol.toUpperCase())
78 + .firstWhere((element) => element.title.toUpperCase() == token.symbol.toUpperCase())
79 .iconPath;
80 } catch (_) {}
81 } else {
@@ -80,4 +86,3 @@ class ArbitrumTokens {
86 }).toList();
87 }
88 }
83 -
cw_evm/lib/usdt0/usdt0_config.dart new
+58
@@ -0,0 +1,58 @@
1 +import 'package:cw_core/erc20_token.dart';
2 +
3 +/// USDT0 (Omnichain USDT) config.
4 +/// Addresses and EIDs from https://docs.usdt0.to/technical-documentation/developer/usdt0-deployments
5 +class USDT0Config {
6 + USDT0Config._();
7 +
8 + static const String ethereumOftAdapter =
9 + '0x6C96dE32CEa08842dcc4058c14d3aaAD7Fa41dee';
10 +
11 + static const String ethereumNativeUsdt =
12 + '0xdac17f958d2ee523a2206206994597c13d831ec7';
13 +
14 + static const Map<int, String> usdt0TokenAddressByChainId = {
15 + 1: ethereumNativeUsdt,
16 + 137: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F',
17 + 42161: '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9',
18 + };
19 +
20 + static const Map<int, String> oftContractAddressByChainId = {
21 + 1: ethereumOftAdapter,
22 + 137: '0x6BA10300f0DC58B7a1e4c0e41f5daBb7D7829e13',
23 + 42161: '0x14E4A1B13bf7F943c8ff7C51fb60FA964A298D92',
24 + };
25 +
26 + static const Map<int, int> endpointIdByChainId = {
27 + 1: 30101,
28 + 137: 30109,
29 + 42161: 30110,
30 + };
31 +
32 + static const int ethereumChainId = 1;
33 +
34 + static List<int> get supportedChainIds =>
35 + usdt0TokenAddressByChainId.keys.toList(growable: false);
36 +
37 + static String? getOftAdapterAddress(int chainId) {
38 + if (chainId == ethereumChainId) return ethereumOftAdapter;
39 + return null;
40 + }
41 +
42 + static String? getUsdt0TokenAddress(int chainId) =>
43 + usdt0TokenAddressByChainId[chainId];
44 +
45 + static String? getOftContractAddress(int chainId) =>
46 + oftContractAddressByChainId[chainId];
47 +
48 + static int? getEndpointId(int chainId) => endpointIdByChainId[chainId];
49 +
50 + static bool isChainSupported(int chainId) =>
51 + usdt0TokenAddressByChainId.containsKey(chainId);
52 +
53 + static bool isUSDT0Token(Erc20Token token, int chainId) {
54 + final address = getUsdt0TokenAddress(chainId);
55 + if (address == null) return false;
56 + return token.contractAddress.toLowerCase() == address.toLowerCase();
57 + }
58 +}
cw_evm/lib/usdt0/usdt0_quote.dart new
+15
@@ -0,0 +1,15 @@
1 +class USDT0Quote {
2 + const USDT0Quote({
3 + required this.nativeFee,
4 + required this.lzTokenFee,
5 + });
6 +
7 + /// Fee in native token (e.g. ETH, MATIC) in wei.
8 + final BigInt nativeFee;
9 +
10 + /// Fee in LZ token (if paying with LZ token).
11 + final BigInt lzTokenFee;
12 +
13 + @override
14 + String toString() => 'USDT0Quote(nativeFee: $nativeFee, lzTokenFee: $lzTokenFee)';
15 +}
cw_evm/lib/usdt0/usdt0_service.dart new
+146
@@ -0,0 +1,146 @@
1 +import 'package:cw_core/erc20_token.dart';
2 +import 'package:cw_core/pending_transaction.dart';
3 +import 'package:cw_evm/contract/erc20.dart';
4 +import 'package:cw_evm/contract/oft.dart';
5 +import 'package:cw_evm/evm_chain_transaction_priority.dart';
6 +import 'package:cw_evm/utils/evm_chain_utils.dart';
7 +import 'package:cw_evm/evm_chain_wallet.dart';
8 +import 'package:cw_evm/usdt0/usdt0_config.dart';
9 +import 'package:cw_evm/usdt0/usdt0_quote.dart';
10 +import 'package:web3dart/web3dart.dart' as web3;
11 +
12 +class USDT0Service {
13 + static Future<USDT0Quote> quoteCrossChainTransfer({
14 + required web3.Web3Client client,
15 + required int sourceChainId,
16 + required int destinationChainId,
17 + required BigInt amount,
18 + required String recipientAddress,
19 + BigInt? minAmount,
20 + }) async {
21 + final min = minAmount ?? BigInt.zero;
22 + final oftAddress = USDT0Config.getOftContractAddress(sourceChainId);
23 + final dstEid = USDT0Config.getEndpointId(destinationChainId);
24 +
25 + if (oftAddress == null || dstEid == null) {
26 + throw Exception(
27 + 'USDT0 not supported for chain $sourceChainId -> $destinationChainId',
28 + );
29 + }
30 +
31 + final toBytes32 = addressToBytes32(recipientAddress);
32 + final oft = OFT(
33 + address: web3.EthereumAddress.fromHex(oftAddress),
34 + client: client,
35 + );
36 +
37 + return oft.quoteSend(
38 + dstEid: dstEid,
39 + toBytes32: toBytes32,
40 + amountLD: amount,
41 + minAmountLD: min,
42 + payInLzToken: false,
43 + );
44 + }
45 +
46 + static Future<PendingTransaction> executeCrossChainTransfer({
47 + required EVMChainWallet wallet,
48 + required int sourceChainId,
49 + required int destinationChainId,
50 + required BigInt amount,
51 + required String recipientAddress,
52 + required USDT0Quote quote,
53 + required Erc20Token token,
54 + required EVMChainTransactionPriority priority,
55 + bool useBlinkProtection = true,
56 + }) async {
57 + final oftAddress = USDT0Config.getOftContractAddress(sourceChainId);
58 + final dstEid = USDT0Config.getEndpointId(destinationChainId);
59 +
60 + if (oftAddress == null || dstEid == null) {
61 + throw Exception(
62 + 'USDT0 not supported for chain $sourceChainId -> $destinationChainId',
63 + );
64 + }
65 +
66 + if (sourceChainId == USDT0Config.ethereumChainId) {
67 + final adapter = USDT0Config.getOftAdapterAddress(sourceChainId);
68 + if (adapter != null) {
69 + final needsApproval = await _isApprovalRequired(
70 + wallet: wallet,
71 + token: token,
72 + spender: adapter,
73 + amount: amount,
74 + );
75 +
76 + if (needsApproval) {
77 + final maxUint = BigInt.parse(
78 + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
79 + radix: 16,
80 + );
81 +
82 + final pendingApproval = await wallet.createApprovalTransaction(
83 + maxUint,
84 + adapter,
85 + token,
86 + priority,
87 + EVMChainUtils.getFeeCurrency(wallet.selectedChainId),
88 + useBlinkProtection: useBlinkProtection,
89 + );
90 + await pendingApproval.commit();
91 + }
92 + }
93 + }
94 +
95 + final client = wallet.getWeb3Client();
96 + if (client == null) {
97 + throw StateError('Wallet not connected to node');
98 + }
99 +
100 + final toBytes32 = addressToBytes32(recipientAddress);
101 + final oft = OFT(
102 + address: web3.EthereumAddress.fromHex(oftAddress),
103 + client: client,
104 + );
105 +
106 + final encoded = oft.encodeSend(
107 + dstEid: dstEid,
108 + toBytes32: toBytes32,
109 + amountLD: amount,
110 + minAmountLD: BigInt.zero,
111 + nativeFee: quote.nativeFee,
112 + lzTokenFee: quote.lzTokenFee,
113 + refundAddress: wallet.walletAddresses.primaryAddress,
114 + );
115 +
116 + return wallet.createCallDataTransaction(
117 + oftAddress,
118 + encoded.dataHex,
119 + encoded.valueWei,
120 + priority,
121 + token.contractAddress,
122 + amount,
123 + useBlinkProtection: useBlinkProtection,
124 + );
125 + }
126 +
127 + static Future<bool> _isApprovalRequired({
128 + required EVMChainWallet wallet,
129 + required Erc20Token token,
130 + required String spender,
131 + required BigInt amount,
132 + }) async {
133 + final client = wallet.getWeb3Client();
134 + if (client == null) return true;
135 +
136 + final erc20 = ERC20(
137 + address: web3.EthereumAddress.fromHex(token.contractAddress),
138 + client: client,
139 + );
140 + final current = await erc20.allowance(
141 + web3.EthereumAddress.fromHex(wallet.walletAddresses.primaryAddress),
142 + web3.EthereumAddress.fromHex(spender),
143 + );
144 + return current < amount;
145 + }
146 +}
lib/core/layerzero_scan_service.dart new
+318
@@ -0,0 +1,318 @@
1 +import 'dart:convert';
2 +
3 +import 'package:cw_core/utils/proxy_wrapper.dart';
4 +
5 +class LayerZeroScanService {
6 + static const String _baseUrl = 'https://scan.layerzero-api.com/v1';
7 +
8 + static Future<LayerZeroMessageStatus?> getMessageStatus(
9 + String sourceTxHash,
10 + ) async {
11 + try {
12 + final uri = Uri.parse('$_baseUrl/messages/tx/$sourceTxHash');
13 +
14 + final response = await ProxyWrapper().get(clearnetUri: uri);
15 +
16 + if (response.statusCode != 200) return null;
17 +
18 + final decoded = json.decode(response.body);
19 +
20 + Map<String, dynamic>? data;
21 + if (decoded is List && decoded.isNotEmpty) {
22 + data = decoded.first as Map<String, dynamic>?;
23 + } else if (decoded is Map<String, dynamic>) {
24 + if (decoded.containsKey('data') && decoded['data'] is List) {
25 + final dataList = decoded['data'] as List;
26 + if (dataList.isNotEmpty) {
27 + data = dataList.first as Map<String, dynamic>?;
28 + }
29 + } else {
30 + data = decoded;
31 + }
32 + }
33 + if (data == null) return null;
34 + return LayerZeroMessageStatus.fromJson(data);
35 + } catch (_) {
36 + return null;
37 + }
38 + }
39 +}
40 +
41 +class LayerZeroMessageStatus {
42 + final String? guid;
43 + final LayerZeroStatus? status;
44 + final LayerZeroSource? source;
45 + final LayerZeroDestination? destination;
46 + final LayerZeroVerification? verification;
47 +
48 + LayerZeroMessageStatus({
49 + this.guid,
50 + this.status,
51 + this.source,
52 + this.destination,
53 + this.verification,
54 + });
55 +
56 + factory LayerZeroMessageStatus.fromJson(Map<String, dynamic> json) {
57 + return LayerZeroMessageStatus(
58 + guid: json['guid'] as String?,
59 + status: json['status'] != null
60 + ? LayerZeroStatus.fromJson(
61 + json['status'] as Map<String, dynamic>,
62 + )
63 + : null,
64 + source: json['source'] != null
65 + ? LayerZeroSource.fromJson(
66 + json['source'] as Map<String, dynamic>,
67 + )
68 + : null,
69 + destination: json['destination'] != null
70 + ? LayerZeroDestination.fromJson(
71 + json['destination'] as Map<String, dynamic>,
72 + )
73 + : null,
74 + verification: json['verification'] != null
75 + ? LayerZeroVerification.fromJson(
76 + json['verification'] as Map<String, dynamic>,
77 + )
78 + : null,
79 + );
80 + }
81 +
82 + bool get isDelivered =>
83 + status?.name == 'DELIVERED' ||
84 + destination?.status == 'DELIVERED' ||
85 + destination?.status == 'SUCCEEDED';
86 +
87 + bool get isFailed => status?.name == 'FAILED' || destination?.status == 'FAILED';
88 +
89 + bool get isInflight =>
90 + status?.name == 'INFLIGHT' ||
91 + status?.name == 'CONFIRMING' ||
92 + destination?.status == 'WAITING' ||
93 + destination?.status == 'VALIDATING_TX' ||
94 + (destination?.status != 'SUCCEEDED' &&
95 + destination?.status != 'DELIVERED' &&
96 + destination?.status != 'FAILED' &&
97 + destination?.tx == null);
98 +}
99 +
100 +class LayerZeroStatus {
101 + final String? name;
102 + final String? message;
103 +
104 + LayerZeroStatus({this.name, this.message});
105 +
106 + factory LayerZeroStatus.fromJson(Map<String, dynamic> json) {
107 + return LayerZeroStatus(
108 + name: json['name'] as String?,
109 + message: json['message'] as String?,
110 + );
111 + }
112 +}
113 +
114 +class LayerZeroSource {
115 + final String? status;
116 + final LayerZeroTransaction? tx;
117 +
118 + LayerZeroSource({
119 + this.status,
120 + this.tx,
121 + });
122 +
123 + factory LayerZeroSource.fromJson(Map<String, dynamic> json) {
124 + return LayerZeroSource(
125 + status: json['status'] as String?,
126 + tx: json['tx'] != null
127 + ? LayerZeroTransaction.fromJson(
128 + json['tx'] as Map<String, dynamic>,
129 + )
130 + : null,
131 + );
132 + }
133 +}
134 +
135 +class LayerZeroDestination {
136 + final String? status;
137 + final LayerZeroTransaction? tx;
138 + final LayerZeroNativeDrop? nativeDrop;
139 + final LayerZeroLzCompose? lzCompose;
140 +
141 + LayerZeroDestination({
142 + this.status,
143 + this.tx,
144 + this.nativeDrop,
145 + this.lzCompose,
146 + });
147 +
148 + factory LayerZeroDestination.fromJson(Map<String, dynamic> json) {
149 + return LayerZeroDestination(
150 + status: json['status'] as String?,
151 + tx: json['tx'] != null
152 + ? LayerZeroTransaction.fromJson(
153 + json['tx'] as Map<String, dynamic>,
154 + )
155 + : null,
156 + nativeDrop: json['nativeDrop'] != null
157 + ? LayerZeroNativeDrop.fromJson(
158 + json['nativeDrop'] as Map<String, dynamic>,
159 + )
160 + : null,
161 + lzCompose: json['lzCompose'] != null
162 + ? LayerZeroLzCompose.fromJson(
163 + json['lzCompose'] as Map<String, dynamic>,
164 + )
165 + : null,
166 + );
167 + }
168 +}
169 +
170 +class LayerZeroNativeDrop {
171 + final String? status;
172 +
173 + LayerZeroNativeDrop({this.status});
174 +
175 + factory LayerZeroNativeDrop.fromJson(Map<String, dynamic> json) {
176 + return LayerZeroNativeDrop(
177 + status: json['status'] as String?,
178 + );
179 + }
180 +}
181 +
182 +class LayerZeroLzCompose {
183 + final String? status;
184 +
185 + LayerZeroLzCompose({this.status});
186 +
187 + factory LayerZeroLzCompose.fromJson(Map<String, dynamic> json) {
188 + return LayerZeroLzCompose(
189 + status: json['status'] as String?,
190 + );
191 + }
192 +}
193 +
194 +class LayerZeroTransaction {
195 + final String? txHash;
196 + final String? blockHash;
197 + final String? blockNumber;
198 + final int? blockTimestamp;
199 + final String? from;
200 +
201 + LayerZeroTransaction({
202 + this.txHash,
203 + this.blockHash,
204 + this.blockNumber,
205 + this.blockTimestamp,
206 + this.from,
207 + });
208 +
209 + factory LayerZeroTransaction.fromJson(Map<String, dynamic> json) {
210 + final blockNumberValue = json['blockNumber'];
211 + final blockNumberStr = blockNumberValue != null
212 + ? (blockNumberValue is int ? blockNumberValue.toString() : blockNumberValue as String?)
213 + : null;
214 + return LayerZeroTransaction(
215 + txHash: json['txHash'] as String?,
216 + blockHash: json['blockHash'] as String?,
217 + blockNumber: blockNumberStr,
218 + blockTimestamp: json['blockTimestamp'] as int?,
219 + from: json['from'] as String?,
220 + );
221 + }
222 +}
223 +
224 +class LayerZeroVerification {
225 + final LayerZeroDvn? dvn;
226 + final LayerZeroSealer? sealer;
227 +
228 + LayerZeroVerification({
229 + this.dvn,
230 + this.sealer,
231 + });
232 +
233 + factory LayerZeroVerification.fromJson(Map<String, dynamic> json) {
234 + return LayerZeroVerification(
235 + dvn: json['dvn'] != null
236 + ? LayerZeroDvn.fromJson(
237 + json['dvn'] as Map<String, dynamic>,
238 + )
239 + : null,
240 + sealer: json['sealer'] != null
241 + ? LayerZeroSealer.fromJson(
242 + json['sealer'] as Map<String, dynamic>,
243 + )
244 + : null,
245 + );
246 + }
247 +}
248 +
249 +class LayerZeroDvn {
250 + final Map<String, LayerZeroDvnStatus>? dvns;
251 + final String? status;
252 +
253 + LayerZeroDvn({
254 + this.dvns,
255 + this.status,
256 + });
257 +
258 + factory LayerZeroDvn.fromJson(Map<String, dynamic> json) {
259 + final dvnsMap = json['dvns'] as Map<String, dynamic>?;
260 + final parsedDvns = dvnsMap?.map(
261 + (key, value) => MapEntry(
262 + key,
263 + LayerZeroDvnStatus.fromJson(value as Map<String, dynamic>),
264 + ),
265 + );
266 + return LayerZeroDvn(
267 + dvns: parsedDvns,
268 + status: json['status'] as String?,
269 + );
270 + }
271 +}
272 +
273 +class LayerZeroDvnStatus {
274 + final String? status;
275 + final String? txHash;
276 + final String? blockHash;
277 + final int? blockNumber;
278 + final int? blockTimestamp;
279 +
280 + LayerZeroDvnStatus({
281 + this.status,
282 + this.txHash,
283 + this.blockHash,
284 + this.blockNumber,
285 + this.blockTimestamp,
286 + });
287 +
288 + factory LayerZeroDvnStatus.fromJson(Map<String, dynamic> json) {
289 + return LayerZeroDvnStatus(
290 + status: json['status'] as String?,
291 + txHash: json['txHash'] as String?,
292 + blockHash: json['blockHash'] as String?,
293 + blockNumber: json['blockNumber'] as int?,
294 + blockTimestamp: json['blockTimestamp'] as int?,
295 + );
296 + }
297 +}
298 +
299 +class LayerZeroSealer {
300 + final String? status;
301 + final LayerZeroTransaction? tx;
302 +
303 + LayerZeroSealer({
304 + this.status,
305 + this.tx,
306 + });
307 +
308 + factory LayerZeroSealer.fromJson(Map<String, dynamic> json) {
309 + return LayerZeroSealer(
310 + status: json['status'] as String?,
311 + tx: json['tx'] != null
312 + ? LayerZeroTransaction.fromJson(
313 + json['tx'] as Map<String, dynamic>,
314 + )
315 + : null,
316 + );
317 + }
318 +}
lib/di.dart
+45 -1
@@ -32,6 +32,7 @@ import 'package:cake_wallet/core/wallet_loading_service.dart';
32 import 'package:cake_wallet/core/yat_service.dart';
33 import 'package:cake_wallet/decred/decred.dart';
34 import 'package:cake_wallet/entities/biometric_auth.dart';
35 +import 'package:cake_wallet/entities/bridge_transfer.dart';
36 import 'package:cake_wallet/entities/contact.dart';
37 import 'package:cake_wallet/entities/contact_record.dart';
38 import 'package:cake_wallet/entities/exchange_api_mode.dart';
@@ -52,6 +53,9 @@ import 'package:cake_wallet/nano/nano.dart';
53 import 'package:cake_wallet/new-ui/new_dashboard.dart';
54 import 'package:cake_wallet/new-ui/pages/about_page.dart';
55 import 'package:cake_wallet/new-ui/pages/account_customizer.dart';
56 +import 'package:cake_wallet/new-ui/pages/bridge/bridge_amount_page.dart';
57 +import 'package:cake_wallet/new-ui/pages/bridge/bridge_network_page.dart';
58 +import 'package:cake_wallet/new-ui/pages/bridge/bridge_receiving_wallet_page.dart';
59 import 'package:cake_wallet/new-ui/pages/coin_control_page.dart';
60 import 'package:cake_wallet/new-ui/pages/addresses_page.dart';
61 import 'package:cake_wallet/new-ui/pages/home_page.dart';
@@ -163,6 +167,8 @@ import 'package:cake_wallet/src/screens/transaction_details/transaction_details_
167 import 'package:cake_wallet/src/screens/unspent_coins/unspent_coins_details_page.dart';
168 import 'package:cake_wallet/src/screens/unspent_coins/unspent_coins_list_page.dart';
169 import 'package:cake_wallet/src/screens/ur/animated_ur_page.dart';
170 +import 'package:cake_wallet/new-ui/pages/bridge/bridge_detail_page.dart';
171 +import 'package:cake_wallet/new-ui/pages/bridge/bridge_history_page.dart';
172 import 'package:cake_wallet/src/screens/wallet/wallet_edit_page.dart';
173 import 'package:cake_wallet/src/screens/wallet_connect/services/bottom_sheet_service.dart';
174 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';
189 import 'package:cake_wallet/store/dashboard/orders_store.dart';
190 import 'package:cake_wallet/store/dashboard/payjoin_transactions_store.dart';
191 import 'package:cake_wallet/store/dashboard/trade_filter_store.dart';
192 +import 'package:cake_wallet/store/bridge_transfers_store.dart';
193 import 'package:cake_wallet/store/dashboard/trades_store.dart';
194 import 'package:cake_wallet/store/dashboard/transaction_filter_store.dart';
195 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';
209 import 'package:cake_wallet/view_model/anonpay_details_view_model.dart';
210 import 'package:cake_wallet/view_model/auth_view_model.dart';
211 import 'package:cake_wallet/view_model/backup_view_model.dart';
212 +import 'package:cake_wallet/view_model/bridge_details_view_model.dart';
213 +import 'package:cake_wallet/view_model/bridge_history_view_model.dart';
214 import 'package:cake_wallet/view_model/buy/buy_amount_view_model.dart';
215 import 'package:cake_wallet/view_model/buy/buy_sell_view_model.dart';
216 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';
278 import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_details_view_model.dart';
279 import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_item.dart';
280 import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart';
281 +import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
282 import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart';
283 import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_item.dart';
284 import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart';
@@ -327,7 +337,6 @@ late Box<Order> _ordersSource;
337 late Box<UnspentCoinsInfo> _unspentCoinsInfoSource;
338 late Box<PayjoinSession> _payjoinSessionSource;
339 late Box<AnonpayInvoiceInfo> _anonpayInvoiceInfoSource;
330 -
340 Future<void> setup({
341 required Box<Node> nodeSource,
342 required Box<Node> powNodeSource,
@@ -396,6 +405,7 @@ Future<void> setup({
405 TradesStore(tradesSource: _tradesSource, appStore: getIt.get<AppStore>()));
406 getIt.registerSingleton<OrdersStore>(
407 OrdersStore(ordersSource: _ordersSource, settingsStore: getIt.get<SettingsStore>()));
408 + getIt.registerSingleton<BridgeTransfersStore>(BridgeTransfersStore());
409 getIt.registerFactory(() =>
410 PayjoinTransactionsStore(payjoinSessionSource: _payjoinSessionSource));
411 getIt.registerSingleton<TradeFilterStore>(TradeFilterStore());
@@ -1761,11 +1771,45 @@ Future<void> setup({
1771
1772 getIt.registerFactory(() => DEuroSavingsPage(getIt<DEuroViewModel>()));
1773
1774 + getIt.registerFactory(() => BridgeViewModel(
1775 + appStore: getIt.get<AppStore>(),
1776 + bridgeTransfersStore: getIt.get<BridgeTransfersStore>(),
1777 + walletManager: getIt.get<WalletManager>(),
1778 + fiatConversionStore: getIt.get<FiatConversionStore>(),
1779 + settingsStore: getIt.get<SettingsStore>(),
1780 + ));
1781 +
1782 + getIt.registerFactory(() => BridgeHistoryViewModel(
1783 + bridgeTransfersStore: getIt.get<BridgeTransfersStore>(),
1784 + appStore: getIt.get<AppStore>(),
1785 + ));
1786 + getIt.registerFactoryParam<BridgeDetailsViewModel, BridgeTransfer, void>(
1787 + (BridgeTransfer transfer, _) {
1788 + final appStore = getIt.get<AppStore>();
1789 + return BridgeDetailsViewModel(
1790 + transferForDetails: transfer,
1791 + bridgeTransfersStore: getIt.get<BridgeTransfersStore>(),
1792 + walletId: appStore.wallet?.name ?? transfer.walletId,
1793 + );
1794 + });
1795 + getIt.registerFactoryParam<BridgeDetailPage, BridgeTransfer, void>(
1796 + (BridgeTransfer transfer, _) => BridgeDetailPage(
1797 + viewModel: getIt.get<BridgeDetailsViewModel>(param1: transfer),
1798 + ));
1799 +
1800 getIt.registerLazySingleton(() => NodeSwitchingService(
1801 appStore: getIt.get<AppStore>(),
1802 settingsStore: getIt.get<SettingsStore>(),
1803 nodeSource: _nodeSource,
1804 ));
1805
1806 + getIt.registerFactoryParam<BridgeAmountPage, CryptoCurrency, void>(
1807 + (CryptoCurrency initialToken, _) => BridgeAmountPage(
1808 + bridgeViewModel: getIt.get<BridgeViewModel>(),
1809 + bridgeHistoryViewModel: getIt.get<BridgeHistoryViewModel>(),
1810 + initialToken: initialToken,
1811 + ),
1812 + );
1813 +
1814 _isSetupFinished = true;
1815 }
lib/entities/bridge_transfer.dart new
+127
@@ -0,0 +1,127 @@
1 +import 'package:cw_core/db/sqlite.dart';
2 +
3 +class BridgeTransfer {
4 + BridgeTransfer({
5 + required this.id,
6 + required this.walletId,
7 + required this.sourceChainId,
8 + required this.destinationChainId,
9 + required this.tokenSymbol,
10 + required this.tokenContract,
11 + required this.amount,
12 + required this.recipientAddress,
13 + required this.sourceTxHash,
14 + required this.status,
15 + required this.createdAt,
16 + this.updatedAt,
17 + this.confirmedAt,
18 + this.amountRaw,
19 + this.errorMessage,
20 + this.statusMessage,
21 + });
22 +
23 + static const tableName = 'BridgeTransfer';
24 +
25 + static Future<List<BridgeTransfer>> selectAll() async {
26 + final database = db;
27 + if (database == null) return [];
28 +
29 + final rows = await database.query(
30 + tableName,
31 + orderBy: 'created_at DESC',
32 + );
33 + return rows.map(fromRow).toList();
34 + }
35 +
36 + static Future<void> insert(BridgeTransfer transfer) async {
37 + final database = db;
38 + if (database == null) return;
39 +
40 + await database.insert(tableName, transfer.toRow());
41 + }
42 +
43 + static Future<void> update(BridgeTransfer transfer) async {
44 + final database = db;
45 + if (database == null) return;
46 +
47 + await database.update(
48 + tableName,
49 + transfer.toRow(),
50 + where: 'id = ?',
51 + whereArgs: [transfer.id],
52 + );
53 + }
54 +
55 + String id;
56 + String walletId;
57 + int sourceChainId;
58 + int destinationChainId;
59 + String tokenSymbol;
60 + String tokenContract;
61 + String amount;
62 + String recipientAddress;
63 + String sourceTxHash;
64 + String status;
65 + DateTime createdAt;
66 + DateTime? updatedAt;
67 + DateTime? confirmedAt;
68 + String? amountRaw;
69 + String? errorMessage;
70 + String? statusMessage;
71 +
72 + bool get isActive => status == 'submitted' || status == 'confirming' || status == 'initiated';
73 +
74 + Map<String, Object?> toRow() {
75 + return {
76 + 'id': id,
77 + 'wallet_id': walletId,
78 + 'source_chain_id': sourceChainId,
79 + 'destination_chain_id': destinationChainId,
80 + 'token_symbol': tokenSymbol,
81 + 'token_contract': tokenContract,
82 + 'amount': amount,
83 + 'recipient_address': recipientAddress,
84 + 'source_tx_hash': sourceTxHash,
85 + 'status': status,
86 + 'created_at': createdAt.millisecondsSinceEpoch,
87 + 'updated_at': updatedAt?.millisecondsSinceEpoch,
88 + 'confirmed_at': confirmedAt?.millisecondsSinceEpoch,
89 + 'amount_raw': amountRaw,
90 + 'error_message': errorMessage,
91 + 'status_message': statusMessage,
92 + };
93 + }
94 +
95 + static int? _nullableInt(Object? v) {
96 + if (v == null) return null;
97 + if (v is int) return v;
98 + if (v is num) return v.toInt();
99 + return int.tryParse(v.toString());
100 + }
101 +
102 + static int _parseInt(Object? v) => _nullableInt(v) ?? 0;
103 + static DateTime _parseDateTime(Object? v) => DateTime.fromMillisecondsSinceEpoch(_parseInt(v));
104 +
105 + static BridgeTransfer fromRow(Map<String, Object?> m) {
106 + String? asStr(Object? v) => v as String?;
107 +
108 + return BridgeTransfer(
109 + id: m['id'] as String,
110 + walletId: m['wallet_id'] as String,
111 + sourceChainId: _parseInt(m['source_chain_id']),
112 + destinationChainId: _parseInt(m['destination_chain_id']),
113 + tokenSymbol: m['token_symbol'] as String,
114 + tokenContract: m['token_contract'] as String,
115 + amount: m['amount'] as String,
116 + recipientAddress: m['recipient_address'] as String,
117 + sourceTxHash: m['source_tx_hash'] as String,
118 + status: m['status'] as String,
119 + createdAt: _parseDateTime(m['created_at']),
120 + updatedAt: _parseDateTime(m['updated_at']),
121 + confirmedAt: _parseDateTime(m['confirmed_at']),
122 + amountRaw: asStr(m['amount_raw']),
123 + errorMessage: asStr(m['error_message']),
124 + statusMessage: asStr(m['status_message']),
125 + );
126 + }
127 +}
lib/entities/wallet_manager.dart
+10 -1
@@ -4,7 +4,6 @@ import 'package:cake_wallet/entities/hash_wallet_identifier.dart';
4 import 'package:cake_wallet/entities/wallet_group.dart';
5 import 'package:cw_core/wallet_base.dart';
6 import 'package:cw_core/wallet_info.dart';
7 -import 'package:hive/hive.dart';
7 import 'package:shared_preferences/shared_preferences.dart';
8
9 class WalletManager {
@@ -163,4 +162,14 @@ class WalletManager {
162 await _sharedPreferences.remove(oldNameKey);
163 }
164 }
165 +
166 + String? getGroupName(WalletInfo walletInfo) {
167 + try {
168 + final groupKey = _resolveGroupKey(walletInfo);
169 + final group = walletGroups.firstWhere((g) => g.groupKey == groupKey);
170 + return group.groupName;
171 + } catch (_) {
172 + return null;
173 + }
174 + }
175 }
lib/evm/cw_evm.dart
+110
@@ -226,6 +226,22 @@ class CWEVM extends EVM {
226 @override
227 Web3Client? getWeb3Client(WalletBase wallet) => (wallet as EVMChainWallet).getWeb3Client();
228
229 + @override
230 + Future<bool?> getTransactionReceipt(WalletBase wallet, String txHash) async {
231 + final client = getWeb3Client(wallet);
232 + if (client == null) return null;
233 +
234 + try {
235 + final receipt = await client.getTransactionReceipt(txHash);
236 +
237 + if (receipt == null) return null;
238 +
239 + return receipt.status;
240 + } catch (_) {
241 + return null;
242 + }
243 + }
244 +
245 @override
246 String getTokenAddress(CryptoCurrency asset) => (asset as Erc20Token).contractAddress;
247
@@ -492,10 +508,24 @@ class CWEVM extends EVM {
508 chainId: config.chainId,
509 name: config.name,
510 shortCode: config.shortCode,
511 + currency: config.nativeCurrency,
512 ))
513 .toList();
514 }
515
516 + @override
517 + ChainInfo? getChainInfoByChainId(int chainId) {
518 + final config = _registry.getChainConfig(chainId);
519 + if (config == null) return null;
520 +
521 + return ChainInfo(
522 + chainId: config.chainId,
523 + name: config.name,
524 + shortCode: config.shortCode,
525 + currency: config.nativeCurrency,
526 + );
527 + }
528 +
529 @override
530 ChainInfo? getCurrentChain(WalletBase wallet) {
531 if (wallet is EVMChainWallet) {
@@ -505,6 +535,7 @@ class CWEVM extends EVM {
535 chainId: config.chainId,
536 name: config.name,
537 shortCode: config.shortCode,
538 + currency: config.nativeCurrency,
539 );
540 }
541 return null;
@@ -542,6 +573,85 @@ class CWEVM extends EVM {
573 bool hasPriorityFee(int chainId) => EVMChainUtils.hasPriorityFee(chainId);
574
575 @override
576 + bool isUSDT0Token(WalletBase wallet, CryptoCurrency token) {
577 + if (token is! Erc20Token) return false;
578 +
579 + final chainId = getSelectedChainId(wallet);
580 + if (chainId == null) return false;
581 +
582 + return USDT0Config.isUSDT0Token(token, chainId);
583 + }
584 +
585 + @override
586 + List<ChainInfo> getUSDT0DestinationChains(WalletBase wallet) {
587 + final currentChainId = getSelectedChainId(wallet);
588 + if (currentChainId == null) return [];
589 +
590 + final result = <ChainInfo>[];
591 + for (final config in _registry.getAllChains()) {
592 + if (USDT0Config.isChainSupported(config.chainId) && config.chainId != currentChainId) {
593 + result.add(ChainInfo(
594 + chainId: config.chainId,
595 + name: config.name,
596 + shortCode: config.shortCode,
597 + currency: config.nativeCurrency,
598 + ));
599 + }
600 + }
601 + return result;
602 + }
603 +
604 + @override
605 + Future<USDT0Quote> quoteUSDT0Transfer({
606 + required WalletBase wallet,
607 + required int sourceChainId,
608 + required int destinationChainId,
609 + required BigInt amount,
610 + required String recipientAddress,
611 + }) {
612 + final evmWallet = wallet as EVMChainWallet;
613 + final client = evmWallet.getWeb3Client();
614 + if (client == null) {
615 + throw StateError('Wallet not connected');
616 + }
617 +
618 + return USDT0Service.quoteCrossChainTransfer(
619 + client: client,
620 + sourceChainId: sourceChainId,
621 + destinationChainId: destinationChainId,
622 + amount: amount,
623 + recipientAddress: recipientAddress,
624 + );
625 + }
626 +
627 + @override
628 + Future<PendingTransaction> executeUSDT0Transfer({
629 + required WalletBase wallet,
630 + required CryptoCurrency token,
631 + required int sourceChainId,
632 + required int destinationChainId,
633 + required BigInt amount,
634 + required String recipientAddress,
635 + required USDT0Quote quote,
636 + required TransactionPriority priority,
637 + bool useBlinkProtection = true,
638 + }) {
639 + final evmWallet = wallet as EVMChainWallet;
640 + final tokenErc20 = token as Erc20Token;
641 +
642 + return USDT0Service.executeCrossChainTransfer(
643 + wallet: evmWallet,
644 + sourceChainId: sourceChainId,
645 + destinationChainId: destinationChainId,
646 + amount: amount,
647 + recipientAddress: recipientAddress,
648 + quote: quote,
649 + token: tokenErc20,
650 + priority: priority as EVMChainTransactionPriority,
651 + useBlinkProtection: useBlinkProtection,
652 +
653 + );
654 + }
655 Future<EvmWalletConnectFeeQuote?> getWCBufferedFeeQuote(
656 WalletBase wallet,
657 TransactionPriority priority,
lib/main.dart
+1 -1
@@ -362,10 +362,10 @@ Future<void> initialSetup({
362 powNodeSource: powNodes,
363 contactSource: contactSource,
364 tradesSource: tradesSource,
365 + ordersSource: ordersSource,
366 templates: templates,
367 exchangeTemplates: exchangeTemplates,
368 transactionDescriptionBox: transactionDescriptions,
368 - ordersSource: ordersSource,
369 anonpayInvoiceInfoSource: anonpayInvoiceInfo,
370 unspentCoinsInfoSource: unspentCoinsInfoSource,
371 payjoinSessionSource: payjoinSessionSource,
lib/new-ui/pages/bridge/bridge_amount_page.dart new
+305
@@ -0,0 +1,305 @@
1 +import 'package:cake_wallet/generated/i18n.dart';
2 +import 'package:cake_wallet/new-ui/pages/bridge/bridge_history_page.dart';
3 +import 'package:cake_wallet/routes.dart';
4 +import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
5 +import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart';
6 +import 'package:cake_wallet/utils/request_review_handler.dart';
7 +import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
8 +import 'package:cake_wallet/new-ui/widgets/modern_button.dart';
9 +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
10 +import 'package:cake_wallet/view_model/bridge_history_view_model.dart';
11 +import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
12 +import 'package:cw_core/crypto_currency.dart';
13 +import 'package:flutter/material.dart';
14 +import 'package:flutter/services.dart';
15 +import 'package:flutter_mobx/flutter_mobx.dart';
16 +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
17 +
18 +class BridgeAmountPage extends StatefulWidget {
19 + const BridgeAmountPage({
20 + super.key,
21 + required this.bridgeViewModel,
22 + required this.bridgeHistoryViewModel,
23 + required this.initialToken,
24 + });
25 +
26 + final BridgeViewModel bridgeViewModel;
27 + final BridgeHistoryViewModel bridgeHistoryViewModel;
28 +
29 + final CryptoCurrency initialToken;
30 +
31 + @override
32 + State<BridgeAmountPage> createState() => _BridgeAmountPageState();
33 +}
34 +
35 +class _BridgeAmountPageState extends State<BridgeAmountPage> {
36 + late final TextEditingController _amountController;
37 + BridgeViewModel get bridgeViewModel => widget.bridgeViewModel;
38 +
39 + @override
40 + void initState() {
41 + super.initState();
42 + bridgeViewModel.applyInitialBridgeToken(widget.initialToken);
43 + bridgeViewModel.onBridgeSuccess = _showBridgeSuccessBottomSheet;
44 +
45 + _amountController = TextEditingController();
46 +
47 + WidgetsBinding.instance.addPostFrameCallback((_) {
48 + bridgeViewModel.ensureFiatPriceForSelectedToken();
49 + });
50 + }
51 +
52 + void _showBridgeSuccessBottomSheet() {
53 + if (!mounted) return;
54 + WidgetsBinding.instance.addPostFrameCallback((_) async {
55 + if (!mounted) return;
56 + final ctx = context;
57 + if (!ctx.mounted) return;
58 +
59 + await showModalBottomSheet<void>(
60 + context: ctx,
61 + isScrollControlled: true,
62 + builder: (BuildContext bottomSheetContext) {
63 + return InfoBottomSheet(
64 + footerType: FooterType.doubleActionButton,
65 + titleText: 'Bridge initiated!',
66 + contentImage: 'assets/images/birthday_cake.png',
67 + content: 'The bridging will take between 30 seconds and 3 '
68 + 'minutes to complete.',
69 + doubleActionLeftButtonText: S.of(bottomSheetContext).close,
70 + doubleActionRightButtonText: 'View history',
71 + onLeftActionButtonPressed: () {
72 + bridgeViewModel.clearBridgeSuccess();
73 + Navigator.of(context, rootNavigator: true).pop();
74 + RequestReviewHandler.requestReview();
75 + },
76 + onRightActionButtonPressed: () {
77 + bridgeViewModel.clearBridgeSuccess();
78 + Navigator.of(context).popUntil((route) => route.isFirst);
79 + showMaterialModalBottomSheet(
80 + context: context,
81 + backgroundColor: Colors.transparent,
82 + builder: (ctx) => BridgeHistoryPage(widget.bridgeHistoryViewModel),
83 + );
84 + },
85 + );
86 + },
87 + );
88 + });
89 + }
90 +
91 + @override
92 + void dispose() {
93 + bridgeViewModel.onBridgeSuccess = null;
94 + bridgeViewModel.setAmount('');
95 + _amountController.dispose();
96 + super.dispose();
97 + }
98 +
99 + @override
100 + Widget build(BuildContext context) {
101 + final theme = Theme.of(context);
102 +
103 + return KeyboardHideOverlay(
104 + unfocusOnTap: true,
105 + child: Container(
106 + decoration: BoxDecoration(
107 + color: theme.colorScheme.surface,
108 + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
109 + ),
110 + child: Column(
111 + children: [
112 + ModalTopBar(
113 + title: 'Enter Amount',
114 + leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
115 + onLeadingPressed: () => Navigator.of(context, rootNavigator: true).pop(),
116 + trailingIcon: const Icon(Icons.calendar_month, size: 18),
117 + onTrailingPressed: () {
118 + Navigator.pushNamed(context, Routes.bridgeHistoryPage, arguments: widget.bridgeHistoryViewModel);
119 + },
120 + ),
121 + Expanded(
122 + child: SafeArea(
123 + child: Padding(
124 + padding: const EdgeInsets.symmetric(horizontal: 18),
125 + child: Observer(
126 + builder: (_) {
127 + final canNext = bridgeViewModel.canProceedToDestinationNetwork;
128 +
129 + return Column(
130 + crossAxisAlignment: CrossAxisAlignment.stretch,
131 + children: [
132 + const Spacer(flex: 2),
133 + LayoutBuilder(
134 + builder: (context, constraints) {
135 + return Center(
136 + child: Row(
137 + mainAxisSize: MainAxisSize.min,
138 + crossAxisAlignment: CrossAxisAlignment.baseline,
139 + textBaseline: TextBaseline.alphabetic,
140 + children: [
141 + ConstrainedBox(
142 + constraints: BoxConstraints(
143 + maxWidth: (constraints.maxWidth - 200)
144 + .clamp(40.0, constraints.maxWidth),
145 + minWidth: 40,
146 + ),
147 + child: TextFormField(
148 + controller: _amountController,
149 + maxLines: 1,
150 + onChanged: bridgeViewModel.setAmount,
151 + autovalidateMode: AutovalidateMode.always,
152 + validator: bridgeViewModel.decimalAmountValidator,
153 + keyboardType: TextInputType.numberWithOptions(
154 + signed: false,
155 + decimal: true,
156 + ),
157 + inputFormatters: <TextInputFormatter>[
158 + FilteringTextInputFormatter.allow(
159 + RegExp(r'^\d*[.,]?\d*$'),
160 + ),
161 + ],
162 + textAlign: TextAlign.center,
163 + decoration: InputDecoration(
164 + isDense: true,
165 + hintText: '0.00',
166 + hintStyle: theme.textTheme.displayMedium?.copyWith(
167 + fontWeight: FontWeight.w400,
168 + color: theme.colorScheme.onSurfaceVariant,
169 + ),
170 + ),
171 + style: theme.textTheme.displayMedium?.copyWith(
172 + fontWeight: FontWeight.w400,
173 + fontSize: 45,
174 + color: theme.colorScheme.onSurface,
175 + ),
176 + ),
177 + ),
178 + const SizedBox(width: 8),
179 + Text(
180 + bridgeViewModel.selectedToken?.title ?? '',
181 + maxLines: 1,
182 + overflow: TextOverflow.ellipsis,
183 + style: theme.textTheme.displayMedium?.copyWith(
184 + fontWeight: FontWeight.w400,
185 + fontSize: 45,
186 + color: theme.colorScheme.onSurfaceVariant,
187 + ),
188 + ),
189 + ],
190 + ),
191 + );
192 + },
193 + ),
194 + const SizedBox(height: 12),
195 + Center(
196 + child: Text(
197 + bridgeViewModel.fiatAmountFormatted.isEmpty
198 + ? ''
199 + : '~${bridgeViewModel.fiatAmountFormatted} ${bridgeViewModel.fiatCurrencyTitle}',
200 + style: theme.textTheme.headlineSmall?.copyWith(
201 + color: theme.colorScheme.onSurfaceVariant,
202 + fontWeight: FontWeight.w600,
203 + fontSize: 22,
204 + letterSpacing: -0.11,
205 + ),
206 + ),
207 + ),
208 + if (bridgeViewModel.amountError != null) ...[
209 + const SizedBox(height: 10),
210 + Text(
211 + bridgeViewModel.amountError!,
212 + textAlign: TextAlign.center,
213 + style: theme.textTheme.bodySmall?.copyWith(
214 + color: theme.colorScheme.error,
215 + ),
216 + ),
217 + ],
218 + const Spacer(flex: 3),
219 + Row(
220 + crossAxisAlignment: CrossAxisAlignment.center,
221 + children: [
222 + Expanded(
223 + child: RichText(
224 + maxLines: 2,
225 + overflow: TextOverflow.ellipsis,
226 + text: TextSpan(
227 + children: [
228 + TextSpan(
229 + text: 'Available ',
230 + style: theme.textTheme.bodyLarge?.copyWith(
231 + color: theme.colorScheme.onSurfaceVariant,
232 + fontWeight: FontWeight.w400,
233 + fontSize: 16,
234 + letterSpacing: -0.08,
235 + ),
236 + ),
237 + TextSpan(
238 + text: '${bridgeViewModel.tokenBalanceFormatted} '
239 + '${bridgeViewModel.selectedToken?.title ?? ''}',
240 + style: theme.textTheme.bodyLarge?.copyWith(
241 + color: theme.colorScheme.onSurface,
242 + fontWeight: FontWeight.w400,
243 + fontSize: 16,
244 + letterSpacing: -0.08,
245 + ),
246 + ),
247 + ],
248 + ),
249 + ),
250 + ),
251 + const SizedBox(width: 10),
252 + TextButton(
253 + style: TextButton.styleFrom(
254 + textStyle: theme.textTheme.bodyMedium?.copyWith(
255 + fontWeight: FontWeight.w400,
256 + fontSize: 14,
257 + letterSpacing: -0.07,
258 + ),
259 + foregroundColor: theme.colorScheme.primary,
260 + backgroundColor: theme.colorScheme.surfaceContainer,
261 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
262 + shape: RoundedRectangleBorder(
263 + borderRadius: BorderRadius.circular(80),
264 + ),
265 + ),
266 + onPressed: () {
267 + bridgeViewModel.setMaxAmount();
268 + _amountController.text = bridgeViewModel.amount;
269 + _amountController.selection = TextSelection.collapsed(
270 + offset: _amountController.text.length,
271 + );
272 + },
273 + child: Text(S.of(context).max),
274 + ),
275 + const SizedBox(width: 10),
276 + IgnorePointer(
277 + ignoring: !canNext,
278 + child: ModernButton(
279 + size: 48,
280 + backgroundColor: theme.colorScheme.primary,
281 + iconColor: theme.colorScheme.onPrimary,
282 + icon: Icon(Icons.arrow_forward, size: 25),
283 + onPressed: () {
284 + _amountController.clear();
285 + Navigator.pushNamed(
286 + context, Routes.bridgeDestinationNetworkPage, arguments: bridgeViewModel);
287 + },
288 + ),
289 + ),
290 + ],
291 + ),
292 + ],
293 + );
294 + },
295 + ),
296 + ),
297 + ),
298 + ),
299 + ],
300 + ),
301 + ),
302 + );
303 + }
304 +}
305 +
lib/new-ui/pages/bridge/bridge_confirm_sheet.dart new
+227
@@ -0,0 +1,227 @@
1 +import 'package:cake_wallet/generated/i18n.dart';
2 +import 'package:cake_wallet/new-ui/widgets/bridge/confirm_details_card.dart';
3 +import 'package:cake_wallet/new-ui/widgets/bridge/network_path_pill.dart';
4 +import 'package:cake_wallet/new-ui/widgets/confirm_swiper.dart';
5 +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
6 +import 'package:cake_wallet/new-ui/widgets/send_page/send_confirm_bottom_widget.dart';
7 +import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
8 +import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
9 +import 'package:flutter/material.dart';
10 +import 'package:flutter_mobx/flutter_mobx.dart';
11 +import 'package:mobx/mobx.dart';
12 +
13 +class BridgeConfirmSheet extends StatefulWidget {
14 + const BridgeConfirmSheet(this.bridgeViewModel);
15 +
16 + final BridgeViewModel bridgeViewModel;
17 +
18 + @override
19 + State<BridgeConfirmSheet> createState() => _BridgeConfirmSheetState();
20 +}
21 +
22 +class _BridgeConfirmSheetState extends State<BridgeConfirmSheet> {
23 + late final ReactionDisposer _successDisposer;
24 +
25 + BridgeViewModel get bridgeViewModel => widget.bridgeViewModel;
26 +
27 + @override
28 + void initState() {
29 + super.initState();
30 + _successDisposer = reaction(
31 + (_) => bridgeViewModel.bridgeSuccess,
32 + (bool success) {
33 + if (success && mounted) {
34 + Navigator.of(context).maybePop();
35 + }
36 + },
37 + );
38 +
39 + WidgetsBinding.instance.addPostFrameCallback((_) {
40 + bridgeViewModel.loadQuote();
41 + bridgeViewModel.ensureFiatPriceForSelectedToken();
42 + bridgeViewModel.ensureFiatPriceForNativeCurrency();
43 + });
44 + }
45 +
46 + @override
47 + void dispose() {
48 + _successDisposer();
49 + super.dispose();
50 + }
51 +
52 + @override
53 + Widget build(BuildContext context) {
54 + final scheme = Theme.of(context).colorScheme;
55 +
56 + return Container(
57 + decoration: BoxDecoration(
58 + color: scheme.surface,
59 + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
60 + ),
61 + child: SafeArea(
62 + child: Column(
63 + mainAxisSize: MainAxisSize.min,
64 + children: [
65 + Observer(
66 + builder: (_) {
67 + return ModalTopBar(
68 + title: '',
69 + leadingWidget: Row(
70 + spacing: 8,
71 + children: [
72 + CakeImageWidget(
73 + imageUrl: bridgeViewModel.selectedToken?.iconPath ?? '',
74 + width: 36,
75 + height: 36,
76 + ),
77 + Text(
78 + 'Bridge',
79 + style: Theme.of(context).textTheme.titleLarge?.copyWith(
80 + fontWeight: FontWeight.w500,
81 + fontSize: 20,
82 + letterSpacing: -0.1,
83 + color: scheme.onSurface,
84 + ),
85 + ),
86 + ],
87 + ),
88 + trailingIcon: const Icon(Icons.close),
89 + onTrailingPressed: () => Navigator.of(context).maybePop(),
90 + );
91 + },
92 + ),
93 + SingleChildScrollView(
94 + padding: const EdgeInsets.symmetric(horizontal: 24),
95 + child: Observer(
96 + builder: (_) {
97 + if (bridgeViewModel.isQuoteLoading && bridgeViewModel.quote == null) {
98 + return const Padding(
99 + padding: EdgeInsets.symmetric(vertical: 48),
100 + child: Center(
101 + child: CircularProgressIndicator(),
102 + ),
103 + );
104 + }
105 +
106 + if (bridgeViewModel.quoteError != null) {
107 + return Padding(
108 + padding: const EdgeInsets.only(bottom: 24),
109 + child: Column(
110 + children: [
111 + Text(
112 + bridgeViewModel.quoteError!,
113 + textAlign: TextAlign.center,
114 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(
115 + fontWeight: FontWeight.w400,
116 + fontSize: 14,
117 + letterSpacing: -0.07,
118 + color: scheme.error,
119 + ),
120 + ),
121 + const SizedBox(height: 16),
122 + FilledButton(
123 + onPressed: () => bridgeViewModel.loadQuote(),
124 + child: Text(
125 + S.of(context).try_again,
126 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(
127 + fontWeight: FontWeight.w500,
128 + fontSize: 16,
129 + letterSpacing: -0.08,
130 + color: scheme.onPrimary,
131 + ),
132 + ),
133 + ),
134 + ],
135 + ),
136 + );
137 + }
138 +
139 + return Column(
140 + crossAxisAlignment: CrossAxisAlignment.stretch,
141 + spacing: 24,
142 + children: [
143 + Column(
144 + children: [
145 + Row(
146 + mainAxisAlignment: MainAxisAlignment.center,
147 + spacing: 4,
148 + children: [
149 + Text(
150 + bridgeViewModel.amountDisplayFormatted,
151 + style: Theme.of(context).textTheme.displaySmall?.copyWith(
152 + fontSize: 36,
153 + fontWeight: FontWeight.w400,
154 + color: scheme.onSurface,
155 + ),
156 + ),
157 + Text(
158 + bridgeViewModel.selectedToken?.title ?? '',
159 + style: Theme.of(context).textTheme.displaySmall?.copyWith(
160 + fontSize: 36,
161 + fontWeight: FontWeight.w400,
162 + color: scheme.onSurfaceVariant,
163 + ),
164 + ),
165 + ],
166 + ),
167 + const SizedBox(height: 4),
168 + Text(
169 + '${bridgeViewModel.fiatCurrencyTitle} ${bridgeViewModel.fiatAmountFormatted}',
170 + style: Theme.of(context).textTheme.titleLarge?.copyWith(
171 + fontSize: 20,
172 + fontWeight: FontWeight.w500,
173 + color: scheme.onSurfaceVariant,
174 + letterSpacing: -0.1,
175 + ),
176 + ),
177 + ],
178 + ),
179 + NetworkPathPill(
180 + sourceChainName: bridgeViewModel.wallet.type.name,
181 + destChainName: bridgeViewModel.destinationChainInfo?.name ?? '',
182 + ),
183 + ConfirmDetailsCard(bridgeViewModel: bridgeViewModel),
184 + if (bridgeViewModel.executeError != null) ...[
185 + Text(
186 + bridgeViewModel.executeError!,
187 + textAlign: TextAlign.center,
188 + style: TextStyle(color: scheme.error),
189 + ),
190 + ],
191 + const SizedBox(height: 8),
192 + ],
193 + );
194 + },
195 + ),
196 + ),
197 + Padding(
198 + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
199 + child: Observer(
200 + builder: (_) {
201 + if (bridgeViewModel.isExecuting) {
202 + return const LoadingBottomWidget(
203 + text: 'Bridging...',
204 + );
205 + }
206 + final canSwipe = bridgeViewModel.quote != null && !bridgeViewModel.isQuoteLoading;
207 + return IgnorePointer(
208 + ignoring: !canSwipe,
209 + child: Opacity(
210 + opacity: canSwipe ? 1 : 0.45,
211 + child: ConfirmSwiper(
212 + onConfirmed: () {
213 + bridgeViewModel.executeBridge();
214 + },
215 + swiperText: 'Swipe to bridge',
216 + ),
217 + ),
218 + );
219 + },
220 + ),
221 + ),
222 + ],
223 + ),
224 + ),
225 + );
226 + }
227 +}
lib/new-ui/pages/bridge/bridge_detail_page.dart new
+160
@@ -0,0 +1,160 @@
1 +import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart';
2 +import 'package:cake_wallet/generated/i18n.dart';
3 +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
4 +import 'package:cake_wallet/src/screens/trade_details/track_trade_list_item.dart';
5 +import 'package:cake_wallet/src/screens/transaction_details/address_list_item.dart';
6 +import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart';
7 +import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart';
8 +import 'package:cake_wallet/themes/core/custom_theme_colors.dart';
9 +import 'package:cake_wallet/utils/address_formatter.dart';
10 +import 'package:cake_wallet/utils/show_bar.dart';
11 +import 'package:cake_wallet/view_model/bridge_details_view_model.dart';
12 +import 'package:cw_core/generate_name.dart';
13 +import 'package:flutter/material.dart';
14 +import 'package:flutter/services.dart';
15 +
16 +class BridgeDetailPage extends StatelessWidget {
17 + BridgeDetailPage({super.key, required this.viewModel});
18 +
19 + final BridgeDetailsViewModel viewModel;
20 +
21 + @override
22 + Widget build(BuildContext context) {
23 + final theme = Theme.of(context);
24 +
25 + return DraggableScrollableSheet(
26 + expand: false,
27 + initialChildSize: 0.6,
28 + minChildSize: 0.25,
29 + maxChildSize: 0.9,
30 + snap: true,
31 + snapSizes: const [0.9],
32 + builder: (context, controller) {
33 + return SafeArea(
34 + bottom: false,
35 + child: Padding(
36 + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
37 + child: GestureDetector(
38 + onTap: () => FocusScope.of(context).unfocus(),
39 + child: Container(
40 + decoration: BoxDecoration(
41 + color: theme.colorScheme.surface,
42 + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
43 + ),
44 + child: Column(
45 + children: [
46 + ModalTopBar(
47 + title: "Bridge Transfer Details",
48 + leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
49 + onLeadingPressed: () => Navigator.of(context).pop(),
50 + ),
51 + Expanded(
52 + child: Padding(
53 + padding: const EdgeInsets.all(16),
54 + child: Column(
55 + children: [
56 + NewListSections(
57 + sections: {
58 + "": viewModel.items
59 + .where((item) => item is! TrackTradeListItem)
60 + .map((item) {
61 + return ListItemRegularRow(
62 + onTap: () {
63 + Clipboard.setData(ClipboardData(text: item.value));
64 + showBar<void>(context, S.of(context).copied_to_clipboard);
65 + },
66 + showArrow: false,
67 + keyValue: item.title,
68 + label: item.title,
69 + trailingWidget: _buildTrailingWidget(item, context),
70 + bottomWidget: _buildBottomWidget(item, context),
71 + );
72 + }).toList(),
73 + },
74 + ),
75 + SizedBox(height: 16),
76 + NewListSections(sections: {
77 + "": viewModel.items
78 + .where((item) => item is TrackTradeListItem)
79 + .map((item) {
80 + return ListItemRegularRow(
81 + keyValue: "view tx on",
82 + label: item.title,
83 + onTap: () => (item as TrackTradeListItem).onTap(),
84 + foregroundColor: Theme.of(context).colorScheme.primary,
85 + trailingIconPath: "assets/new-ui/link_arrow.svg",
86 + trailingIconSize: 8,
87 + );
88 + }).toList()
89 + }),
90 + ],
91 + ),
92 + ),
93 + ),
94 + SizedBox(height: MediaQuery.of(context).viewPadding.bottom)
95 + ],
96 + ),
97 + ),
98 + ),
99 + ),
100 + );
101 + },
102 + );
103 + }
104 +
105 + Widget _buildBottomWidget(TransactionDetailsListItem item, BuildContext context) {
106 + if (item is AddressListItem) {
107 + return AddressFormatter.buildSegmentedAddress(
108 + address: item.value,
109 + evenTextStyle: TextStyle(
110 + fontSize: 12,
111 + fontFamily: "IBM Plex Mono",
112 + color: Theme.of(context).colorScheme.onSurface));
113 + }
114 +
115 + final isCompleted = item.value.contains("Completed");
116 + final isInitiated = item.value.contains("initiated");
117 + if (isCompleted || isInitiated) {
118 + return Row(
119 + children: [
120 + Container(
121 + height: 8,
122 + width: 8,
123 + decoration: BoxDecoration(
124 + color: isCompleted ? CustomThemeColors.syncGreen : CustomThemeColors.syncYellow,
125 + borderRadius: BorderRadius.circular(12),
126 + border: Border.all(
127 + color: Theme.of(context).colorScheme.surface,
128 + width: 1.5,
129 + ),
130 + ),
131 + ),
132 + SizedBox(width: 8),
133 + Text(
134 + item.value,
135 + style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurfaceVariant),
136 + ),
137 + ],
138 + );
139 + }
140 +
141 + return SizedBox.shrink();
142 + }
143 +
144 + Widget _buildTrailingWidget(TransactionDetailsListItem item, BuildContext context) {
145 + final isCompleted = item.value.contains("Completed");
146 + final isInitiated = item.value.contains("initiated");
147 + if (item is AddressListItem || isCompleted || isInitiated) {
148 + return SizedBox.shrink();
149 + }
150 +
151 + return Text(
152 + item.value.capitalized(),
153 + style: TextStyle(
154 + fontWeight: FontWeight.w400,
155 + fontFamily: 'Wix Madefor Text',
156 + color: Theme.of(context).colorScheme.onSurfaceVariant,
157 + ),
158 + );
159 + }
160 +}
lib/new-ui/pages/bridge/bridge_history_page.dart new
+117
@@ -0,0 +1,117 @@
1 +import 'package:cake_wallet/di.dart';
2 +import 'package:cake_wallet/new-ui/pages/bridge/bridge_detail_page.dart';
3 +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
4 +import 'package:cake_wallet/view_model/bridge_history_view_model.dart';
5 +import 'package:cake_wallet/new-ui/widgets/bridge/transfer_history_row.dart';
6 +import 'package:flutter/material.dart';
7 +import 'package:flutter_mobx/flutter_mobx.dart';
8 +
9 +class BridgeHistoryPage extends StatelessWidget {
10 + BridgeHistoryPage(this.viewModel);
11 +
12 + final BridgeHistoryViewModel viewModel;
13 +
14 + @override
15 + Widget build(BuildContext context) {
16 + final theme = Theme.of(context);
17 +
18 + return Container(
19 + decoration: BoxDecoration(
20 + color: theme.colorScheme.surface,
21 + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
22 + ),
23 + child: Column(
24 + children: [
25 + ModalTopBar(
26 + title: "Bridge history",
27 + leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
28 + onLeadingPressed: () => Navigator.of(context).pop(),
29 + ),
30 + Expanded(
31 + child: Observer(
32 + builder: (_) {
33 + if (viewModel.isEmpty) {
34 + return Center(
35 + child: Padding(
36 + padding: const EdgeInsets.all(24),
37 + child: Text(
38 + "No bridge transfers yet.",
39 + textAlign: TextAlign.center,
40 + style: Theme.of(context).textTheme.bodyLarge,
41 + ),
42 + ),
43 + );
44 + }
45 +
46 + final active = viewModel.activeTransfers;
47 + final past = viewModel.pastTransfers;
48 + final items = <Widget>[];
49 +
50 + if (active.isNotEmpty) {
51 + items.add(
52 + Padding(
53 + padding: const EdgeInsets.only(bottom: 8.0),
54 + child: Text(
55 + "In progress",
56 + style: Theme.of(context).textTheme.titleSmall,
57 + ),
58 + ),
59 + );
60 + for (final transfer in active) {
61 + items.add(
62 + TransferHistoryRow(
63 + transfer: transfer,
64 + onTap: () {
65 + final page = getIt.get<BridgeDetailPage>(param1: transfer);
66 + showModalBottomSheet(
67 + backgroundColor: Colors.transparent,
68 + isScrollControlled: true,
69 + context: context,
70 + builder: (context) => page,
71 + );
72 + },
73 + ),
74 + );
75 + }
76 + }
77 +
78 + if (past.isNotEmpty) {
79 + items.add(
80 + Padding(
81 + padding: const EdgeInsets.only(bottom: 8.0, top: 16),
82 + child: Text(
83 + "Past",
84 + style: Theme.of(context).textTheme.titleSmall,
85 + ),
86 + ),
87 + );
88 + for (final transfer in past) {
89 + items.add(
90 + TransferHistoryRow(
91 + transfer: transfer,
92 + onTap: () {
93 + final page = getIt.get<BridgeDetailPage>(param1: transfer);
94 + showModalBottomSheet(
95 + backgroundColor: Colors.transparent,
96 + isScrollControlled: true,
97 + context: context,
98 + builder: (context) => page,
99 + );
100 + },
101 + ),
102 + );
103 + }
104 + }
105 +
106 + return ListView(
107 + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
108 + children: items,
109 + );
110 + },
111 + ),
112 + ),
113 + ],
114 + ),
115 + );
116 + }
117 +}
lib/new-ui/pages/bridge/bridge_network_page.dart new
+98
@@ -0,0 +1,98 @@
1 +import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart';
2 +import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
3 +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
4 +import 'package:cake_wallet/routes.dart';
5 +import 'package:cake_wallet/src/widgets/new_list_row/list_item_regular_row_widget.dart';
6 +import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart';
7 +import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
8 +import 'package:cw_core/generate_name.dart';
9 +import 'package:flutter/material.dart';
10 +import 'package:flutter_mobx/flutter_mobx.dart';
11 +
12 +class BridgeNetworkPage extends StatelessWidget {
13 + const BridgeNetworkPage(this.bridgeViewModel);
14 +
15 + final BridgeViewModel bridgeViewModel;
16 +
17 + @override
18 + Widget build(BuildContext context) {
19 + final theme = Theme.of(context);
20 +
21 + return KeyboardHideOverlay(
22 + unfocusOnTap: true,
23 + child: Container(
24 + decoration: BoxDecoration(
25 + color: theme.colorScheme.surface,
26 + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
27 + ),
28 + child: Column(
29 + children: [
30 + ModalTopBar(
31 + title: 'Destination Network',
32 + leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
33 + onLeadingPressed: () => Navigator.of(context).pop(),
34 + ),
35 + SizedBox(height: 48),
36 + Padding(
37 + padding: const EdgeInsets.fromLTRB(18, 8, 18, 16),
38 + child: Text(
39 + 'Select what Network to transfer your assets to',
40 + textAlign: TextAlign.center,
41 + style: theme.textTheme.bodyMedium?.copyWith(
42 + color: theme.colorScheme.onSurfaceVariant,
43 + ),
44 + ),
45 + ),
46 + SizedBox(height: 24),
47 + Expanded(
48 + child: Padding(
49 + padding: const EdgeInsets.symmetric(horizontal: 18),
50 + child: Column(
51 + children: [
52 + Observer(
53 + builder: (_) {
54 + return NewListSections(
55 + sections: {
56 + '': bridgeViewModel.availableDestinationChains.map(
57 + (chain) {
58 + final chainName = chain.name;
59 + return ListItemRegularRow(
60 + iconPath: 'assets/images/crypto/${chainName.toLowerCase()}.webp',
61 + keyValue: chain.chainId.toString(),
62 + label: chainName,
63 + onTap: () {
64 + bridgeViewModel.setDestinationChain(chain.chainId);
65 + Navigator.pushNamed(context, Routes.bridgeReceivingWalletPage, arguments: bridgeViewModel);
66 + },
67 + );
68 + },
69 + ).toList(),
70 + },
71 + );
72 + },
73 + ),
74 + SizedBox(height: 24),
75 + Observer(
76 + builder: (_) {
77 + final src = bridgeViewModel.wallet.type.name;
78 +
79 + return ListItemRegularRowWidget(
80 + isFirstInSection: true,
81 + isLastInSection: true,
82 + keyValue: 'sending_from',
83 + label: 'Sending from',
84 + trailingIconPath: 'assets/new-ui/chain_badges/${src.toLowerCase()}.svg',
85 + trailingText: src.capitalized(),
86 + );
87 + },
88 + ),
89 + ],
90 + ),
91 + ),
92 + ),
93 + ],
94 + ),
95 + ),
96 + );
97 + }
98 +}
lib/new-ui/pages/bridge/bridge_receive_address_input_page.dart new
+160
@@ -0,0 +1,160 @@
1 +import 'package:cake_wallet/core/address_validator.dart';
2 +import 'package:cake_wallet/generated/i18n.dart';
3 +import 'package:cake_wallet/new-ui/pages/bridge/bridge_confirm_sheet.dart';
4 +import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
5 +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
6 +import 'package:cake_wallet/new-ui/widgets/send_page/send_address_input.dart';
7 +import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
8 +import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
9 +import 'package:flutter/material.dart';
10 +import 'package:flutter_mobx/flutter_mobx.dart';
11 +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
12 +
13 +class BridgeReceiveAddressInputPage extends StatefulWidget {
14 + const BridgeReceiveAddressInputPage({super.key, required this.bridgeViewModel});
15 +
16 + final BridgeViewModel bridgeViewModel;
17 +
18 + @override
19 + State<BridgeReceiveAddressInputPage> createState() => _BridgeReceiveAddressInputPageState();
20 +}
21 +
22 +class _BridgeReceiveAddressInputPageState extends State<BridgeReceiveAddressInputPage> {
23 + BridgeViewModel get bridgeViewModel => widget.bridgeViewModel;
24 +
25 + late final TextEditingController _controller;
26 + final FocusNode _focusNode = FocusNode();
27 +
28 + @override
29 + void initState() {
30 + super.initState();
31 + _controller = TextEditingController();
32 + _controller.addListener(() => setState(() {}));
33 + }
34 +
35 + @override
36 + void dispose() {
37 + _controller.dispose();
38 + _focusNode.dispose();
39 + super.dispose();
40 + }
41 +
42 + @override
43 + Widget build(BuildContext context) {
44 + final theme = Theme.of(context);
45 +
46 + return KeyboardHideOverlay(
47 + unfocusOnTap: true,
48 + child: Container(
49 + decoration: BoxDecoration(
50 + color: theme.colorScheme.surface,
51 + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
52 + ),
53 + child: Column(
54 + children: [
55 + ModalTopBar(
56 + title: 'Input Receive Address',
57 + leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
58 + onLeadingPressed: () => Navigator.of(context).pop(),
59 + ),
60 + SizedBox(height: 48),
61 + Expanded(
62 + child: SafeArea(
63 + child: Padding(
64 + padding: const EdgeInsets.symmetric(horizontal: 18),
65 + child: Column(
66 + crossAxisAlignment: CrossAxisAlignment.stretch,
67 + children: [
68 + Observer(
69 + builder: (_) {
70 + final chainName = bridgeViewModel.destinationChainInfo?.name;
71 +
72 + return Column(
73 + children: [
74 + Text(
75 + 'Make sure it is compatible with:',
76 + textAlign: TextAlign.center,
77 + style: theme.textTheme.bodyMedium?.copyWith(
78 + color: theme.colorScheme.onSurfaceVariant,
79 + fontSize: 14,
80 + fontWeight: FontWeight.w400,
81 + letterSpacing: -0.07,
82 + ),
83 + ),
84 + const SizedBox(height: 12),
85 + Row(
86 + mainAxisAlignment: MainAxisAlignment.center,
87 + children: [
88 + CakeImageWidget(
89 + imageUrl:
90 + 'assets/new-ui/chain_badges/${chainName?.toLowerCase()}.svg',
91 + width: 24,
92 + height: 24,
93 + color: theme.colorScheme.onSurfaceVariant,
94 + ),
95 + const SizedBox(width: 4),
96 + Text(
97 + chainName ?? '',
98 + style: theme.textTheme.bodyMedium?.copyWith(
99 + fontSize: 14,
100 + color: theme.colorScheme.onSurfaceVariant,
101 + fontWeight: FontWeight.w400,
102 + letterSpacing: -0.07,
103 + ),
104 + ),
105 + ],
106 + ),
107 + ],
108 + );
109 + },
110 + ),
111 + const SizedBox(height: 24),
112 + NewSendAddressInput(
113 + hintText: 'Enter Address',
114 + addressController: _controller,
115 + focusNode: _focusNode,
116 + selectedCurrency: bridgeViewModel.wallet.currency,
117 + onEditingComplete: () {},
118 + validator: AddressValidator(type: bridgeViewModel.destinationChainInfo!.currency),
119 + ),
120 + const Spacer(),
121 + Observer(
122 + builder: (_) {
123 + return FilledButton(
124 + onPressed: () {
125 + final overlayCtx = Navigator.of(context).overlay?.context;
126 + bridgeViewModel.setRecipientAddress(_controller.text.trim());
127 +
128 + WidgetsBinding.instance.addPostFrameCallback((_) {
129 + if (overlayCtx != null && overlayCtx.mounted) {
130 + showMaterialModalBottomSheet<void>(
131 + context: overlayCtx,
132 + backgroundColor: Colors.transparent,
133 + builder: (ctx) =>
134 + BridgeConfirmSheet(bridgeViewModel),
135 + );
136 + }
137 + });
138 + },
139 + style: FilledButton.styleFrom(
140 + padding: const EdgeInsets.all(16),
141 + shape: RoundedRectangleBorder(
142 + borderRadius: BorderRadius.circular(18),
143 + ),
144 + ),
145 + child: Text(S.of(context).continue_text),
146 + );
147 + },
148 + ),
149 + const SizedBox(height: 12),
150 + ],
151 + ),
152 + ),
153 + ),
154 + ),
155 + ],
156 + ),
157 + ),
158 + );
159 + }
160 +}
lib/new-ui/pages/bridge/bridge_receiving_wallet_page.dart new
+264
@@ -0,0 +1,264 @@
1 +import 'package:cake_wallet/view_model/bridge/bridge_receiving_wallet_option.dart';
2 +import 'package:cake_wallet/new-ui/pages/bridge/bridge_confirm_sheet.dart';
3 +import 'package:cake_wallet/new-ui/pages/bridge/bridge_receive_address_input_page.dart';
4 +import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
5 +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
6 +import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
7 +import 'package:cake_wallet/src/widgets/new_list_row/list_Item_style_wrapper.dart';
8 +import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
9 +import 'package:flutter/material.dart';
10 +import 'package:flutter_mobx/flutter_mobx.dart';
11 +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
12 +
13 +class BridgeReceivingWalletPage extends StatefulWidget {
14 + const BridgeReceivingWalletPage(this.bridgeViewModel);
15 +
16 + final BridgeViewModel bridgeViewModel;
17 +
18 + @override
19 + State<BridgeReceivingWalletPage> createState() => _BridgeReceivingWalletPageState();
20 +}
21 +
22 +class _BridgeReceivingWalletPageState extends State<BridgeReceivingWalletPage> {
23 + BridgeViewModel get bridgeViewModel => widget.bridgeViewModel;
24 +
25 + @override
26 + void initState() {
27 + super.initState();
28 + bridgeViewModel.loadReceivingWalletOptions();
29 + }
30 +
31 + @override
32 + Widget build(BuildContext context) {
33 + return KeyboardHideOverlay(
34 + unfocusOnTap: true,
35 + child: Container(
36 + decoration: BoxDecoration(
37 + color: Theme.of(context).colorScheme.surface,
38 + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
39 + ),
40 + child: Column(
41 + children: [
42 + ModalTopBar(
43 + title: 'Receiving Wallet',
44 + leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
45 + onLeadingPressed: () => Navigator.of(context).pop(),
46 + ),
47 + const SizedBox(height: 48),
48 + Expanded(
49 + child: SafeArea(
50 + child: Padding(
51 + padding: const EdgeInsets.symmetric(horizontal: 18),
52 + child: Observer(
53 + builder: (_) {
54 + final chain = bridgeViewModel.destinationChainInfo;
55 +
56 + final chainName = chain?.name ?? '';
57 +
58 + return Column(
59 + crossAxisAlignment: CrossAxisAlignment.stretch,
60 + children: [
61 + const SizedBox(height: 8),
62 + Center(
63 + child: CakeImageWidget(
64 + borderRadius: 8,
65 + imageUrl: chainName.isNotEmpty
66 + ? 'assets/images/crypto/${chainName.toLowerCase()}.webp'
67 + : null,
68 + width: 72,
69 + height: 72,
70 + ),
71 + ),
72 + const SizedBox(height: 20),
73 + Text(
74 + 'Select a $chainName wallet or address to send '
75 + 'the bridged USDT0 to.',
76 + textAlign: TextAlign.center,
77 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(
78 + fontWeight: FontWeight.w400,
79 + fontSize: 14,
80 + letterSpacing: -0.07,
81 + color: Theme.of(context).colorScheme.onSurfaceVariant,
82 + ),
83 + ),
84 + const SizedBox(height: 24),
85 + if (bridgeViewModel.isBridgeReceivingWalletListLoading)
86 + const Expanded(
87 + child: Center(
88 + child: CircularProgressIndicator(),
89 + ),
90 + )
91 + else
92 + Expanded(
93 + child: ListView(
94 + padding: const EdgeInsets.only(bottom: 16),
95 + children: _receivingListRows(context),
96 + ),
97 + ),
98 + ],
99 + );
100 + },
101 + ),
102 + ),
103 + ),
104 + ),
105 + ],
106 + ),
107 + ),
108 + );
109 + }
110 +
111 + List<Widget> _receivingListRows(BuildContext context) {
112 + final options = bridgeViewModel.bridgeReceivingWalletOptions;
113 + final rows = <Widget>[];
114 +
115 + for (int i = 0; i < options.length; i++) {
116 + final option = options[i];
117 +
118 + if (i > 0) rows.add(const SizedBox(height: 12));
119 +
120 + rows.add(
121 + _BridgeReceivingWalletRow(
122 + option: option,
123 + onTap: () {
124 + bridgeViewModel.setRecipientAddress(option.address, destWalletName: option.name);
125 +
126 + showMaterialModalBottomSheet<void>(
127 + context: context,
128 + backgroundColor: Colors.transparent,
129 + builder: (ctx) => BridgeConfirmSheet(bridgeViewModel),
130 + );
131 + },
132 + ),
133 + );
134 + }
135 +
136 + if (options.isNotEmpty) rows.add(const SizedBox(height: 12));
137 +
138 + rows.add(
139 + ListItemStyleWrapper(
140 + isFirstInSection: true,
141 + isLastInSection: true,
142 + height: 48,
143 + contentPadding: const EdgeInsets.all(12),
144 + onTap: () {
145 + showMaterialModalBottomSheet(
146 + context: context,
147 + backgroundColor: Colors.transparent,
148 + builder: (ctx) => BridgeReceiveAddressInputPage(bridgeViewModel: bridgeViewModel),
149 + );
150 + },
151 + builder: (ctx, textStyle, _) {
152 + final scheme = Theme.of(ctx).colorScheme;
153 + return Row(
154 + children: [
155 + Icon(
156 + Icons.edit_outlined,
157 + size: 24,
158 + color: scheme.primary,
159 + ),
160 + const SizedBox(width: 12),
161 + Expanded(
162 + child: Text(
163 + 'Input an Address',
164 + style: textStyle.copyWith(
165 + color: scheme.primary,
166 + fontSize: 15,
167 + fontWeight: FontWeight.w500,
168 + letterSpacing: -0.3,
169 + ),
170 + ),
171 + ),
172 + ],
173 + );
174 + },
175 + ),
176 + );
177 +
178 + return rows;
179 + }
180 +}
181 +
182 +class _BridgeReceivingWalletRow extends StatelessWidget {
183 + const _BridgeReceivingWalletRow({
184 + required this.option,
185 + required this.onTap,
186 + });
187 +
188 + final BridgeReceivingWalletOption option;
189 + final VoidCallback onTap;
190 +
191 + @override
192 + Widget build(BuildContext context) {
193 + final hasGroupRow = option.groupLabel != null;
194 +
195 + return ListItemStyleWrapper(
196 + onTap: onTap,
197 + isFirstInSection: true,
198 + isLastInSection: true,
199 + height: hasGroupRow ? 62.0 : 48.0,
200 + builder: (ctx, textStyle, labelStyle) {
201 + return Row(
202 + crossAxisAlignment: CrossAxisAlignment.center,
203 + children: [
204 + Expanded(
205 + child: Column(
206 + mainAxisAlignment: MainAxisAlignment.center,
207 + crossAxisAlignment: CrossAxisAlignment.start,
208 + children: [
209 + Text(
210 + option.name,
211 + style: textStyle.copyWith(letterSpacing: -0.07),
212 + maxLines: 1,
213 + overflow: TextOverflow.ellipsis,
214 + ),
215 + if (hasGroupRow) ...[
216 + const SizedBox(height: 4),
217 + Row(
218 + children: [
219 + Icon(
220 + Icons.account_balance_wallet_outlined,
221 + size: 16,
222 + color: Theme.of(ctx).colorScheme.onSurfaceVariant,
223 + ),
224 + const SizedBox(width: 6),
225 + Expanded(
226 + child: Text(
227 + option.groupLabel!,
228 + style: labelStyle.copyWith(
229 + fontSize: 12,
230 + letterSpacing: -0.06,
231 + color: Theme.of(ctx).colorScheme.onSurfaceVariant,
232 + ),
233 + maxLines: 1,
234 + overflow: TextOverflow.ellipsis,
235 + ),
236 + ),
237 + if (option.isCurrent) ...[
238 + const SizedBox(width: 8),
239 + Text(
240 + 'Current',
241 + style: textStyle.copyWith(
242 + fontSize: 12,
243 + color: Theme.of(ctx).colorScheme.primary,
244 + letterSpacing: -0.06,
245 + ),
246 + ),
247 + ],
248 + ],
249 + ),
250 + ],
251 + ],
252 + ),
253 + ),
254 + CakeImageWidget(
255 + imageUrl: 'assets/new-ui/arrow_forward.svg',
256 + height: 16,
257 + color: Theme.of(ctx).colorScheme.onSurfaceVariant,
258 + ),
259 + ],
260 + );
261 + },
262 + );
263 + }
264 +}
lib/new-ui/widgets/bridge/confirm_details_card.dart new
+62
@@ -0,0 +1,62 @@
1 +import 'package:cake_wallet/generated/i18n.dart';
2 +import 'package:cake_wallet/new-ui/widgets/bridge/expandable_details_card.dart';
3 +import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
4 +import 'package:flutter/material.dart';
5 +import 'package:flutter_mobx/flutter_mobx.dart';
6 +
7 +class ConfirmDetailsCard extends StatelessWidget {
8 + const ConfirmDetailsCard({required this.bridgeViewModel});
9 +
10 + final BridgeViewModel bridgeViewModel;
11 +
12 + @override
13 + Widget build(BuildContext context) {
14 + final scheme = Theme.of(context).colorScheme;
15 +
16 + return Observer(
17 + builder: (_) {
18 + final feeParts = <String>[
19 + bridgeViewModel.quoteNativeFeeFormattedForDisplay,
20 + bridgeViewModel.quoteNativeFiatFeeFormattedForDisplay,
21 + ];
22 + final feeLine = feeParts.join(' ');
23 +
24 + return Container(
25 + decoration: BoxDecoration(
26 + color: scheme.surfaceContainer,
27 + borderRadius: BorderRadius.circular(16),
28 + ),
29 + child: Column(
30 + children: [
31 + ExpandableBridgeDetailRow(
32 + label: S.of(context).from,
33 + title: bridgeViewModel.wallet.name,
34 + address: bridgeViewModel.sourceAddress,
35 + showChevron: true,
36 + ),
37 + Divider(
38 + height: 1,
39 + color: scheme.surfaceContainerHigh,
40 + ),
41 + ExpandableBridgeDetailRow(
42 + label: S.of(context).to,
43 + title: bridgeViewModel.destinationWalletName ?? bridgeViewModel.recipientAddress.trim(),
44 + address: bridgeViewModel.recipientAddress.trim(),
45 + showChevron: bridgeViewModel.destinationWalletName != null,
46 + ),
47 + Divider(
48 + height: 1,
49 + color: scheme.surfaceContainerHigh,
50 + ),
51 + ExpandableBridgeDetailRow(
52 + label: S.of(context).fee,
53 + title: feeLine,
54 + showChevron: false,
55 + ),
56 + ],
57 + ),
58 + );
59 + },
60 + );
61 + }
62 +}
lib/new-ui/widgets/bridge/expandable_details_card.dart new
+113
@@ -0,0 +1,113 @@
1 +import 'package:flutter/material.dart';
2 +
3 +class ExpandableBridgeDetailRow extends StatefulWidget {
4 + const ExpandableBridgeDetailRow({
5 + required this.label,
6 + required this.title,
7 + this.address,
8 + this.showChevron = false,
9 + });
10 +
11 + final String label;
12 + final String title;
13 + final String? address;
14 + final bool showChevron;
15 +
16 + @override
17 + State<ExpandableBridgeDetailRow> createState() => ExpandableBridgeDetailRowState();
18 +}
19 +
20 +class ExpandableBridgeDetailRowState extends State<ExpandableBridgeDetailRow> {
21 + bool _expanded = false;
22 +
23 + @override
24 + Widget build(BuildContext context) {
25 + final scheme = Theme.of(context).colorScheme;
26 + final showChevron = widget.showChevron;
27 +
28 + return Column(
29 + crossAxisAlignment: CrossAxisAlignment.stretch,
30 + children: [
31 + InkWell(
32 + onTap: showChevron ? () => setState(() => _expanded = !_expanded) : null,
33 + child: Padding(
34 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
35 + child: Row(
36 + crossAxisAlignment: CrossAxisAlignment.start,
37 + children: [
38 + Text(
39 + widget.label,
40 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(
41 + color: scheme.onSurface,
42 + fontWeight: FontWeight.w400,
43 + fontSize: 14,
44 + letterSpacing: -0.07,
45 + ),
46 + ),
47 + const SizedBox(width: 12),
48 + Expanded(
49 + child: Row(
50 + mainAxisAlignment: MainAxisAlignment.end,
51 + crossAxisAlignment: CrossAxisAlignment.start,
52 + children: [
53 + Flexible(
54 + child: Text(
55 + widget.title,
56 + textAlign: TextAlign.end,
57 + maxLines: 2,
58 + overflow: TextOverflow.ellipsis,
59 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(
60 + fontWeight: FontWeight.w400,
61 + fontSize: 14,
62 + letterSpacing: -0.07,
63 + color: scheme.onSurfaceVariant,
64 + ),
65 + ),
66 + ),
67 + if (showChevron) ...[
68 + const SizedBox(width: 2),
69 + AnimatedRotation(
70 + turns: _expanded ? 0.5 : 0,
71 + duration: const Duration(milliseconds: 200),
72 + curve: Curves.easeOutCubic,
73 + child: Icon(
74 + Icons.keyboard_arrow_down,
75 + size: 22,
76 + color: scheme.onSurfaceVariant,
77 + ),
78 + ),
79 + ],
80 + ],
81 + ),
82 + ),
83 + ],
84 + ),
85 + ),
86 + ),
87 + AnimatedSize(
88 + duration: const Duration(milliseconds: 200),
89 + curve: Curves.easeOutCubic,
90 + alignment: Alignment.topCenter,
91 + child: showChevron && _expanded
92 + ? Padding(
93 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
94 + child: Align(
95 + alignment: Alignment.centerRight,
96 + child: SelectableText(
97 + widget.address ?? '',
98 + textAlign: TextAlign.end,
99 + style: TextStyle(
100 + fontSize: 13,
101 + height: 1.35,
102 + color: scheme.onSurfaceVariant,
103 + fontWeight: FontWeight.w400,
104 + ),
105 + ),
106 + ),
107 + )
108 + : const SizedBox.shrink(),
109 + ),
110 + ],
111 + );
112 + }
113 +}
lib/new-ui/widgets/bridge/network_path_pill.dart new
+89
@@ -0,0 +1,89 @@
1 +import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
2 +import 'package:cw_core/generate_name.dart';
3 +import 'package:flutter/material.dart';
4 +import 'package:flutter_mobx/flutter_mobx.dart';
5 +
6 +class NetworkPathPill extends StatelessWidget {
7 + const NetworkPathPill({
8 + required this.sourceChainName,
9 + required this.destChainName,
10 + this.showBackground = true,
11 + });
12 +
13 + final String sourceChainName;
14 + final String destChainName;
15 + final bool showBackground;
16 +
17 + @override
18 + Widget build(BuildContext context) {
19 + final scheme = Theme.of(context).colorScheme;
20 + final body = Row(
21 + mainAxisAlignment: MainAxisAlignment.center,
22 + mainAxisSize: MainAxisSize.min,
23 + children: [
24 + CakeImageWidget(
25 + imageUrl: 'assets/new-ui/chain_badges/${sourceChainName.toLowerCase()}.svg',
26 + width: 24,
27 + height: 24,
28 + color: scheme.onSurfaceVariant,
29 + ),
30 + const SizedBox(width: 8),
31 + Flexible(
32 + child: Text(
33 + sourceChainName.capitalized(),
34 + maxLines: 1,
35 + overflow: TextOverflow.ellipsis,
36 + style: Theme.of(context).textTheme.bodyLarge?.copyWith(
37 + color: scheme.onSurface,
38 + fontWeight: FontWeight.w400,
39 + fontSize: 16,
40 + letterSpacing: -0.08,
41 + ),
42 + ),
43 + ),
44 + Padding(
45 + padding: const EdgeInsets.symmetric(horizontal: 8),
46 + child: Icon(
47 + Icons.arrow_forward,
48 + size: 16,
49 + color: scheme.primary,
50 + ),
51 + ),
52 + CakeImageWidget(
53 + imageUrl: 'assets/new-ui/chain_badges/${destChainName.toLowerCase()}.svg',
54 + width: 24,
55 + height: 24,
56 + color: scheme.onSurfaceVariant,
57 + ),
58 + const SizedBox(width: 8),
59 + Flexible(
60 + child: Text(
61 + destChainName,
62 + maxLines: 1,
63 + overflow: TextOverflow.ellipsis,
64 + style: Theme.of(context).textTheme.bodyLarge?.copyWith(
65 + color: scheme.onSurface,
66 + fontWeight: FontWeight.w400,
67 + fontSize: 16,
68 + letterSpacing: -0.08,
69 + ),
70 + ),
71 + ),
72 + ],
73 + );
74 + return Observer(
75 + builder: (_) {
76 + return showBackground
77 + ? Container(
78 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
79 + decoration: BoxDecoration(
80 + color: scheme.surfaceContainer,
81 + borderRadius: BorderRadius.circular(80),
82 + ),
83 + child: body,
84 + )
85 + : body;
86 + },
87 + );
88 + }
89 +}
lib/new-ui/widgets/bridge/transfer_history_row.dart new
+180
@@ -0,0 +1,180 @@
1 +import 'package:cake_wallet/entities/bridge_transfer.dart';
2 +import 'package:cake_wallet/evm/evm.dart';
3 +import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
4 +import 'package:cake_wallet/themes/core/custom_theme_colors.dart';
5 +import 'package:flutter/material.dart';
6 +import 'package:intl/intl.dart';
7 +
8 +class TransferHistoryRow extends StatelessWidget {
9 + const TransferHistoryRow({
10 + required this.transfer,
11 + required this.onTap,
12 + super.key,
13 + });
14 +
15 + final BridgeTransfer transfer;
16 + final VoidCallback onTap;
17 +
18 + String _statusLabel(String status) {
19 + switch (status) {
20 + case 'submitted':
21 + return "Submitted";
22 + case 'confirming':
23 + return "Confirming on source";
24 + case 'initiated':
25 + return "Bridge initiated";
26 + case 'completed':
27 + return "Completed";
28 + case 'failed':
29 + return "Failed";
30 + default:
31 + return status;
32 + }
33 + }
34 +
35 + Color _statusColor(BuildContext context, String status) {
36 + switch (status) {
37 + case 'completed':
38 + return CustomThemeColors.syncGreen;
39 + case 'failed':
40 + return Theme.of(context).colorScheme.error;
41 + case 'submitted':
42 + case 'confirming':
43 + case 'initiated':
44 + default:
45 + return CustomThemeColors.syncYellow;
46 + }
47 + }
48 +
49 + @override
50 + Widget build(BuildContext context) {
51 + final sourceChainName = evm?.getChainInfoByChainId(transfer.sourceChainId)?.name;
52 + final destChainName = evm?.getChainInfoByChainId(transfer.destinationChainId)?.name;
53 + final formattedDate = DateFormat('HH:mm').format(transfer.createdAt);
54 + final statusText = _statusLabel(transfer.status);
55 +
56 + return InkWell(
57 + onTap: onTap,
58 + child: Container(
59 + padding: const EdgeInsets.symmetric(horizontal: 16.0),
60 + margin: const EdgeInsets.only(bottom: 16),
61 + width: double.infinity,
62 + height: 72,
63 + decoration: BoxDecoration(
64 + gradient: LinearGradient(
65 + colors: [
66 + Theme.of(context).colorScheme.surfaceContainerHigh,
67 + Theme.of(context).colorScheme.surfaceContainer,
68 + ],
69 + begin: Alignment.topCenter,
70 + end: Alignment.bottomCenter,
71 + ),
72 + borderRadius: BorderRadius.all(Radius.circular(20)),
73 + border: Border.all(
74 + color: Theme.of(context).colorScheme.surfaceContainerHighest,
75 + width: 1,
76 + ),
77 + ),
78 + child: Row(
79 + mainAxisSize: MainAxisSize.max,
80 + crossAxisAlignment: CrossAxisAlignment.center,
81 + children: [
82 + Container(
83 + height: 42,
84 + width: 42,
85 + decoration: BoxDecoration(
86 + borderRadius: BorderRadius.circular(16),
87 + ),
88 + child: Stack(
89 + children: [
90 + CakeImageWidget(
91 + imageUrl: 'assets/images/crypto/${sourceChainName?.toLowerCase()}.webp',
92 + width: 28,
93 + height: 28,
94 + ),
95 + Positioned(
96 + top: 14,
97 + left: 12,
98 + child: CakeImageWidget(
99 + imageUrl: 'assets/images/crypto/${destChainName?.toLowerCase()}.webp',
100 + width: 28,
101 + height: 28,
102 + ),
103 + ),
104 + ],
105 + ),
106 + ),
107 + const SizedBox(width: 8),
108 + Expanded(
109 + child: Column(
110 + mainAxisSize: MainAxisSize.min,
111 + crossAxisAlignment: CrossAxisAlignment.start,
112 + children: [
113 + Row(
114 + mainAxisAlignment: MainAxisAlignment.spaceBetween,
115 + children: <Widget>[
116 + Text(
117 + '$sourceChainName → $destChainName',
118 + style: Theme.of(context).textTheme.bodyLarge?.copyWith(
119 + fontSize: 16,
120 + fontWeight: FontWeight.w500,
121 + color: Theme.of(context).colorScheme.onSurface,
122 + ),
123 + ),
124 + Text(
125 + '${transfer.amount} ${transfer.tokenSymbol}',
126 + style: Theme.of(context).textTheme.bodyLarge?.copyWith(
127 + fontSize: 16,
128 + fontWeight: FontWeight.w500,
129 + color: Theme.of(context).colorScheme.onSurface,
130 + ),
131 + ),
132 + ],
133 + ),
134 + const SizedBox(height: 8),
135 + Row(
136 + mainAxisAlignment: MainAxisAlignment.spaceBetween,
137 + children: <Widget>[
138 + Row(
139 + children: [
140 + Container(
141 + height: 8,
142 + width: 8,
143 + decoration: BoxDecoration(
144 + color: _statusColor(context, transfer.status),
145 + borderRadius: BorderRadius.circular(12),
146 + border: Border.all(
147 + color: Theme.of(context).colorScheme.surface,
148 + width: 1.5,
149 + ),
150 + ),
151 + ),
152 + SizedBox(width: 8),
153 + Text(
154 + statusText,
155 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(
156 + fontWeight: FontWeight.w500,
157 + color: Theme.of(context).colorScheme.onSurfaceVariant,
158 + ),
159 + overflow: TextOverflow.ellipsis,
160 + ),
161 + ],
162 + ),
163 + Text(
164 + formattedDate,
165 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(
166 + fontWeight: FontWeight.w500,
167 + color: Theme.of(context).colorScheme.onSurfaceVariant,
168 + ),
169 + ),
170 + ],
171 + ),
172 + ],
173 + ),
174 + ),
175 + ],
176 + ),
177 + ),
178 + );
179 + }
180 +}
lib/new-ui/widgets/coins_page/assets_history/asset_details_modal.dart
+38 -26
@@ -2,6 +2,7 @@ import 'package:cake_wallet/bitcoin/bitcoin.dart';
2 import 'package:cake_wallet/di.dart';
3 import 'package:cake_wallet/generated/i18n.dart';
4 import 'package:cake_wallet/new-ui/modal_navigator.dart';
5 +import 'package:cake_wallet/new-ui/pages/bridge/bridge_amount_page.dart';
6 import 'package:cake_wallet/new-ui/pages/receive_page.dart';
7 import 'package:cake_wallet/new-ui/pages/send_page.dart';
8 import 'package:cake_wallet/new-ui/pages/swap_page.dart';
@@ -31,6 +32,7 @@ class AssetDetailsModal extends StatelessWidget {
32 required this.mode,
33 required this.wallet,
34 required this.showSwap,
35 + required this.showBridgeButton,
36 this.asset});
37
38 final String title;
@@ -44,6 +46,7 @@ class AssetDetailsModal extends StatelessWidget {
46 final String chainIconPath;
47 final WalletBase wallet;
48 final bool showSwap;
49 + final bool showBridgeButton;
50 final AssetDetailsModalModes mode;
51
52 @override
@@ -59,7 +62,7 @@ class AssetDetailsModal extends StatelessWidget {
62 title: "",
63 trailingIcon: Icon(Icons.close),
64 onTrailingPressed: Navigator.of(context).pop,
62 - padding: EdgeInsets.only(top:12,right:18),
65 + padding: EdgeInsets.only(top: 12, right: 18),
66 ),
67 SafeArea(
68 child: Column(
@@ -125,21 +128,20 @@ class AssetDetailsModal extends StatelessWidget {
128 fontWeight: FontWeight.w500,
129 color: Theme.of(context).colorScheme.onSurface),
130 ),
128 - if(asset != null)
129 - Container(
130 - decoration: BoxDecoration(
131 - color: Theme.of(context).colorScheme.surfaceContainer,
132 - borderRadius: BorderRadius.circular(999999999)),
133 - child: Padding(
134 - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4),
135 -
136 - child: Text(
137 - asset?.title??"",
138 - style: TextStyle(
139 - color: Theme.of(context).colorScheme.onSurfaceVariant),
131 + if (asset != null)
132 + Container(
133 + decoration: BoxDecoration(
134 + color: Theme.of(context).colorScheme.surfaceContainer,
135 + borderRadius: BorderRadius.circular(999999999)),
136 + child: Padding(
137 + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4),
138 + child: Text(
139 + asset?.title ?? "",
140 + style: TextStyle(
141 + color: Theme.of(context).colorScheme.onSurfaceVariant),
142 + ),
143 ),
141 - ),
142 - )
144 + )
145 ],
146 ),
147 if (subtitle.isNotEmpty)
@@ -147,11 +149,15 @@ class AssetDetailsModal extends StatelessWidget {
149 mainAxisAlignment: MainAxisAlignment.center,
150 spacing: 4,
151 children: [
150 - if(chainIconPath.isNotEmpty)
151 - CakeImageWidget(
152 - imageUrl: chainIconPath,
153 - width:16,height:16,colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.onSurfaceVariant,BlendMode.srcIn),
154 - ),
152 + if (chainIconPath.isNotEmpty)
153 + CakeImageWidget(
154 + imageUrl: chainIconPath,
155 + width: 16,
156 + height: 16,
157 + colorFilter: ColorFilter.mode(
158 + Theme.of(context).colorScheme.onSurfaceVariant,
159 + BlendMode.srcIn),
160 + ),
161 Text(
162 subtitle,
163 style: TextStyle(
@@ -227,6 +233,18 @@ class AssetDetailsModal extends StatelessWidget {
233 ),
234 ),
235 ),
236 + if (showSwap && mode != AssetDetailsModalModes.ltcPrivate)
237 + AssetDetailsModalBottomButton(
238 + iconPath: "assets/new-ui/exchange.svg",
239 + title: S.of(context).swap,
240 + onPressed: () => openPage<NewSwapPage>(context, param2: asset),
241 + ),
242 + if (showBridgeButton)
243 + AssetDetailsModalBottomButton(
244 + iconPath: "assets/new-ui/bridge.svg",
245 + title: "Bridge",
246 + onPressed: () => openPage<BridgeAmountPage>(context, param1: asset),
247 + ),
248 AssetDetailsModalBottomButton(
249 iconPath: "assets/new-ui/receive.svg",
250 title: S.of(context).receive,
@@ -240,12 +258,6 @@ class AssetDetailsModal extends StatelessWidget {
258 openPage<NewReceivePage>(context, param2: asset);
259 },
260 ),
243 - if (showSwap && mode != AssetDetailsModalModes.ltcPrivate)
244 - AssetDetailsModalBottomButton(
245 - iconPath: "assets/new-ui/exchange.svg",
246 - title: S.of(context).swap,
247 - onPressed: () => openPage<NewSwapPage>(context, param2: asset),
248 - ),
261 ],
262 ),
263 SizedBox()
lib/new-ui/widgets/coins_page/assets_history/asset_tile.dart
+3
@@ -14,6 +14,7 @@ class AssetTile extends StatelessWidget {
14 required this.balance,
15 required this.chainIconPath,
16 this.showSecondary = false,
17 + this.showBridgeButton = false,
18 this.title,
19 this.trailingText,
20 this.modalMode = AssetDetailsModalModes.normal,
@@ -22,6 +23,7 @@ class AssetTile extends StatelessWidget {
23 final BalanceRecord balance;
24 final bool showSecondary;
25 final bool showSwap;
26 + final bool showBridgeButton;
27 final String chainIconPath;
28 final String? title;
29 final String? trailingText;
@@ -42,6 +44,7 @@ class AssetTile extends StatelessWidget {
44 builder: (context) {
45 return AssetDetailsModal(
46 showSwap: showSwap,
47 + showBridgeButton: showBridgeButton,
48 asset: balance.asset,
49 title: title ?? balance.asset.fullName ?? balance.asset.name,
50 chainTitle: "",
lib/new-ui/widgets/coins_page/assets_history/assets_section.dart
+1
@@ -59,6 +59,7 @@ class AssetsSection extends StatelessWidget {
59 final balance = dashboardViewModel.balanceViewModel.formattedBalances.elementAt(index);
60 return AssetTile(
61 showSwap: dashboardViewModel.isEnabledSwapAction,
62 + showBridgeButton: dashboardViewModel.showBridge(balance.asset),
63 balance: balance,
64 wallet: dashboardViewModel.wallet,
65 isFirst: index == 0,
lib/new-ui/widgets/send_page/send_address_input.dart
+4 -2
@@ -23,6 +23,7 @@ class NewSendAddressInput extends StatefulWidget {
23 this.validator,
24 this.focusNode,
25 this.displayName,
26 + this.hintText,
27 });
28
29 final TextEditingController addressController;
@@ -36,7 +37,8 @@ class NewSendAddressInput extends StatefulWidget {
37 final bool bottomPadding;
38 final FormFieldValidator<String>? validator;
39 final FocusNode? focusNode;
39 -
40 + final String? hintText;
41 +
42 @override
43 State<NewSendAddressInput> createState() => _NewSendAddressInputState();
44 }
@@ -96,7 +98,7 @@ class _NewSendAddressInputState extends State<NewSendAddressInput> {
98 },
99 controller: widget.addressController,
100 decoration: InputDecoration(
99 - hintText: S.of(context).search_or_enter,
101 + hintText: widget.hintText ?? S.of(context).search_or_enter,
102 errorMaxLines: 3,
103 ),
104 ),
lib/router.dart
+21 -1
@@ -4,6 +4,10 @@ import 'package:cake_wallet/anonpay/anonpay_invoice_info.dart';
4 import 'package:cake_wallet/core/new_wallet_arguments.dart';
5 import 'package:cake_wallet/new-ui/new_dashboard.dart';
6 import 'package:cake_wallet/new-ui/pages/about_page.dart';
7 +import 'package:cake_wallet/new-ui/pages/bridge/bridge_confirm_sheet.dart';
8 +import 'package:cake_wallet/new-ui/pages/bridge/bridge_history_page.dart';
9 +import 'package:cake_wallet/new-ui/pages/bridge/bridge_network_page.dart';
10 +import 'package:cake_wallet/new-ui/pages/bridge/bridge_receiving_wallet_page.dart';
11 import 'package:cake_wallet/new-ui/pages/coin_control_page.dart';
12 import 'package:cake_wallet/new-ui/pages/addresses_page.dart';
13 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';
93 import 'package:cake_wallet/src/screens/seed/pre_seed_page.dart';
94 import 'package:cake_wallet/src/screens/seed/seed_verification/seed_verification_page.dart';
95 import 'package:cake_wallet/src/screens/seed/wallet_seed_page.dart';
92 -import 'package:cake_wallet/src/screens/send/send_page.dart';
96 import 'package:cake_wallet/src/screens/send/send_template_page.dart';
97 import 'package:cake_wallet/src/screens/send/transaction_success_info_page.dart';
98 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';
139 import 'package:cake_wallet/store/settings_store.dart';
140 import 'package:cake_wallet/utils/payment_request.dart';
141 import 'package:cake_wallet/view_model/advanced_privacy_settings_view_model.dart';
142 +import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
143 +import 'package:cake_wallet/view_model/bridge_history_view_model.dart';
144 import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
145 import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart';
146 import 'package:cake_wallet/view_model/dashboard/sign_view_model.dart';
@@ -1035,6 +1040,21 @@ Route<dynamic> createRoute(RouteSettings settings) {
1040 builder: (_) => getIt.get<DEuroSavingsPage>(),
1041 );
1042
1043 + case Routes.bridgeHistoryPage:
1044 + return handleRouteWithPlatformAwareness(
1045 + (context) => BridgeHistoryPage(settings.arguments as BridgeHistoryViewModel),
1046 + );
1047 +
1048 + case Routes.bridgeDestinationNetworkPage:
1049 + return handleRouteWithPlatformAwareness(
1050 + (context) => BridgeNetworkPage(settings.arguments as BridgeViewModel),
1051 + );
1052 +
1053 + case Routes.bridgeReceivingWalletPage:
1054 + return handleRouteWithPlatformAwareness(
1055 + (context) => BridgeReceivingWalletPage(settings.arguments as BridgeViewModel),
1056 + );
1057 +
1058 default:
1059 return MaterialPageRoute<void>(
1060 builder: (_) => Scaffold(
lib/routes.dart
+4
@@ -144,4 +144,8 @@ class Routes {
144
145 static const lightningUsernamePage = "/lightning_username_page";
146 static const aboutPage = "/about_page";
147 +
148 + static const bridgeHistoryPage = '/bridge_history_page';
149 + static const bridgeDestinationNetworkPage = '/bridge_destination_network_page';
150 + static const bridgeReceivingWalletPage = '/bridge_receiving_wallet_page';
151 }
lib/src/widgets/new_list_row/list_Item_style_wrapper.dart
+7 -5
@@ -9,6 +9,7 @@ class ListItemStyleWrapper extends StatelessWidget {
9 this.backgroundColor,
10 this.onTap,
11 this.iconPath,
12 + this.contentPadding,
13 this.height,
14 });
15
@@ -19,10 +20,10 @@ class ListItemStyleWrapper extends StatelessWidget {
20 final VoidCallback? onTap;
21 final Color? backgroundColor;
22 final Widget Function(BuildContext context, TextStyle textStyle, TextStyle labelStyle) builder;
23 + final EdgeInsets? contentPadding;
24
25 @override
26 Widget build(BuildContext context) {
25 -
27 final theme = Theme.of(context);
28
29 final textStyle = TextStyle(
@@ -70,15 +71,16 @@ class ListItemStyleWrapper extends StatelessWidget {
71 child: Container(height: 1, color: theme.colorScheme.outlineVariant),
72 ),
73 )
73 - else if(!isLastInSection) Container(
74 + else if (!isLastInSection)
75 + Container(
76 color: theme.colorScheme.surfaceContainer,
77 child: Padding(
78 padding: const EdgeInsets.symmetric(horizontal: 12),
79 child: Container(height: 1, color: theme.colorScheme.outlineVariant),
80 ),
81 )
80 - ],
81 - ),
82 - );
82 + ],
83 + ),
84 + );
85 }
86 }
lib/store/bridge_transfers_store.dart new
+46
@@ -0,0 +1,46 @@
1 +import 'package:cake_wallet/entities/bridge_transfer.dart';
2 +import 'package:mobx/mobx.dart';
3 +
4 +part 'bridge_transfers_store.g.dart';
5 +
6 +class BridgeTransfersStore = BridgeTransfersStoreBase with _$BridgeTransfersStore;
7 +
8 +abstract class BridgeTransfersStoreBase with Store {
9 + BridgeTransfersStoreBase() : bridgeTransfers = [] {
10 + updateList();
11 + }
12 +
13 + @observable
14 + List<BridgeTransfer> bridgeTransfers;
15 +
16 + @action
17 + Future<void> updateList() async {
18 + bridgeTransfers = await BridgeTransfer.selectAll();
19 + }
20 +
21 + @action
22 + Future<void> addTransfer(BridgeTransfer transfer) async {
23 + try {
24 + await BridgeTransfer.insert(transfer);
25 + await updateList();
26 + } catch (_) {}
27 + }
28 +
29 + @action
30 + Future<void> updateTransfer(BridgeTransfer transfer) async {
31 + try {
32 + await BridgeTransfer.update(transfer);
33 + await updateList();
34 + } catch (_) {}
35 + }
36 +
37 + @computed
38 + List<BridgeTransfer> get activeTransfers =>
39 + bridgeTransfers.where((b) => b.isActive).toList(growable: false);
40 +
41 + @computed
42 + List<BridgeTransfer> get pastTransfers =>
43 + bridgeTransfers.where((b) => !b.isActive).toList(growable: false);
44 +
45 + void dispose() {}
46 +}
lib/view_model/bridge/bridge_receiving_wallet_option.dart new
+17
@@ -0,0 +1,17 @@
1 +import 'package:cw_core/wallet_info.dart';
2 +
3 +class BridgeReceivingWalletOption {
4 + const BridgeReceivingWalletOption({
5 + required this.walletInfo,
6 + required this.isCurrent,
7 + this.groupLabel,
8 + });
9 +
10 + final WalletInfo walletInfo;
11 + final bool isCurrent;
12 +
13 + final String? groupLabel;
14 +
15 + String get name => walletInfo.name;
16 + String get address => walletInfo.address;
17 +}
lib/view_model/bridge/bridge_view_model.dart new
+719
@@ -0,0 +1,719 @@
1 +import 'dart:async';
2 +
3 +import 'package:cake_wallet/core/amount_parsing_proxy.dart';
4 +import 'package:cake_wallet/core/amount_validator.dart';
5 +import 'package:cake_wallet/core/fiat_conversion_service.dart';
6 +import 'package:cake_wallet/core/utilities.dart';
7 +import 'package:cake_wallet/core/wallet_change_listener_view_model.dart';
8 +import 'package:cake_wallet/entities/calculate_fiat_amount.dart';
9 +import 'package:cake_wallet/entities/fiat_api_mode.dart';
10 +import 'package:cake_wallet/view_model/bridge/bridge_receiving_wallet_option.dart';
11 +import 'package:cake_wallet/entities/bridge_transfer.dart';
12 +import 'package:cake_wallet/entities/wallet_manager.dart';
13 +import 'package:cake_wallet/evm/evm.dart';
14 +import 'package:cake_wallet/reactions/wallet_connect.dart';
15 +import 'package:cake_wallet/core/layerzero_scan_service.dart';
16 +import 'package:cake_wallet/store/app_store.dart';
17 +import 'package:cake_wallet/store/bridge_transfers_store.dart';
18 +import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart';
19 +import 'package:cake_wallet/store/settings_store.dart';
20 +import 'package:cw_core/crypto_currency.dart';
21 +import 'package:cw_core/erc20_token.dart';
22 +import 'package:cw_core/transaction_priority.dart';
23 +import 'package:cw_core/wallet_base.dart';
24 +import 'package:cw_core/wallet_info.dart';
25 +import 'package:cw_core/utils/print_verbose.dart';
26 +import 'package:mobx/mobx.dart';
27 +
28 +part 'bridge_view_model.g.dart';
29 +
30 +class BridgeViewModel = BridgeViewModelBase with _$BridgeViewModel;
31 +
32 +abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with Store {
33 + BridgeViewModelBase({
34 + required AppStore appStore,
35 + required this.bridgeTransfersStore,
36 + required this.walletManager,
37 + required this.fiatConversionStore,
38 + required this.settingsStore,
39 + }) : _appStore = appStore,
40 + super(appStore: appStore);
41 +
42 + final AppStore _appStore;
43 +
44 + AmountParsingProxy get amountParsingProxy => _appStore.amountParsingProxy;
45 +
46 + void Function()? onBridgeSuccess;
47 + final Map<String, Completer<void>> _pollingCancellers = {};
48 + final BridgeTransfersStore bridgeTransfersStore;
49 + final WalletManager walletManager;
50 + final FiatConversionStore fiatConversionStore;
51 + final SettingsStore settingsStore;
52 +
53 + @observable
54 + ObservableList<BridgeReceivingWalletOption> bridgeReceivingWalletOptions =
55 + ObservableList<BridgeReceivingWalletOption>();
56 +
57 + @observable
58 + bool isBridgeReceivingWalletListLoading = false;
59 +
60 + @observable
61 + CryptoCurrency? selectedToken;
62 +
63 + @observable
64 + int? destinationChainId;
65 +
66 + @observable
67 + String amount = '';
68 +
69 + @observable
70 + String recipientAddress = '';
71 +
72 + @observable
73 + String? destinationWalletName;
74 +
75 + @observable
76 + USDT0Quote? quote;
77 +
78 + @observable
79 + bool isQuoteLoading = false;
80 +
81 + @observable
82 + String? quoteError;
83 +
84 + @observable
85 + bool isExecuting = false;
86 +
87 + @observable
88 + String? executeError;
89 +
90 + @observable
91 + bool bridgeSuccess = false;
92 +
93 + @observable
94 + BridgeTransfer? lastCreatedBridgeTransfer;
95 +
96 + @computed
97 + int? get sourceChainId => evm!.getSelectedChainId(wallet);
98 +
99 + @computed
100 + String get sourceAddress => wallet.walletAddresses.address;
101 +
102 + @computed
103 + List<ChainInfo> get availableDestinationChains {
104 + if (!isEVMCompatibleChain(wallet.type)) return [];
105 +
106 + return evm!.getUSDT0DestinationChains(wallet);
107 + }
108 +
109 + @computed
110 + List<Erc20Token> get availableUSDT0Tokens {
111 + if (!isEVMCompatibleChain(wallet.type)) return [];
112 +
113 + final tokens = wallet.balance.keys.whereType<Erc20Token>();
114 + return tokens.where((token) => evm!.isUSDT0Token(wallet, token)).toList(growable: false);
115 + }
116 +
117 + @computed
118 + ChainInfo? get destinationChainInfo {
119 + if (destinationChainId == null) return null;
120 +
121 + return availableDestinationChains.firstWhereOrNull((c) => c.chainId == destinationChainId);
122 + }
123 +
124 + @computed
125 + String get tokenBalanceFormatted {
126 + final token = selectedToken;
127 + if (token is! Erc20Token) return '0.00';
128 +
129 + return amountParsingProxy.getDisplayCryptoStringFromBigInt(
130 + selectedTokenBalance,
131 + token,
132 + );
133 + }
134 +
135 + @computed
136 + String get amountDisplayFormatted {
137 + if (amount.isEmpty) return '';
138 + final token = selectedToken;
139 + if (token is! Erc20Token) return amount.replaceAll(',', '.');
140 +
141 + return amountParsingProxy.getDisplayCryptoAmount(
142 + amount.replaceAll(',', '.'),
143 + token,
144 + );
145 + }
146 +
147 +
148 + DecimalAmountValidator get decimalAmountValidator => DecimalAmountValidator(
149 + currency: selectedToken!,
150 + isAutovalidate: true,
151 + );
152 +
153 + @computed
154 + String get fiatAmountFormatted {
155 + if (amount.isEmpty) return '';
156 + final token = selectedToken;
157 + if (token is! Erc20Token) return '';
158 +
159 + final price = fiatConversionStore.prices[token];
160 + if (price == null) return '';
161 +
162 + final forFiat = amountParsingProxy.getDisplayCryptoAmount(
163 + amount.replaceAll(',', '.'),
164 + token,
165 + );
166 +
167 + return calculateFiatAmount(
168 + price: price,
169 + cryptoAmount: forFiat,
170 + );
171 + }
172 +
173 + @computed
174 + String get fiatCurrencyTitle => settingsStore.fiatCurrency.title;
175 +
176 + @computed
177 + String get quoteNativeFee {
178 + if (quote == null) return '—';
179 +
180 + final cur = wallet.currency;
181 + return amountParsingProxy.getDisplayCryptoStringFromBigInt(
182 + quote!.nativeFee,
183 + cur,
184 + );
185 + }
186 +
187 + @computed
188 + String get quoteNativeFeeFormattedForDisplay {
189 + if (quoteNativeFee.isEmpty) return '';
190 +
191 + return '${quoteNativeFee} ${wallet.currency.title}';
192 + }
193 +
194 + @computed
195 + String get quoteNativeFiatFeeFormattedForDisplay {
196 + if (quote == null || quoteNativeFee.isEmpty) return '';
197 +
198 + final price = fiatConversionStore.prices[wallet.currency];
199 + if (price == null) return '';
200 +
201 + final fiatFeeFormatted = calculateFiatAmount(
202 + price: price,
203 + cryptoAmount: amountParsingProxy.getDisplayCryptoAmount(
204 + quoteNativeFee.replaceAll(',', '.'),
205 + wallet.currency,
206 + ),
207 + );
208 +
209 + return '(${settingsStore.fiatCurrency.title} $fiatFeeFormatted)';
210 + }
211 +
212 + @computed
213 + bool get canProceedToDestinationNetwork {
214 + if (amount.isEmpty) return false;
215 +
216 + if (selectedToken == null || selectedToken is! Erc20Token) return false;
217 +
218 + if (amountError != null) return false;
219 +
220 + final token = selectedToken as Erc20Token;
221 + final validAmount = amountParsingProxy.tryParseCryptoString(
222 + amount.replaceAll(',', '.'),
223 + token,
224 + );
225 + return validAmount != null && validAmount > BigInt.zero;
226 + }
227 +
228 + @action
229 + void applyInitialBridgeToken(CryptoCurrency asset) {
230 + for (final t in availableUSDT0Tokens) {
231 + if (t == asset) {
232 + setSelectedToken(t);
233 + break;
234 + }
235 + }
236 + }
237 +
238 + @action
239 + void setDestinationChain(int chainId) {
240 + destinationChainId = chainId;
241 + _clearQuoteState();
242 + }
243 +
244 + @action
245 + void setSelectedToken(CryptoCurrency token) {
246 + selectedToken = token;
247 + _clearQuoteState();
248 + }
249 +
250 + @action
251 + void setAmount(String value) {
252 + amount = value;
253 + _clearQuoteState();
254 + }
255 +
256 + @action
257 + void setMaxAmount() {
258 + final token = selectedToken;
259 + if (token is! Erc20Token) return;
260 + if (selectedTokenBalance == BigInt.zero) {
261 + setAmount('');
262 + return;
263 + }
264 + setAmount(
265 + amountParsingProxy.getDisplayCryptoStringFromBigInt(
266 + selectedTokenBalance,
267 + token,
268 + ),
269 + );
270 + }
271 +
272 + @action
273 + void setRecipientAddress(String value, {String? destWalletName}) {
274 + recipientAddress = value;
275 + destinationWalletName = destWalletName;
276 + _clearQuoteState();
277 + }
278 +
279 + void _clearQuoteState() {
280 + quote = null;
281 + quoteError = null;
282 + executeError = null;
283 + }
284 +
285 + Future<void> _ensureFiatPriceFor(CryptoCurrency crypto) async {
286 + if (fiatConversionStore.prices[crypto] != null) return;
287 +
288 + final p = await FiatConversionService.fetchPrice(
289 + crypto: crypto,
290 + fiat: settingsStore.fiatCurrency,
291 + torOnly: settingsStore.fiatApiMode == FiatApiMode.torOnly,
292 + );
293 +
294 + runInAction(() {
295 + fiatConversionStore.prices[crypto] = p;
296 + });
297 + }
298 +
299 + @action
300 + Future<void> ensureFiatPriceForSelectedToken() async {
301 + await _ensureFiatPriceFor(selectedToken!);
302 + }
303 +
304 + @action
305 + Future<void> ensureFiatPriceForNativeCurrency() async {
306 + await _ensureFiatPriceFor(wallet.currency);
307 + }
308 +
309 + @computed
310 + BigInt get selectedTokenBalance {
311 + final bal = wallet.balance[selectedToken];
312 +
313 + if (bal is EVMChainERC20Balance) return bal.balance;
314 + return BigInt.zero;
315 + }
316 +
317 + @computed
318 + String? get amountError {
319 + if (selectedToken == null || amount.isEmpty) return null;
320 + if (selectedToken is! Erc20Token) return null;
321 +
322 + final token = selectedToken as Erc20Token;
323 + final amountBigInt = amountParsingProxy.tryParseCryptoString(
324 + amount.replaceAll(',', '.'),
325 + token,
326 + );
327 + if (amountBigInt == null || amountBigInt == BigInt.zero) return null;
328 + if (amountBigInt > selectedTokenBalance) {
329 + return 'Insufficient balance for ${token.title} token.';
330 + }
331 +
332 + return null;
333 + }
334 +
335 + @action
336 + Future<void> loadReceivingWalletOptions() async {
337 + if (!isEVMCompatibleChain(wallet.type)) return;
338 +
339 + final destWalletType = evm!.getWalletTypeByChainId(destinationChainId!);
340 +
341 + isBridgeReceivingWalletListLoading = true;
342 + try {
343 + await walletManager.updateWalletGroups();
344 + final all = await WalletInfo.getAll();
345 +
346 + if (destWalletType == null) {
347 + bridgeReceivingWalletOptions.clear();
348 + return;
349 + }
350 +
351 + final filtered =
352 + all.where((w) => w.type == destWalletType && w.hardwareWalletType == null).toList();
353 +
354 + final options = <BridgeReceivingWalletOption>[];
355 +
356 + for (final wi in filtered) {
357 + final isCurrent = wi.name == wallet.name;
358 + options.add(
359 + BridgeReceivingWalletOption(
360 + walletInfo: wi,
361 + isCurrent: isCurrent,
362 + groupLabel: walletManager.getGroupName(wi),
363 + ),
364 + );
365 + }
366 +
367 + bridgeReceivingWalletOptions
368 + ..clear()
369 + ..addAll(options);
370 + } finally {
371 + isBridgeReceivingWalletListLoading = false;
372 + }
373 + }
374 +
375 + String? missingBridgeFieldsMessage() {
376 + final src = sourceChainId;
377 + final dst = destinationChainId;
378 + final token = selectedToken;
379 +
380 + if (src == null ||
381 + dst == null ||
382 + token == null ||
383 + amount.isEmpty ||
384 + recipientAddress.trim().isEmpty) {
385 + return 'Fill all fields';
386 + }
387 + return null;
388 + }
389 +
390 + ({String? error, BigInt? parsedAmount}) _parseAndValidateAmount(Erc20Token token) {
391 + final amountBigInt = amountParsingProxy.tryParseCryptoString(
392 + amount.replaceAll(',', '.'),
393 + token,
394 + );
395 +
396 + if (amountBigInt == null || amountBigInt == BigInt.zero) {
397 + return (error: 'Invalid amount', parsedAmount: null);
398 + }
399 +
400 + if (amountBigInt > selectedTokenBalance) {
401 + return (
402 + error: 'Insufficient balance for ${token.title} token.',
403 + parsedAmount: null,
404 + );
405 + }
406 +
407 + return (error: null, parsedAmount: amountBigInt);
408 + }
409 +
410 + @action
411 + Future<void> loadQuote() async {
412 + final missing = missingBridgeFieldsMessage();
413 + if (missing != null) {
414 + quoteError = missing;
415 + return;
416 + }
417 +
418 + final token = selectedToken!;
419 + if (token is! Erc20Token) return;
420 +
421 + final check = _parseAndValidateAmount(token);
422 + if (check.error != null) {
423 + quoteError = check.error;
424 + return;
425 + }
426 +
427 + final src = sourceChainId!;
428 + final dst = destinationChainId!;
429 + final amountBigInt = check.parsedAmount!;
430 +
431 + isQuoteLoading = true;
432 + quoteError = null;
433 + quote = null;
434 + executeError = null;
435 +
436 + try {
437 + quote = await evm!.quoteUSDT0Transfer(
438 + wallet: wallet,
439 + sourceChainId: src,
440 + destinationChainId: dst,
441 + amount: amountBigInt,
442 + recipientAddress: recipientAddress.trim(),
443 + );
444 + } catch (e) {
445 + quoteError = e.toString();
446 + } finally {
447 + isQuoteLoading = false;
448 + }
449 + }
450 +
451 + @action
452 + Future<void> executeBridge() async {
453 + if (quote == null) {
454 + executeError = 'Get a quote first';
455 + return;
456 + }
457 +
458 + final missing = missingBridgeFieldsMessage();
459 + if (missing != null) {
460 + executeError = missing;
461 + return;
462 + }
463 +
464 + final token = selectedToken!;
465 + if (token is! Erc20Token) return;
466 +
467 + final check = _parseAndValidateAmount(token);
468 + if (check.error != null) {
469 + executeError = check.error;
470 + return;
471 + }
472 +
473 + final src = sourceChainId!;
474 + final dst = destinationChainId!;
475 + final amountBigInt = check.parsedAmount!;
476 +
477 + isExecuting = true;
478 + executeError = null;
479 + try {
480 + final priority = EVMChainTransactionPriority.medium;
481 + final pending = await evm!.executeUSDT0Transfer(
482 + wallet: wallet,
483 + token: token,
484 + sourceChainId: src,
485 + destinationChainId: dst,
486 + amount: amountBigInt,
487 + recipientAddress: recipientAddress.trim(),
488 + quote: quote!,
489 + priority: priority as TransactionPriority,
490 + useBlinkProtection: canSupportBlinkProtection(src),
491 + );
492 +
493 + final sourceTxHash = pending.evmTxHashFromRawHex ?? pending.id;
494 + await pending.commit();
495 +
496 + final record = BridgeTransfer(
497 + id: '${sourceTxHash}_${DateTime.now().millisecondsSinceEpoch}',
498 + walletId: wallet.name,
499 + sourceChainId: src,
500 + destinationChainId: dst,
501 + tokenSymbol: token.title,
502 + tokenContract: token.contractAddress,
503 + amount: amount,
504 + recipientAddress: recipientAddress.trim(),
505 + sourceTxHash: sourceTxHash,
506 + status: 'submitted',
507 + createdAt: DateTime.now(),
508 + );
509 +
510 + await bridgeTransfersStore.addTransfer(record);
511 + runInAction(() {
512 + quote = null;
513 + bridgeSuccess = true;
514 + lastCreatedBridgeTransfer = record;
515 + });
516 + onBridgeSuccess?.call();
517 + _pollForSourceConfirmation(record, wallet);
518 + } catch (e) {
519 + executeError = e.toString();
520 + } finally {
521 + isExecuting = false;
522 + }
523 + }
524 +
525 + static const _pollInterval = Duration(seconds: 2);
526 + static const _pollTimeout = Duration(minutes: 3);
527 + static const _destinationPollInterval = Duration(seconds: 5);
528 + static const _destinationPollTimeout = Duration(minutes: 10);
529 +
530 + @override
531 + void onWalletChange(WalletBase wallet) {
532 + _cancelAllPolling();
533 + _resumePollingForActiveTransfers(wallet);
534 + }
535 +
536 + void _cancelAllPolling() {
537 + for (final canceller in _pollingCancellers.values) {
538 + if (!canceller.isCompleted) {
539 + canceller.complete();
540 + }
541 + }
542 + _pollingCancellers.clear();
543 + }
544 +
545 + void _resumePollingForActiveTransfers(WalletBase wallet) {
546 + if (!isEVMCompatibleChain(wallet.type)) return;
547 +
548 + final activeTransfers = bridgeTransfersStore.bridgeTransfers
549 + .where((t) => t.walletId == wallet.name && t.isActive)
550 + .toList();
551 +
552 + for (final transfer in activeTransfers) {
553 + if (transfer.status == 'submitted' || transfer.status == 'confirming') {
554 + _pollForSourceConfirmation(transfer, wallet);
555 + } else if (transfer.status == 'initiated') {
556 + _pollForDestinationCompletion(transfer, wallet);
557 + }
558 + }
559 + }
560 +
561 + bool _isValidWalletContext(String expectedWalletId) {
562 + return wallet.name == expectedWalletId &&
563 + isEVMCompatibleChain(wallet.type) &&
564 + !_pollingCancellers.values.any((c) => c.isCompleted);
565 + }
566 +
567 + Future<void> _updateTransferStatus(
568 + BridgeTransfer record,
569 + String status, {
570 + String? errorMessage,
571 + String? statusMessage,
572 + DateTime? confirmedAt,
573 + }) async {
574 + if (!_isValidWalletContext(record.walletId)) return;
575 +
576 + runInAction(() {
577 + record.updatedAt = DateTime.now();
578 + record.status = status;
579 + if (errorMessage != null) record.errorMessage = errorMessage;
580 + if (statusMessage != null) record.statusMessage = statusMessage;
581 + if (confirmedAt != null) record.confirmedAt = confirmedAt;
582 + });
583 + await bridgeTransfersStore.updateTransfer(record);
584 + }
585 +
586 + Future<void> _pollForSourceConfirmation(
587 + BridgeTransfer record,
588 + WalletBase wallet,
589 + ) async {
590 + final canceller = Completer<void>();
591 + _pollingCancellers[record.id] = canceller;
592 + final walletId = wallet.name;
593 + final deadline = DateTime.now().add(_pollTimeout);
594 +
595 + try {
596 + while (DateTime.now().isBefore(deadline)) {
597 + await Future.any([
598 + Future.delayed(_pollInterval),
599 + canceller.future,
600 + ]);
601 +
602 + if (canceller.isCompleted || !_isValidWalletContext(walletId)) return;
603 +
604 + bool? receipt;
605 + try {
606 + receipt = await evm!.getTransactionReceipt(wallet, record.sourceTxHash);
607 + } catch (e) {
608 + printV('USDT0 bridge: Error fetching receipt: $e');
609 + continue;
610 + }
611 +
612 + if (receipt == null) continue;
613 +
614 + if (receipt == true) {
615 + await _updateTransferStatus(
616 + record,
617 + 'confirming',
618 + confirmedAt: DateTime.now(),
619 + );
620 +
621 + await Future.delayed(const Duration(seconds: 1));
622 + if (canceller.isCompleted || !_isValidWalletContext(walletId)) return;
623 +
624 + await _updateTransferStatus(record, 'initiated');
625 + _pollForDestinationCompletion(record, wallet);
626 +
627 + return;
628 + } else if (receipt == false) {
629 + await _updateTransferStatus(
630 + record,
631 + 'failed',
632 + errorMessage: 'Transaction reverted',
633 + );
634 + return;
635 + }
636 + }
637 +
638 + if (!_isValidWalletContext(walletId)) return;
639 +
640 + await _updateTransferStatus(record, 'initiated');
641 + _pollForDestinationCompletion(record, wallet);
642 + } finally {
643 + _pollingCancellers.remove(record.id);
644 + }
645 + }
646 +
647 + Future<void> _pollForDestinationCompletion(
648 + BridgeTransfer record,
649 + WalletBase wallet,
650 + ) async {
651 + final canceller = Completer<void>();
652 + _pollingCancellers['${record.id}_dest'] = canceller;
653 + final walletId = wallet.name;
654 + final deadline = DateTime.now().add(_destinationPollTimeout);
655 +
656 + try {
657 + while (DateTime.now().isBefore(deadline)) {
658 + await Future.any([
659 + Future.delayed(_destinationPollInterval),
660 + canceller.future,
661 + ]);
662 +
663 + if (canceller.isCompleted || !_isValidWalletContext(walletId)) return;
664 +
665 + LayerZeroMessageStatus? status;
666 + try {
667 + status = await LayerZeroScanService.getMessageStatus(record.sourceTxHash);
668 + } catch (e) {
669 + printV('USDT0 bridge: Error fetching LayerZero status: $e');
670 + continue;
671 + }
672 +
673 + if (status == null) continue;
674 +
675 + if (status.isDelivered) {
676 + await _updateTransferStatus(
677 + record,
678 + 'completed',
679 + statusMessage: status.status?.message,
680 + );
681 + return;
682 + }
683 +
684 + if (status.isFailed) {
685 + await _updateTransferStatus(
686 + record,
687 + 'failed',
688 + errorMessage: status.status?.message ?? 'Bridge message failed',
689 + statusMessage: status.status?.message,
690 + );
691 + return;
692 + }
693 +
694 + await _updateTransferStatus(
695 + record,
696 + record.status,
697 + statusMessage: status.status?.message,
698 + );
699 + }
700 + } finally {
701 + _pollingCancellers.remove('${record.id}_dest');
702 + }
703 + }
704 +
705 + @action
706 + void clearBridgeSuccess() {
707 + amount = '';
708 + recipientAddress = '';
709 + destinationWalletName = null;
710 + destinationChainId = null;
711 + bridgeSuccess = false;
712 + lastCreatedBridgeTransfer = null;
713 + _clearQuoteState();
714 + }
715 +
716 + void dispose() {
717 + _cancelAllPolling();
718 + }
719 +}
lib/view_model/bridge_details_view_model.dart new
+181
@@ -0,0 +1,181 @@
1 +import 'dart:async';
2 +
3 +import 'package:cake_wallet/entities/bridge_transfer.dart';
4 +import 'package:cake_wallet/evm/evm.dart';
5 +import 'package:cake_wallet/generated/i18n.dart';
6 +import 'package:cake_wallet/src/screens/transaction_details/address_list_item.dart';
7 +import 'package:cake_wallet/src/screens/transaction_details/standart_list_item.dart';
8 +import 'package:cake_wallet/src/screens/trade_details/trade_details_status_item.dart';
9 +import 'package:cake_wallet/src/screens/trade_details/track_trade_list_item.dart';
10 +import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart';
11 +import 'package:cake_wallet/store/bridge_transfers_store.dart';
12 +import 'package:mobx/mobx.dart';
13 +import 'package:url_launcher/url_launcher.dart';
14 +
15 +part 'bridge_details_view_model.g.dart';
16 +
17 +class BridgeDetailsViewModel = BridgeDetailsViewModelBase with _$BridgeDetailsViewModel;
18 +
19 +abstract class BridgeDetailsViewModelBase with Store {
20 + BridgeDetailsViewModelBase({
21 + required BridgeTransfer transferForDetails,
22 + required this.bridgeTransfersStore,
23 + required this.walletId,
24 + }) : items = ObservableList<TransactionDetailsListItem>(),
25 + transfer = _findTransferInStore(
26 + bridgeTransfersStore.bridgeTransfers, transferForDetails.id, walletId) ??
27 + transferForDetails {
28 + _updateItems();
29 + _setupReaction();
30 + }
31 +
32 + static BridgeTransfer? _findTransferInStore(
33 + List<BridgeTransfer> transfers,
34 + String transferId,
35 + String walletId,
36 + ) {
37 + try {
38 + return transfers.firstWhere(
39 + (t) => t.id == transferId && t.walletId == walletId,
40 + );
41 + } catch (_) {
42 + return null;
43 + }
44 + }
45 +
46 + final BridgeTransfersStore bridgeTransfersStore;
47 + final String walletId;
48 + ReactionDisposer? _reactionDisposer;
49 +
50 + @observable
51 + BridgeTransfer transfer;
52 +
53 + @observable
54 + ObservableList<TransactionDetailsListItem> items;
55 +
56 + Timer? timer;
57 +
58 + void _setupReaction() {
59 + _reactionDisposer = reaction(
60 + (_) => bridgeTransfersStore.bridgeTransfers,
61 + (_) => updateTransfer(),
62 + );
63 + updateTransfer();
64 + }
65 +
66 + @action
67 + void updateTransfer() {
68 + final updatedTransfer = _findTransferInStore(
69 + bridgeTransfersStore.bridgeTransfers,
70 + transfer.id,
71 + walletId,
72 + );
73 + if (updatedTransfer != null) {
74 + transfer = updatedTransfer;
75 + _updateItems();
76 + }
77 + }
78 +
79 + void dispose() {
80 + _reactionDisposer?.call();
81 + timer?.cancel();
82 + }
83 +
84 + void _updateItems() {
85 + items.clear();
86 +
87 + final statusText = transfer.statusMessage?.isNotEmpty == true
88 + ? '${_statusLabel(transfer.status)} · ${transfer.statusMessage}'
89 + : _statusLabel(transfer.status);
90 +
91 + items.add(
92 + DetailsListStatusItem(
93 + title: "Status",
94 + value: statusText,
95 + status: transfer.status,
96 + ),
97 + );
98 +
99 + final sourceName =
100 + evm?.getChainNameByChainId(transfer.sourceChainId) ?? '${transfer.sourceChainId}';
101 + final destName =
102 + evm?.getChainNameByChainId(transfer.destinationChainId) ?? '${transfer.destinationChainId}';
103 +
104 + items.add(
105 + StandartListItem(
106 + title: "Source chain",
107 + value: sourceName,
108 + ),
109 + );
110 +
111 + items.add(
112 + StandartListItem(
113 + title: "Destination chain",
114 + value: destName,
115 + ),
116 + );
117 +
118 + items.add(
119 + StandartListItem(
120 + title: "Amount",
121 + value: '${transfer.amount} ${transfer.tokenSymbol}',
122 + ),
123 + );
124 +
125 + items.add(
126 + AddressListItem(
127 + title: "Recipient",
128 + value: transfer.recipientAddress,
129 + ),
130 + );
131 +
132 + final sourceExplorerUrl = evm?.getExplorerUrlForChainId(transfer.sourceChainId);
133 + final sourceTxUrl = sourceExplorerUrl != null && sourceExplorerUrl.isNotEmpty
134 + ? '$sourceExplorerUrl/tx/${transfer.sourceTxHash}'
135 + : null;
136 +
137 + if (sourceTxUrl != null) {
138 + final explorerDescription = S.current.view_transaction_on + Uri.parse(sourceTxUrl).host;
139 + items.add(
140 + TrackTradeListItem(
141 + title: explorerDescription,
142 + value: sourceTxUrl,
143 + onTap: () => _launchUrl(sourceTxUrl),
144 + ),
145 + );
146 + }
147 +
148 + if (transfer.errorMessage != null && transfer.errorMessage!.isNotEmpty) {
149 + items.add(
150 + StandartListItem(
151 + title: "Error",
152 + value: transfer.errorMessage!,
153 + ),
154 + );
155 + }
156 + }
157 +
158 + String _statusLabel(String status) {
159 + switch (status) {
160 + case 'submitted':
161 + return "Submitted";
162 + case 'confirming':
163 + return "Confirming on source";
164 + case 'initiated':
165 + return "Bridge initiated";
166 + case 'completed':
167 + return "Completed";
168 + case 'failed':
169 + return "Failed";
170 + default:
171 + return status;
172 + }
173 + }
174 +
175 + void _launchUrl(String url) {
176 + final uri = Uri.parse(url);
177 + try {
178 + launchUrl(uri, mode: LaunchMode.externalApplication);
179 + } catch (_) {}
180 + }
181 +}
lib/view_model/bridge_history_view_model.dart new
+37
@@ -0,0 +1,37 @@
1 +import 'package:cake_wallet/entities/bridge_transfer.dart';
2 +import 'package:cake_wallet/store/app_store.dart';
3 +import 'package:cake_wallet/store/bridge_transfers_store.dart';
4 +import 'package:mobx/mobx.dart';
5 +
6 +part 'bridge_history_view_model.g.dart';
7 +
8 +class BridgeHistoryViewModel = BridgeHistoryViewModelBase with _$BridgeHistoryViewModel;
9 +
10 +abstract class BridgeHistoryViewModelBase with Store {
11 + BridgeHistoryViewModelBase({
12 + required this.bridgeTransfersStore,
13 + required this.appStore,
14 + });
15 +
16 + final BridgeTransfersStore bridgeTransfersStore;
17 + final AppStore appStore;
18 +
19 + @computed
20 + List<BridgeTransfer> get walletTransfers {
21 + final wallet = appStore.wallet;
22 + if (wallet == null) return [];
23 +
24 + return bridgeTransfersStore.bridgeTransfers.where((t) => t.walletId == wallet.name).toList();
25 + }
26 +
27 + @computed
28 + List<BridgeTransfer> get activeTransfers =>
29 + walletTransfers.where((b) => b.isActive).toList(growable: false);
30 +
31 + @computed
32 + List<BridgeTransfer> get pastTransfers =>
33 + walletTransfers.where((b) => !b.isActive).toList(growable: false);
34 +
35 + @computed
36 + bool get isEmpty => walletTransfers.isEmpty;
37 +}
lib/view_model/dashboard/dashboard_view_model.dart
+8
@@ -393,6 +393,14 @@ abstract class DashboardViewModelBase with Store {
393 return false;
394 }
395
396 + bool showBridge(CryptoCurrency currency) {
397 + if(!isEVMCompatibleChain(wallet.type)) return false;
398 +
399 + if(evm!.isUSDT0Token(wallet, currency)) return true;
400 +
401 + return false;
402 + }
403 +
404 @action
405 Future<void> loadCardDesigns() async {
406 final accountStyleSettings =
res/pictures/bridge.svg new
+3
@@ -0,0 +1,3 @@
1 +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
2 +<path d="M30.8571 20.5713H26.2857V12.1913C27.3844 13.5466 28.8102 14.5997 30.4286 15.2513C30.7083 15.3591 31.0192 15.3526 31.2943 15.2333C31.5693 15.114 31.7865 14.8914 31.8989 14.6135C32.0114 14.3356 32.0101 14.0246 31.8954 13.7476C31.7807 13.4706 31.5617 13.2498 31.2857 13.1327C29.8056 12.5386 28.5378 11.5139 27.6464 10.1913C26.755 8.86873 26.281 7.30908 26.2857 5.71415C26.2857 5.41104 26.1653 5.12035 25.951 4.90602C25.7367 4.6917 25.446 4.57129 25.1429 4.57129C24.8398 4.57129 24.5491 4.6917 24.3347 4.90602C24.1204 5.12035 24 5.41104 24 5.71415C24 7.83588 23.1571 9.87071 21.6569 11.371C20.1566 12.8713 18.1217 13.7141 16 13.7141C13.8783 13.7141 11.8434 12.8713 10.3431 11.371C8.84286 9.87071 8 7.83588 8 5.71415C8 5.41104 7.87959 5.12035 7.66527 4.90602C7.45094 4.6917 7.16025 4.57129 6.85714 4.57129C6.55404 4.57129 6.26335 4.6917 6.04902 4.90602C5.83469 5.12035 5.71429 5.41104 5.71429 5.71415C5.71901 7.30908 5.245 8.86873 4.3536 10.1913C3.4622 11.5139 2.19442 12.5386 0.714286 13.1327C0.438277 13.2498 0.219319 13.4706 0.104613 13.7476C-0.010094 14.0246 -0.0113626 14.3356 0.10108 14.6135C0.213523 14.8914 0.430672 15.114 0.705716 15.2333C0.98076 15.3526 1.29167 15.3591 1.57143 15.2513C3.18985 14.5997 4.61563 13.5466 5.71429 12.1913V20.5713H1.14286C0.839753 20.5713 0.549062 20.6917 0.334735 20.906C0.120408 21.1204 0 21.411 0 21.7141C0 22.0173 0.120408 22.3079 0.334735 22.5223C0.549062 22.7366 0.839753 22.857 1.14286 22.857H5.71429V26.2856C5.71429 26.5887 5.83469 26.8794 6.04902 27.0937C6.26335 27.308 6.55404 27.4284 6.85714 27.4284C7.16025 27.4284 7.45094 27.308 7.66527 27.0937C7.87959 26.8794 8 26.5887 8 26.2856V22.857H24V26.2856C24 26.5887 24.1204 26.8794 24.3347 27.0937C24.5491 27.308 24.8398 27.4284 25.1429 27.4284C25.446 27.4284 25.7367 27.308 25.951 27.0937C26.1653 26.8794 26.2857 26.5887 26.2857 26.2856V22.857H30.8571C31.1602 22.857 31.4509 22.7366 31.6653 22.5223C31.8796 22.3079 32 22.0173 32 21.7141C32 21.411 31.8796 21.1204 31.6653 20.906C31.4509 20.6917 31.1602 20.5713 30.8571 20.5713ZM18.2857 15.7427V20.5713H13.7143V15.7427C15.2188 16.0856 16.7812 16.0856 18.2857 15.7427ZM8 12.1713C8.93266 13.3237 10.1019 14.2625 11.4286 14.9241V20.5713H8V12.1713ZM20.5714 20.5713V14.9256C21.8981 14.2639 23.0673 13.3251 24 12.1727V20.5713H20.5714Z" fill="#91B0FF"/>
3 +</svg>
tool/configure.dart
+34 -1
@@ -1392,7 +1392,13 @@ import 'package:cw_evm/evm_chain_wallet_creation_credentials.dart';
1392 import 'package:cw_evm/utils/evm_chain_utils.dart';
1393 import 'package:cw_evm/evm_chain_default_tokens.dart';
1394 import 'package:cw_evm/deuro/deuro_savings.dart';
1395 +import 'package:cw_evm/usdt0/usdt0_config.dart';
1396 +import 'package:cw_evm/usdt0/usdt0_quote.dart';
1397 +import 'package:cw_evm/usdt0/usdt0_service.dart';
1398 import 'package:eth_sig_util/util/utils.dart';
1399 +export 'package:cw_evm/evm_chain_transaction_priority.dart';
1400 +export 'package:cw_evm/evm_erc20_balance.dart';
1401 +export 'package:cw_evm/usdt0/usdt0_quote.dart';
1402
1403 """;
1404 const evmCwPart = "part 'cw_evm.dart';";
@@ -1546,19 +1552,44 @@ abstract class EVM {
1552 WalletType? getWalletTypeByChainId(int chainId);
1553 String getChainNameByChainId(int chainId);
1554 String getTokenNameByChainId(int chainId);
1549 -
1555 // Chain selection methods
1556 List<ChainInfo> getAllChains();
1557 ChainInfo? getCurrentChain(WalletBase wallet);
1558 + ChainInfo? getChainInfoByChainId(int chainId);
1559 +
1560
1561 int? getSelectedChainId(WalletBase wallet);
1562 Future<void> selectChain(WalletBase wallet, int chainId, {required Node node});
1563
1564 String? getExplorerUrlForChainId(int chainId, {bool showProtocol = true});
1565
1566 + Future<bool?> getTransactionReceipt(WalletBase wallet, String txHash);
1567 +
1568 bool hasPriorityFee(int chainId);
1569
1570 + bool isUSDT0Token(WalletBase wallet, CryptoCurrency token);
1571 + List<ChainInfo> getUSDT0DestinationChains(WalletBase wallet);
1572 +
1573 + Future<USDT0Quote> quoteUSDT0Transfer({
1574 + required WalletBase wallet,
1575 + required int sourceChainId,
1576 + required int destinationChainId,
1577 + required BigInt amount,
1578 + required String recipientAddress,
1579 + });
1580
1581 + Future<PendingTransaction> executeUSDT0Transfer({
1582 + required WalletBase wallet,
1583 + required CryptoCurrency token,
1584 + required int sourceChainId,
1585 + required int destinationChainId,
1586 + required BigInt amount,
1587 + required String recipientAddress,
1588 + required USDT0Quote quote,
1589 + required TransactionPriority priority,
1590 + bool useBlinkProtection = true,
1591 + });
1592 +
1593 Future<EvmWalletConnectFeeQuote?> getWCBufferedFeeQuote(
1594 WalletBase wallet,
1595 TransactionPriority priority,
@@ -1572,11 +1603,13 @@ class ChainInfo {
1603 required this.chainId,
1604 required this.name,
1605 required this.shortCode,
1606 + required this.currency,
1607 });
1608
1609 final int chainId;
1610 final String name;
1611 final String shortCode;
1612 + final CryptoCurrency currency;
1613
1614 @override
1615 bool operator ==(Object other) =>