CW-1273: Implement USDT0 Bridge (#2855)
What changed, and why it matters
This commit adds a new feature to Cake Wallet that lets users move USDT (a popular stablecoin) between Ethereum, Polygon, and Arbitrum using a cross-chain bridge powered by LayerZero. It introduces new screens, a service that talks to the bridge contracts, a database table to store bridge history, and status polling so users can see whether their transfer is in progress or completed. The change is a feature implementation, not a documented security fix, but it touches sensitive areas such as transaction construction, token approvals, and external API calls.
Treat this as a high-touch feature addition requiring security review. Verify the hardcoded USDT0/LayerZero contract addresses and endpoint IDs against official documentation. Review the unlimited ERC-20 approval path on Ethereum for necessity and consider whether a user-controlled or per-transfer allowance is safer. Audit how recipient addresses and amounts are validated before being encoded into OFT send() calldata, ensure the LayerZero scan service response parsing is robust against malicious API responses, and confirm the new BridgeTransfer SQLite table does not leak sensitive data in backups or logs. Test the changed transaction-deduplication logic for regressions.
Security signals we found
New feature adds cross-chain token bridge with hardcoded contract addresses and endpoint IDs
Ethereum path requests unlimited ERC-20 approval (maxUint) to a hardcoded OFT adapter address
New external network dependency: LayerZero scan API (https://scan.layerzero-api.com/v1)
New SQLite table BridgeTransfer stores wallet_id, chain IDs, token contract, amount, recipient address, source tx hash, status, and error/status messages
OFT contract wrapper encodes payable send() calls and passes user-supplied recipient address as bytes32
Transaction deduplication logic changed to prefer outgoing transactions that include an EVM token contract address
No explicit security claims, CVE references, or researcher attribution in commit message or diff
Evidence from the diff
The commit implements USDT0 (Omnichain USDT) bridging via LayerZero’s OFT (Omnichain Fungible Token) standard. Key additions: cw_evm/lib/usdt0/usdt0_config.dart (hardcoded contract addresses and endpoint IDs for Ethereum, Polygon, Arbitrum), cw_evm/lib/usdt0/usdt0_service.dart (quote and execute cross-chain transfers, including an unlimited ERC-20 approval on Ethereum to the OFT adapter), cw_evm/lib/contract/oft.dart (OFT ABI wrapper for quoteSend/encodeSend), lib/core/layerzero_scan_service.dart (polls LayerZero scan API for transfer status), cw_core/lib/db/sqlite.dart (new BridgeTransfer SQLite table and migration to DB version 5), and a full set of UI/view-model files for the bridge flow. The EVM wallet transaction-deduplication logic is also adjusted to prefer token-bearing outgoing records over plain outgoing ones when both exist for the same hash.
Changed components
cw_evm/lib/usdt0/usdt0_config.dartcw_evm/lib/usdt0/usdt0_service.dartcw_evm/lib/usdt0/usdt0_quote.dartcw_evm/lib/contract/oft.dartcw_evm/lib/evm_chain_wallet.dartcw_evm/lib/tokens/arbitrum_tokens.dartlib/core/layerzero_scan_service.dartcw_core/lib/db/sqlite.dartlib/entities/bridge_transfer.dartlib/entities/wallet_manager.dartlib/evm/cw_evm.dartlib/store/bridge_transfers_store.dartlib/view_model/bridge/bridge_view_model.dartlib/view_model/bridge_details_view_model.dartlib/view_model/bridge_history_view_model.dartlib/new-ui/pages/bridge/*lib/new-ui/widgets/bridge/*lib/di.dartlib/router.dartlib/routes.dartInspect captured patch +3958 / −43
diff --git a/assets/images/crypto/paxg.webp b/assets/images/crypto/paxg.webp
new file mode 100644
index 0000000..9b89a14
Binary files /dev/null and b/assets/images/crypto/paxg.webp differ
diff --git a/assets/images/crypto/usdt0.webp b/assets/images/crypto/usdt0.webp
new file mode 100644
index 0000000..d9afd7e
Binary files /dev/null and b/assets/images/crypto/usdt0.webp differ
diff --git a/assets/images/crypto/xaut.webp b/assets/images/crypto/xaut.webp
new file mode 100644
index 0000000..74f514d
Binary files /dev/null and b/assets/images/crypto/xaut.webp differ
diff --git a/assets/new-ui/bridge.svg.vec b/assets/new-ui/bridge.svg.vec
new file mode 100644
index 0000000..a0435fe
Binary files /dev/null and b/assets/new-ui/bridge.svg.vec differ
diff --git a/assets/new-ui/tor.svg.vec b/assets/new-ui/tor.svg.vec
new file mode 100644
index 0000000..3acf075
Binary files /dev/null and b/assets/new-ui/tor.svg.vec differ
diff --git a/cw_core/lib/db/sqlite.dart b/cw_core/lib/db/sqlite.dart
index b32c1e8..8d716c4 100644
--- a/cw_core/lib/db/sqlite.dart
+++ b/cw_core/lib/db/sqlite.dart
@@ -7,6 +7,8 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart';
Database? db;
+
+
Future<void> _addColumnIfNotExists(
Database db, {
required String table,
@@ -39,7 +41,7 @@ Future<void> initDb({String? pathOverride}) async {
}
}
await db?.close();
- db = await openDatabase(dbFile.path, version: 4,
+ db = await openDatabase(dbFile.path, version: 5,
onUpgrade: (Database db, int oldVersion, int newVersion) async {
printV("migrating: $oldVersion, $newVersion");
if (oldVersion <= 1) {
@@ -88,6 +90,9 @@ CREATE TABLE IF NOT EXISTS BalanceCardStyleSettings (
// if address doesn't correspond to a valid token, fallback to primary token
await _addColumnIfNotExists(db, table: "WalletInfo", column: "favoriteTokenAddress", definition: "TEXT DEFAULT NULL");
}
+ if (oldVersion <= 4) {
+ await _createBridgeTransferTable(db);
+ }
},
onCreate: (Database db, int version) async {
await db.execute(
@@ -185,6 +190,7 @@ CREATE TABLE BalanceCardStyleSettings (
FOREIGN KEY (walletInfoId) REFERENCES WalletInfo(walletInfoId)
);
''');
+ await _createBridgeTransferTable(db);
}
);
}
@@ -222,4 +228,31 @@ Future<Map<String, dynamic>> dumpCustomDb(String path) async {
ret[tableName] = await db.query(tableName);
}
return ret;
+}
+
+Future<void> _createBridgeTransferTable(Database db) async {
+ await db.execute('''
+CREATE TABLE IF NOT EXISTS BridgeTransfer (
+ id TEXT NOT NULL PRIMARY KEY,
+ wallet_id TEXT NOT NULL,
+ source_chain_id INTEGER NOT NULL,
+ destination_chain_id INTEGER NOT NULL,
+ token_symbol TEXT NOT NULL,
+ token_contract TEXT NOT NULL,
+ amount TEXT NOT NULL,
+ recipient_address TEXT NOT NULL,
+ source_tx_hash TEXT NOT NULL,
+ status TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER,
+ confirmed_at INTEGER,
+ amount_raw TEXT,
+ error_message TEXT,
+ status_message TEXT
+);
+''');
+ await db.execute('''
+CREATE INDEX IF NOT EXISTS idx_bridgetransfer_wallet_id
+ON BridgeTransfer(wallet_id);
+''');
}
\ No newline at end of file
diff --git a/cw_core/lib/hive_type_ids.dart b/cw_core/lib/hive_type_ids.dart
index e7c20a4..72139ca 100644
--- a/cw_core/lib/hive_type_ids.dart
+++ b/cw_core/lib/hive_type_ids.dart
@@ -21,4 +21,4 @@ const HARDWARE_WALLET_TYPE_TYPE_ID = 19;
const MWEB_UTXO_TYPE_ID = 20;
const HAVEN_SEED_STORE_TYPE_ID = 21;
const ZANO_ASSET_TYPE_ID = 22;
-const PAYJOIN_SESSION_TYPE_ID = 23;
+const PAYJOIN_SESSION_TYPE_ID = 23;
\ No newline at end of file
diff --git a/cw_evm/lib/contract/oft.dart b/cw_evm/lib/contract/oft.dart
new file mode 100644
index 0000000..cb0b355
--- /dev/null
+++ b/cw_evm/lib/contract/oft.dart
@@ -0,0 +1,174 @@
+import 'dart:typed_data';
+
+import 'package:cw_evm/usdt0/usdt0_quote.dart';
+import 'package:hex/hex.dart' as hex;
+import 'package:web3dart/web3dart.dart' as web3;
+
+/// Minimal OFT ABI for USDT0 (per docs.usdt0.to).
+/// quoteSend(SendParam, bool payInLzToken) returns MessagingFee.
+/// send(SendParam, MessagingFee, address refundAddress) payable.
+/// SendParam: dstEid, to (bytes32), amountLD (amount to send, token units),
+/// minAmountLD (min receive on destination), extraOptions, composeMsg, oftCmd.
+const String _oftAbiJson = '''
+[
+ {
+ "inputs": [
+ {
+ "components": [
+ {"internalType": "uint32", "name": "dstEid", "type": "uint32"},
+ {"internalType": "bytes32", "name": "to", "type": "bytes32"},
+ {"internalType": "uint256", "name": "amountLD", "type": "uint256"},
+ {"internalType": "uint256", "name": "minAmountLD", "type": "uint256"},
+ {"internalType": "bytes", "name": "extraOptions", "type": "bytes"},
+ {"internalType": "bytes", "name": "composeMsg", "type": "bytes"},
+ {"internalType": "bytes", "name": "oftCmd", "type": "bytes"}
+ ],
+ "internalType": "struct SendParam",
+ "name": "sendParam",
+ "type": "tuple"
+ },
+ {"internalType": "bool", "name": "payInLzToken", "type": "bool"}
+ ],
+ "name": "quoteSend",
+ "outputs": [
+ {
+ "components": [
+ {"internalType": "uint256", "name": "nativeFee", "type": "uint256"},
+ {"internalType": "uint256", "name": "lzTokenFee", "type": "uint256"}
+ ],
+ "internalType": "struct MessagingFee",
+ "name": "",
+ "type": "tuple"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "components": [
+ {"internalType": "uint32", "name": "dstEid", "type": "uint32"},
+ {"internalType": "bytes32", "name": "to", "type": "bytes32"},
+ {"internalType": "uint256", "name": "amountLD", "type": "uint256"},
+ {"internalType": "uint256", "name": "minAmountLD", "type": "uint256"},
+ {"internalType": "bytes", "name": "extraOptions", "type": "bytes"},
+ {"internalType": "bytes", "name": "composeMsg", "type": "bytes"},
+ {"internalType": "bytes", "name": "oftCmd", "type": "bytes"}
+ ],
+ "internalType": "struct SendParam",
+ "name": "_sendParam",
+ "type": "tuple"
+ },
+ {
+ "components": [
+ {"internalType": "uint256", "name": "nativeFee", "type": "uint256"},
+ {"internalType": "uint256", "name": "lzTokenFee", "type": "uint256"}
+ ],
+ "internalType": "struct MessagingFee",
+ "name": "_fee",
+ "type": "tuple"
+ },
+ {"internalType": "address", "name": "_refundAddress", "type": "address"}
+ ],
+ "name": "send",
+ "outputs": [],
+ "stateMutability": "payable",
+ "type": "function"
+ }
+]
+''';
+
+final web3.ContractAbi oftContractAbi = web3.ContractAbi.fromJson(_oftAbiJson, 'OFT');
+
+/// OFT contract wrapper for quoteSend (view) and send (payable).
+class OFT {
+ OFT({
+ required web3.EthereumAddress address,
+ required web3.Web3Client client,
+ }) : _address = address,
+ _client = client;
+
+ final web3.EthereumAddress _address;
+ final web3.Web3Client _client;
+
+ /// Calls quoteSend and returns MessagingFee (nativeFee, lzTokenFee).
+ Future<USDT0Quote> quoteSend({
+ required int dstEid,
+ required List<int> toBytes32,
+ required BigInt amountLD,
+ required BigInt minAmountLD,
+ Uint8List? extraOptions,
+ Uint8List? composeMsg,
+ Uint8List? oftCmd,
+ bool payInLzToken = false,
+ }) async {
+ final contract = web3.DeployedContract(
+ oftContractAbi,
+ _address,
+ );
+ final fn = contract.function('quoteSend');
+ final sendParam = [
+ BigInt.from(dstEid),
+ Uint8List.fromList(toBytes32),
+ amountLD,
+ minAmountLD,
+ extraOptions ?? Uint8List(0),
+ composeMsg ?? Uint8List(0),
+ oftCmd ?? Uint8List(0),
+ ];
+ final params = [sendParam, payInLzToken];
+
+ final result = await _client.call(
+ contract: contract,
+ function: fn,
+ params: params,
+ );
+
+ final msgFee = result.first as List<dynamic>;
+ final nativeFee = msgFee[0] as BigInt;
+ final lzTokenFee = msgFee[1] as BigInt;
+ return USDT0Quote(nativeFee: nativeFee, lzTokenFee: lzTokenFee);
+ }
+
+ /// Encodes send(SendParam, MessagingFee, refundAddress) for transaction.
+ /// Returns (dataHex, valueWei) where valueWei is the native fee to send.
+ ({String dataHex, BigInt valueWei}) encodeSend({
+ required int dstEid,
+ required List<int> toBytes32,
+ required BigInt amountLD,
+ required BigInt minAmountLD,
+ required BigInt nativeFee,
+ required BigInt lzTokenFee,
+ required String refundAddress,
+ Uint8List? extraOptions,
+ Uint8List? composeMsg,
+ Uint8List? oftCmd,
+ }) {
+ final contract = web3.DeployedContract(oftContractAbi, _address);
+ final fn = contract.function('send');
+ final sendParam = [
+ BigInt.from(dstEid),
+ Uint8List.fromList(toBytes32),
+ amountLD,
+ minAmountLD,
+ extraOptions ?? Uint8List(0),
+ composeMsg ?? Uint8List(0),
+ oftCmd ?? Uint8List(0),
+ ];
+ final fee = [nativeFee, lzTokenFee];
+ final refund = web3.EthereumAddress.fromHex(refundAddress);
+ final encoded = fn.encodeCall([sendParam, fee, refund]);
+ final dataHex = '0x${hex.HEX.encode(encoded)}';
+ return (dataHex: dataHex, valueWei: nativeFee);
+ }
+}
+
+/// Converts EVM address (0x + 40 hex) to bytes32 (left-padded).
+List<int> addressToBytes32(String address) {
+ final clean = address.startsWith('0x') ? address.substring(2) : address;
+
+ if (clean.length != 40) throw ArgumentError('Invalid address length');
+
+ return hex.HEX.decode('000000000000000000000000$clean');
+}
diff --git a/cw_evm/lib/evm_chain_wallet.dart b/cw_evm/lib/evm_chain_wallet.dart
index 991fd07..e2e97a3 100644
--- a/cw_evm/lib/evm_chain_wallet.dart
+++ b/cw_evm/lib/evm_chain_wallet.dart
@@ -1281,12 +1281,25 @@ abstract class EVMChainWalletBase
} else if (newTxInfo.direction == TransactionDirection.incoming &&
existingTxInfo.direction == TransactionDirection.outgoing) {
result[transactionModel.hash] = newTxInfo;
+ }
+
+ else if (newTxInfo.direction == TransactionDirection.outgoing &&
+ existingTxInfo.direction == TransactionDirection.outgoing &&
+ _hasEvmTokenContractAddress(newTxInfo) &&
+ !_hasEvmTokenContractAddress(existingTxInfo)) {
+ result[transactionModel.hash] = newTxInfo;
}
}
return result;
}
+
+ bool _hasEvmTokenContractAddress(EVMChainTransactionInfo info) {
+ final c = info.contractAddress;
+ return c != null && c.isNotEmpty;
+ }
+
String? analyzeTransaction(String? transactionInput) {
if (transactionInput == '0x' || transactionInput == null || transactionInput.isEmpty) {
return '';
diff --git a/cw_evm/lib/tokens/arbitrum_tokens.dart b/cw_evm/lib/tokens/arbitrum_tokens.dart
index fb6aa77..dec37d4 100644
--- a/cw_evm/lib/tokens/arbitrum_tokens.dart
+++ b/cw_evm/lib/tokens/arbitrum_tokens.dart
@@ -12,6 +12,13 @@ class ArbitrumTokens {
decimal: 18,
enabled: true,
),
+ Erc20Token(
+ name: "Tether USD (Omnichain)",
+ symbol: "USDT",
+ contractAddress: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9",
+ decimal: 6,
+ enabled: true,
+ ),
Erc20Token(
name: "USD Coin",
symbol: "USDC",
@@ -68,8 +75,7 @@ class ArbitrumTokens {
if (token.iconPath?.isEmpty ?? true) {
try {
iconPath = CryptoCurrency.all
- .firstWhere((element) =>
- element.title.toUpperCase() == token.symbol.toUpperCase())
+ .firstWhere((element) => element.title.toUpperCase() == token.symbol.toUpperCase())
.iconPath;
} catch (_) {}
} else {
@@ -80,4 +86,3 @@ class ArbitrumTokens {
}).toList();
}
}
-
diff --git a/cw_evm/lib/usdt0/usdt0_config.dart b/cw_evm/lib/usdt0/usdt0_config.dart
new file mode 100644
index 0000000..e1978b7
--- /dev/null
+++ b/cw_evm/lib/usdt0/usdt0_config.dart
@@ -0,0 +1,58 @@
+import 'package:cw_core/erc20_token.dart';
+
+/// USDT0 (Omnichain USDT) config.
+/// Addresses and EIDs from https://docs.usdt0.to/technical-documentation/developer/usdt0-deployments
+class USDT0Config {
+ USDT0Config._();
+
+ static const String ethereumOftAdapter =
+ '0x6C96dE32CEa08842dcc4058c14d3aaAD7Fa41dee';
+
+ static const String ethereumNativeUsdt =
+ '0xdac17f958d2ee523a2206206994597c13d831ec7';
+
+ static const Map<int, String> usdt0TokenAddressByChainId = {
+ 1: ethereumNativeUsdt,
+ 137: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F',
+ 42161: '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9',
+ };
+
+ static const Map<int, String> oftContractAddressByChainId = {
+ 1: ethereumOftAdapter,
+ 137: '0x6BA10300f0DC58B7a1e4c0e41f5daBb7D7829e13',
+ 42161: '0x14E4A1B13bf7F943c8ff7C51fb60FA964A298D92',
+ };
+
+ static const Map<int, int> endpointIdByChainId = {
+ 1: 30101,
+ 137: 30109,
+ 42161: 30110,
+ };
+
+ static const int ethereumChainId = 1;
+
+ static List<int> get supportedChainIds =>
+ usdt0TokenAddressByChainId.keys.toList(growable: false);
+
+ static String? getOftAdapterAddress(int chainId) {
+ if (chainId == ethereumChainId) return ethereumOftAdapter;
+ return null;
+ }
+
+ static String? getUsdt0TokenAddress(int chainId) =>
+ usdt0TokenAddressByChainId[chainId];
+
+ static String? getOftContractAddress(int chainId) =>
+ oftContractAddressByChainId[chainId];
+
+ static int? getEndpointId(int chainId) => endpointIdByChainId[chainId];
+
+ static bool isChainSupported(int chainId) =>
+ usdt0TokenAddressByChainId.containsKey(chainId);
+
+ static bool isUSDT0Token(Erc20Token token, int chainId) {
+ final address = getUsdt0TokenAddress(chainId);
+ if (address == null) return false;
+ return token.contractAddress.toLowerCase() == address.toLowerCase();
+ }
+}
diff --git a/cw_evm/lib/usdt0/usdt0_quote.dart b/cw_evm/lib/usdt0/usdt0_quote.dart
new file mode 100644
index 0000000..2c2cbb8
--- /dev/null
+++ b/cw_evm/lib/usdt0/usdt0_quote.dart
@@ -0,0 +1,15 @@
+class USDT0Quote {
+ const USDT0Quote({
+ required this.nativeFee,
+ required this.lzTokenFee,
+ });
+
+ /// Fee in native token (e.g. ETH, MATIC) in wei.
+ final BigInt nativeFee;
+
+ /// Fee in LZ token (if paying with LZ token).
+ final BigInt lzTokenFee;
+
+ @override
+ String toString() => 'USDT0Quote(nativeFee: $nativeFee, lzTokenFee: $lzTokenFee)';
+}
diff --git a/cw_evm/lib/usdt0/usdt0_service.dart b/cw_evm/lib/usdt0/usdt0_service.dart
new file mode 100644
index 0000000..963994b
--- /dev/null
+++ b/cw_evm/lib/usdt0/usdt0_service.dart
@@ -0,0 +1,146 @@
+import 'package:cw_core/erc20_token.dart';
+import 'package:cw_core/pending_transaction.dart';
+import 'package:cw_evm/contract/erc20.dart';
+import 'package:cw_evm/contract/oft.dart';
+import 'package:cw_evm/evm_chain_transaction_priority.dart';
+import 'package:cw_evm/utils/evm_chain_utils.dart';
+import 'package:cw_evm/evm_chain_wallet.dart';
+import 'package:cw_evm/usdt0/usdt0_config.dart';
+import 'package:cw_evm/usdt0/usdt0_quote.dart';
+import 'package:web3dart/web3dart.dart' as web3;
+
+class USDT0Service {
+ static Future<USDT0Quote> quoteCrossChainTransfer({
+ required web3.Web3Client client,
+ required int sourceChainId,
+ required int destinationChainId,
+ required BigInt amount,
+ required String recipientAddress,
+ BigInt? minAmount,
+ }) async {
+ final min = minAmount ?? BigInt.zero;
+ final oftAddress = USDT0Config.getOftContractAddress(sourceChainId);
+ final dstEid = USDT0Config.getEndpointId(destinationChainId);
+
+ if (oftAddress == null || dstEid == null) {
+ throw Exception(
+ 'USDT0 not supported for chain $sourceChainId -> $destinationChainId',
+ );
+ }
+
+ final toBytes32 = addressToBytes32(recipientAddress);
+ final oft = OFT(
+ address: web3.EthereumAddress.fromHex(oftAddress),
+ client: client,
+ );
+
+ return oft.quoteSend(
+ dstEid: dstEid,
+ toBytes32: toBytes32,
+ amountLD: amount,
+ minAmountLD: min,
+ payInLzToken: false,
+ );
+ }
+
+ static Future<PendingTransaction> executeCrossChainTransfer({
+ required EVMChainWallet wallet,
+ required int sourceChainId,
+ required int destinationChainId,
+ required BigInt amount,
+ required String recipientAddress,
+ required USDT0Quote quote,
+ required Erc20Token token,
+ required EVMChainTransactionPriority priority,
+ bool useBlinkProtection = true,
+ }) async {
+ final oftAddress = USDT0Config.getOftContractAddress(sourceChainId);
+ final dstEid = USDT0Config.getEndpointId(destinationChainId);
+
+ if (oftAddress == null || dstEid == null) {
+ throw Exception(
+ 'USDT0 not supported for chain $sourceChainId -> $destinationChainId',
+ );
+ }
+
+ if (sourceChainId == USDT0Config.ethereumChainId) {
+ final adapter = USDT0Config.getOftAdapterAddress(sourceChainId);
+ if (adapter != null) {
+ final needsApproval = await _isApprovalRequired(
+ wallet: wallet,
+ token: token,
+ spender: adapter,
+ amount: amount,
+ );
+
+ if (needsApproval) {
+ final maxUint = BigInt.parse(
+ 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
+ radix: 16,
+ );
+
+ final pendingApproval = await wallet.createApprovalTransaction(
+ maxUint,
+ adapter,
+ token,
+ priority,
+ EVMChainUtils.getFeeCurrency(wallet.selectedChainId),
+ useBlinkProtection: useBlinkProtection,
+ );
+ await pendingApproval.commit();
+ }
+ }
+ }
+
+ final client = wallet.getWeb3Client();
+ if (client == null) {
+ throw StateError('Wallet not connected to node');
+ }
+
+ final toBytes32 = addressToBytes32(recipientAddress);
+ final oft = OFT(
+ address: web3.EthereumAddress.fromHex(oftAddress),
+ client: client,
+ );
+
+ final encoded = oft.encodeSend(
+ dstEid: dstEid,
+ toBytes32: toBytes32,
+ amountLD: amount,
+ minAmountLD: BigInt.zero,
+ nativeFee: quote.nativeFee,
+ lzTokenFee: quote.lzTokenFee,
+ refundAddress: wallet.walletAddresses.primaryAddress,
+ );
+
+ return wallet.createCallDataTransaction(
+ oftAddress,
+ encoded.dataHex,
+ encoded.valueWei,
+ priority,
+ token.contractAddress,
+ amount,
+ useBlinkProtection: useBlinkProtection,
+ );
+ }
+
+ static Future<bool> _isApprovalRequired({
+ required EVMChainWallet wallet,
+ required Erc20Token token,
+ required String spender,
+ required BigInt amount,
+ }) async {
+ final client = wallet.getWeb3Client();
+ if (client == null) return true;
+
+ final erc20 = ERC20(
+ address: web3.EthereumAddress.fromHex(token.contractAddress),
+ client: client,
+ );
+ final current = await erc20.allowance(
+ web3.EthereumAddress.fromHex(wallet.walletAddresses.primaryAddress),
+ web3.EthereumAddress.fromHex(spender),
+ );
+ return current < amount;
+ }
+}
diff --git a/lib/core/layerzero_scan_service.dart b/lib/core/layerzero_scan_service.dart
new file mode 100644
index 0000000..6bb572c
--- /dev/null
+++ b/lib/core/layerzero_scan_service.dart
@@ -0,0 +1,318 @@
+import 'dart:convert';
+
+import 'package:cw_core/utils/proxy_wrapper.dart';
+
+class LayerZeroScanService {
+ static const String _baseUrl = 'https://scan.layerzero-api.com/v1';
+
+ static Future<LayerZeroMessageStatus?> getMessageStatus(
+ String sourceTxHash,
+ ) async {
+ try {
+ final uri = Uri.parse('$_baseUrl/messages/tx/$sourceTxHash');
+
+ final response = await ProxyWrapper().get(clearnetUri: uri);
+
+ if (response.statusCode != 200) return null;
+
+ final decoded = json.decode(response.body);
+
+ Map<String, dynamic>? data;
+ if (decoded is List && decoded.isNotEmpty) {
+ data = decoded.first as Map<String, dynamic>?;
+ } else if (decoded is Map<String, dynamic>) {
+ if (decoded.containsKey('data') && decoded['data'] is List) {
+ final dataList = decoded['data'] as List;
+ if (dataList.isNotEmpty) {
+ data = dataList.first as Map<String, dynamic>?;
+ }
+ } else {
+ data = decoded;
+ }
+ }
+ if (data == null) return null;
+ return LayerZeroMessageStatus.fromJson(data);
+ } catch (_) {
+ return null;
+ }
+ }
+}
+
+class LayerZeroMessageStatus {
+ final String? guid;
+ final LayerZeroStatus? status;
+ final LayerZeroSource? source;
+ final LayerZeroDestination? destination;
+ final LayerZeroVerification? verification;
+
+ LayerZeroMessageStatus({
+ this.guid,
+ this.status,
+ this.source,
+ this.destination,
+ this.verification,
+ });
+
+ factory LayerZeroMessageStatus.fromJson(Map<String, dynamic> json) {
+ return LayerZeroMessageStatus(
+ guid: json['guid'] as String?,
+ status: json['status'] != null
+ ? LayerZeroStatus.fromJson(
+ json['status'] as Map<String, dynamic>,
+ )
+ : null,
+ source: json['source'] != null
+ ? LayerZeroSource.fromJson(
+ json['source'] as Map<String, dynamic>,
+ )
+ : null,
+ destination: json['destination'] != null
+ ? LayerZeroDestination.fromJson(
+ json['destination'] as Map<String, dynamic>,
+ )
+ : null,
+ verification: json['verification'] != null
+ ? LayerZeroVerification.fromJson(
+ json['verification'] as Map<String, dynamic>,
+ )
+ : null,
+ );
+ }
+
+ bool get isDelivered =>
+ status?.name == 'DELIVERED' ||
+ destination?.status == 'DELIVERED' ||
+ destination?.status == 'SUCCEEDED';
+
+ bool get isFailed => status?.name == 'FAILED' || destination?.status == 'FAILED';
+
+ bool get isInflight =>
+ status?.name == 'INFLIGHT' ||
+ status?.name == 'CONFIRMING' ||
+ destination?.status == 'WAITING' ||
+ destination?.status == 'VALIDATING_TX' ||
+ (destination?.status != 'SUCCEEDED' &&
+ destination?.status != 'DELIVERED' &&
+ destination?.status != 'FAILED' &&
+ destination?.tx == null);
+}
+
+class LayerZeroStatus {
+ final String? name;
+ final String? message;
+
+ LayerZeroStatus({this.name, this.message});
+
+ factory LayerZeroStatus.fromJson(Map<String, dynamic> json) {
+ return LayerZeroStatus(
+ name: json['name'] as String?,
+ message: json['message'] as String?,
+ );
+ }
+}
+
+class LayerZeroSource {
+ final String? status;
+ final LayerZeroTransaction? tx;
+
+ LayerZeroSource({
+ this.status,
+ this.tx,
+ });
+
+ factory LayerZeroSource.fromJson(Map<String, dynamic> json) {
+ return LayerZeroSource(
+ status: json['status'] as String?,
+ tx: json['tx'] != null
+ ? LayerZeroTransaction.fromJson(
+ json['tx'] as Map<String, dynamic>,
+ )
+ : null,
+ );
+ }
+}
+
+class LayerZeroDestination {
+ final String? status;
+ final LayerZeroTransaction? tx;
+ final LayerZeroNativeDrop? nativeDrop;
+ final LayerZeroLzCompose? lzCompose;
+
+ LayerZeroDestination({
+ this.status,
+ this.tx,
+ this.nativeDrop,
+ this.lzCompose,
+ });
+
+ factory LayerZeroDestination.fromJson(Map<String, dynamic> json) {
+ return LayerZeroDestination(
+ status: json['status'] as String?,
+ tx: json['tx'] != null
+ ? LayerZeroTransaction.fromJson(
+ json['tx'] as Map<String, dynamic>,
+ )
+ : null,
+ nativeDrop: json['nativeDrop'] != null
+ ? LayerZeroNativeDrop.fromJson(
+ json['nativeDrop'] as Map<String, dynamic>,
+ )
+ : null,
+ lzCompose: json['lzCompose'] != null
+ ? LayerZeroLzCompose.fromJson(
+ json['lzCompose'] as Map<String, dynamic>,
+ )
+ : null,
+ );
+ }
+}
+
+class LayerZeroNativeDrop {
+ final String? status;
+
+ LayerZeroNativeDrop({this.status});
+
+ factory LayerZeroNativeDrop.fromJson(Map<String, dynamic> json) {
+ return LayerZeroNativeDrop(
+ status: json['status'] as String?,
+ );
+ }
+}
+
+class LayerZeroLzCompose {
+ final String? status;
+
+ LayerZeroLzCompose({this.status});
+
+ factory LayerZeroLzCompose.fromJson(Map<String, dynamic> json) {
+ return LayerZeroLzCompose(
+ status: json['status'] as String?,
+ );
+ }
+}
+
+class LayerZeroTransaction {
+ final String? txHash;
+ final String? blockHash;
+ final String? blockNumber;
+ final int? blockTimestamp;
+ final String? from;
+
+ LayerZeroTransaction({
+ this.txHash,
+ this.blockHash,
+ this.blockNumber,
+ this.blockTimestamp,
+ this.from,
+ });
+
+ factory LayerZeroTransaction.fromJson(Map<String, dynamic> json) {
+ final blockNumberValue = json['blockNumber'];
+ final blockNumberStr = blockNumberValue != null
+ ? (blockNumberValue is int ? blockNumberValue.toString() : blockNumberValue as String?)
+ : null;
+ return LayerZeroTransaction(
+ txHash: json['txHash'] as String?,
+ blockHash: json['blockHash'] as String?,
+ blockNumber: blockNumberStr,
+ blockTimestamp: json['blockTimestamp'] as int?,
+ from: json['from'] as String?,
+ );
+ }
+}
+
+class LayerZeroVerification {
+ final LayerZeroDvn? dvn;
+ final LayerZeroSealer? sealer;
+
+ LayerZeroVerification({
+ this.dvn,
+ this.sealer,
+ });
+
+ factory LayerZeroVerification.fromJson(Map<String, dynamic> json) {
+ return LayerZeroVerification(
+ dvn: json['dvn'] != null
+ ? LayerZeroDvn.fromJson(
+ json['dvn'] as Map<String, dynamic>,
+ )
+ : null,
+ sealer: json['sealer'] != null
+ ? LayerZeroSealer.fromJson(
+ json['sealer'] as Map<String, dynamic>,
+ )
+ : null,
+ );
+ }
+}
+
+class LayerZeroDvn {
+ final Map<String, LayerZeroDvnStatus>? dvns;
+ final String? status;
+
+ LayerZeroDvn({
+ this.dvns,
+ this.status,
+ });
+
+ factory LayerZeroDvn.fromJson(Map<String, dynamic> json) {
+ final dvnsMap = json['dvns'] as Map<String, dynamic>?;
+ final parsedDvns = dvnsMap?.map(
+ (key, value) => MapEntry(
+ key,
+ LayerZeroDvnStatus.fromJson(value as Map<String, dynamic>),
+ ),
+ );
+ return LayerZeroDvn(
+ dvns: parsedDvns,
+ status: json['status'] as String?,
+ );
+ }
+}
+
+class LayerZeroDvnStatus {
+ final String? status;
+ final String? txHash;
+ final String? blockHash;
+ final int? blockNumber;
+ final int? blockTimestamp;
+
+ LayerZeroDvnStatus({
+ this.status,
+ this.txHash,
+ this.blockHash,
+ this.blockNumber,
+ this.blockTimestamp,
+ });
+
+ factory LayerZeroDvnStatus.fromJson(Map<String, dynamic> json) {
+ return LayerZeroDvnStatus(
+ status: json['status'] as String?,
+ txHash: json['txHash'] as String?,
+ blockHash: json['blockHash'] as String?,
+ blockNumber: json['blockNumber'] as int?,
+ blockTimestamp: json['blockTimestamp'] as int?,
+ );
+ }
+}
+
+class LayerZeroSealer {
+ final String? status;
+ final LayerZeroTransaction? tx;
+
+ LayerZeroSealer({
+ this.status,
+ this.tx,
+ });
+
+ factory LayerZeroSealer.fromJson(Map<String, dynamic> json) {
+ return LayerZeroSealer(
+ status: json['status'] as String?,
+ tx: json['tx'] != null
+ ? LayerZeroTransaction.fromJson(
+ json['tx'] as Map<String, dynamic>,
+ )
+ : null,
+ );
+ }
+}
diff --git a/lib/di.dart b/lib/di.dart
index 7a8a818..7d386e5 100644
--- a/lib/di.dart
+++ b/lib/di.dart
@@ -32,6 +32,7 @@ import 'package:cake_wallet/core/wallet_loading_service.dart';
import 'package:cake_wallet/core/yat_service.dart';
import 'package:cake_wallet/decred/decred.dart';
import 'package:cake_wallet/entities/biometric_auth.dart';
+import 'package:cake_wallet/entities/bridge_transfer.dart';
import 'package:cake_wallet/entities/contact.dart';
import 'package:cake_wallet/entities/contact_record.dart';
import 'package:cake_wallet/entities/exchange_api_mode.dart';
@@ -52,6 +53,9 @@ import 'package:cake_wallet/nano/nano.dart';
import 'package:cake_wallet/new-ui/new_dashboard.dart';
import 'package:cake_wallet/new-ui/pages/about_page.dart';
import 'package:cake_wallet/new-ui/pages/account_customizer.dart';
+import 'package:cake_wallet/new-ui/pages/bridge/bridge_amount_page.dart';
+import 'package:cake_wallet/new-ui/pages/bridge/bridge_network_page.dart';
+import 'package:cake_wallet/new-ui/pages/bridge/bridge_receiving_wallet_page.dart';
import 'package:cake_wallet/new-ui/pages/coin_control_page.dart';
import 'package:cake_wallet/new-ui/pages/addresses_page.dart';
import 'package:cake_wallet/new-ui/pages/home_page.dart';
@@ -163,6 +167,8 @@ import 'package:cake_wallet/src/screens/transaction_details/transaction_details_
import 'package:cake_wallet/src/screens/unspent_coins/unspent_coins_details_page.dart';
import 'package:cake_wallet/src/screens/unspent_coins/unspent_coins_list_page.dart';
import 'package:cake_wallet/src/screens/ur/animated_ur_page.dart';
+import 'package:cake_wallet/new-ui/pages/bridge/bridge_detail_page.dart';
+import 'package:cake_wallet/new-ui/pages/bridge/bridge_history_page.dart';
import 'package:cake_wallet/src/screens/wallet/wallet_edit_page.dart';
import 'package:cake_wallet/src/screens/wallet_connect/services/bottom_sheet_service.dart';
import 'package:cake_wallet/src/screens/wallet_connect/services/key_service/wallet_connect_key_service.dart';
@@ -183,6 +189,7 @@ import 'package:cake_wallet/store/dashboard/order_filter_store.dart';
import 'package:cake_wallet/store/dashboard/orders_store.dart';
import 'package:cake_wallet/store/dashboard/payjoin_transactions_store.dart';
import 'package:cake_wallet/store/dashboard/trade_filter_store.dart';
+import 'package:cake_wallet/store/bridge_transfers_store.dart';
import 'package:cake_wallet/store/dashboard/trades_store.dart';
import 'package:cake_wallet/store/dashboard/transaction_filter_store.dart';
import 'package:cake_wallet/store/node_list_store.dart';
@@ -202,6 +209,8 @@ import 'package:cake_wallet/view_model/anon_invoice_page_view_model.dart';
import 'package:cake_wallet/view_model/anonpay_details_view_model.dart';
import 'package:cake_wallet/view_model/auth_view_model.dart';
import 'package:cake_wallet/view_model/backup_view_model.dart';
+import 'package:cake_wallet/view_model/bridge_details_view_model.dart';
+import 'package:cake_wallet/view_model/bridge_history_view_model.dart';
import 'package:cake_wallet/view_model/buy/buy_amount_view_model.dart';
import 'package:cake_wallet/view_model/buy/buy_sell_view_model.dart';
import 'package:cake_wallet/view_model/buy/buy_view_model.dart';
@@ -269,6 +278,7 @@ import 'package:cake_wallet/view_model/transaction_details_view_model.dart';
import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_details_view_model.dart';
import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_item.dart';
import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart';
+import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart';
import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_item.dart';
import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart';
@@ -327,7 +337,6 @@ late Box<Order> _ordersSource;
late Box<UnspentCoinsInfo> _unspentCoinsInfoSource;
late Box<PayjoinSession> _payjoinSessionSource;
late Box<AnonpayInvoiceInfo> _anonpayInvoiceInfoSource;
-
Future<void> setup({
required Box<Node> nodeSource,
required Box<Node> powNodeSource,
@@ -396,6 +405,7 @@ Future<void> setup({
TradesStore(tradesSource: _tradesSource, appStore: getIt.get<AppStore>()));
getIt.registerSingleton<OrdersStore>(
OrdersStore(ordersSource: _ordersSource, settingsStore: getIt.get<SettingsStore>()));
+ getIt.registerSingleton<BridgeTransfersStore>(BridgeTransfersStore());
getIt.registerFactory(() =>
PayjoinTransactionsStore(payjoinSessionSource: _payjoinSessionSource));
getIt.registerSingleton<TradeFilterStore>(TradeFilterStore());
@@ -1761,11 +1771,45 @@ Future<void> setup({
getIt.registerFactory(() => DEuroSavingsPage(getIt<DEuroViewModel>()));
+ getIt.registerFactory(() => BridgeViewModel(
+ appStore: getIt.get<AppStore>(),
+ bridgeTransfersStore: getIt.get<BridgeTransfersStore>(),
+ walletManager: getIt.get<WalletManager>(),
+ fiatConversionStore: getIt.get<FiatConversionStore>(),
+ settingsStore: getIt.get<SettingsStore>(),
+ ));
+
+ getIt.registerFactory(() => BridgeHistoryViewModel(
+ bridgeTransfersStore: getIt.get<BridgeTransfersStore>(),
+ appStore: getIt.get<AppStore>(),
+ ));
+ getIt.registerFactoryParam<BridgeDetailsViewModel, BridgeTransfer, void>(
+ (BridgeTransfer transfer, _) {
+ final appStore = getIt.get<AppStore>();
+ return BridgeDetailsViewModel(
+ transferForDetails: transfer,
+ bridgeTransfersStore: getIt.get<BridgeTransfersStore>(),
+ walletId: appStore.wallet?.name ?? transfer.walletId,
+ );
+ });
+ getIt.registerFactoryParam<BridgeDetailPage, BridgeTransfer, void>(
+ (BridgeTransfer transfer, _) => BridgeDetailPage(
+ viewModel: getIt.get<BridgeDetailsViewModel>(param1: transfer),
+ ));
+
getIt.registerLazySingleton(() => NodeSwitchingService(
appStore: getIt.get<AppStore>(),
settingsStore: getIt.get<SettingsStore>(),
nodeSource: _nodeSource,
));
+ getIt.registerFactoryParam<BridgeAmountPage, CryptoCurrency, void>(
+ (CryptoCurrency initialToken, _) => BridgeAmountPage(
+ bridgeViewModel: getIt.get<BridgeViewModel>(),
+ bridgeHistoryViewModel: getIt.get<BridgeHistoryViewModel>(),
+ initialToken: initialToken,
+ ),
+ );
+
_isSetupFinished = true;
}
diff --git a/lib/entities/bridge_transfer.dart b/lib/entities/bridge_transfer.dart
new file mode 100644
index 0000000..893f568
--- /dev/null
+++ b/lib/entities/bridge_transfer.dart
@@ -0,0 +1,127 @@
+import 'package:cw_core/db/sqlite.dart';
+
+class BridgeTransfer {
+ BridgeTransfer({
+ required this.id,
+ required this.walletId,
+ required this.sourceChainId,
+ required this.destinationChainId,
+ required this.tokenSymbol,
+ required this.tokenContract,
+ required this.amount,
+ required this.recipientAddress,
+ required this.sourceTxHash,
+ required this.status,
+ required this.createdAt,
+ this.updatedAt,
+ this.confirmedAt,
+ this.amountRaw,
+ this.errorMessage,
+ this.statusMessage,
+ });
+
+ static const tableName = 'BridgeTransfer';
+
+ static Future<List<BridgeTransfer>> selectAll() async {
+ final database = db;
+ if (database == null) return [];
+
+ final rows = await database.query(
+ tableName,
+ orderBy: 'created_at DESC',
+ );
+ return rows.map(fromRow).toList();
+ }
+
+ static Future<void> insert(BridgeTransfer transfer) async {
+ final database = db;
+ if (database == null) return;
+
+ await database.insert(tableName, transfer.toRow());
+ }
+
+ static Future<void> update(BridgeTransfer transfer) async {
+ final database = db;
+ if (database == null) return;
+
+ await database.update(
+ tableName,
+ transfer.toRow(),
+ where: 'id = ?',
+ whereArgs: [transfer.id],
+ );
+ }
+
+ String id;
+ String walletId;
+ int sourceChainId;
+ int destinationChainId;
+ String tokenSymbol;
+ String tokenContract;
+ String amount;
+ String recipientAddress;
+ String sourceTxHash;
+ String status;
+ DateTime createdAt;
+ DateTime? updatedAt;
+ DateTime? confirmedAt;
+ String? amountRaw;
+ String? errorMessage;
+ String? statusMessage;
+
+ bool get isActive => status == 'submitted' || status == 'confirming' || status == 'initiated';
+
+ Map<String, Object?> toRow() {
+ return {
+ 'id': id,
+ 'wallet_id': walletId,
+ 'source_chain_id': sourceChainId,
+ 'destination_chain_id': destinationChainId,
+ 'token_symbol': tokenSymbol,
+ 'token_contract': tokenContract,
+ 'amount': amount,
+ 'recipient_address': recipientAddress,
+ 'source_tx_hash': sourceTxHash,
+ 'status': status,
+ 'created_at': createdAt.millisecondsSinceEpoch,
+ 'updated_at': updatedAt?.millisecondsSinceEpoch,
+ 'confirmed_at': confirmedAt?.millisecondsSinceEpoch,
+ 'amount_raw': amountRaw,
+ 'error_message': errorMessage,
+ 'status_message': statusMessage,
+ };
+ }
+
+ static int? _nullableInt(Object? v) {
+ if (v == null) return null;
+ if (v is int) return v;
+ if (v is num) return v.toInt();
+ return int.tryParse(v.toString());
+ }
+
+ static int _parseInt(Object? v) => _nullableInt(v) ?? 0;
+ static DateTime _parseDateTime(Object? v) => DateTime.fromMillisecondsSinceEpoch(_parseInt(v));
+
+ static BridgeTransfer fromRow(Map<String, Object?> m) {
+ String? asStr(Object? v) => v as String?;
+
+ return BridgeTransfer(
+ id: m['id'] as String,
+ walletId: m['wallet_id'] as String,
+ sourceChainId: _parseInt(m['source_chain_id']),
+ destinationChainId: _parseInt(m['destination_chain_id']),
+ tokenSymbol: m['token_symbol'] as String,
+ tokenContract: m['token_contract'] as String,
+ amount: m['amount'] as String,
+ recipientAddress: m['recipient_address'] as String,
+ sourceTxHash: m['source_tx_hash'] as String,
+ status: m['status'] as String,
+ createdAt: _parseDateTime(m['created_at']),
+ updatedAt: _parseDateTime(m['updated_at']),
+ confirmedAt: _parseDateTime(m['confirmed_at']),
+ amountRaw: asStr(m['amount_raw']),
+ errorMessage: asStr(m['error_message']),
+ statusMessage: asStr(m['status_message']),
+ );
+ }
+}
diff --git a/lib/entities/wallet_manager.dart b/lib/entities/wallet_manager.dart
index dd88c93..bb20a71 100644
--- a/lib/entities/wallet_manager.dart
+++ b/lib/entities/wallet_manager.dart
@@ -4,7 +4,6 @@ import 'package:cake_wallet/entities/hash_wallet_identifier.dart';
import 'package:cake_wallet/entities/wallet_group.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_info.dart';
-import 'package:hive/hive.dart';
import 'package:shared_preferences/shared_preferences.dart';
class WalletManager {
@@ -163,4 +162,14 @@ class WalletManager {
await _sharedPreferences.remove(oldNameKey);
}
}
+
+ String? getGroupName(WalletInfo walletInfo) {
+ try {
+ final groupKey = _resolveGroupKey(walletInfo);
+ final group = walletGroups.firstWhere((g) => g.groupKey == groupKey);
+ return group.groupName;
+ } catch (_) {
+ return null;
+ }
+ }
}
diff --git a/lib/evm/cw_evm.dart b/lib/evm/cw_evm.dart
index ec27c93..1462dbe 100644
--- a/lib/evm/cw_evm.dart
+++ b/lib/evm/cw_evm.dart
@@ -226,6 +226,22 @@ class CWEVM extends EVM {
@override
Web3Client? getWeb3Client(WalletBase wallet) => (wallet as EVMChainWallet).getWeb3Client();
+ @override
+ Future<bool?> getTransactionReceipt(WalletBase wallet, String txHash) async {
+ final client = getWeb3Client(wallet);
+ if (client == null) return null;
+
+ try {
+ final receipt = await client.getTransactionReceipt(txHash);
+
+ if (receipt == null) return null;
+
+ return receipt.status;
+ } catch (_) {
+ return null;
+ }
+ }
+
@override
String getTokenAddress(CryptoCurrency asset) => (asset as Erc20Token).contractAddress;
@@ -492,10 +508,24 @@ class CWEVM extends EVM {
chainId: config.chainId,
name: config.name,
shortCode: config.shortCode,
+ currency: config.nativeCurrency,
))
.toList();
}
+ @override
+ ChainInfo? getChainInfoByChainId(int chainId) {
+ final config = _registry.getChainConfig(chainId);
+ if (config == null) return null;
+
+ return ChainInfo(
+ chainId: config.chainId,
+ name: config.name,
+ shortCode: config.shortCode,
+ currency: config.nativeCurrency,
+ );
+ }
+
@override
ChainInfo? getCurrentChain(WalletBase wallet) {
if (wallet is EVMChainWallet) {
@@ -505,6 +535,7 @@ class CWEVM extends EVM {
chainId: config.chainId,
name: config.name,
shortCode: config.shortCode,
+ currency: config.nativeCurrency,
);
}
return null;
@@ -542,6 +573,85 @@ class CWEVM extends EVM {
bool hasPriorityFee(int chainId) => EVMChainUtils.hasPriorityFee(chainId);
@override
+ bool isUSDT0Token(WalletBase wallet, CryptoCurrency token) {
+ if (token is! Erc20Token) return false;
+
+ final chainId = getSelectedChainId(wallet);
+ if (chainId == null) return false;
+
+ return USDT0Config.isUSDT0Token(token, chainId);
+ }
+
+ @override
+ List<ChainInfo> getUSDT0DestinationChains(WalletBase wallet) {
+ final currentChainId = getSelectedChainId(wallet);
+ if (currentChainId == null) return [];
+
+ final result = <ChainInfo>[];
+ for (final config in _registry.getAllChains()) {
+ if (USDT0Config.isChainSupported(config.chainId) && config.chainId != currentChainId) {
+ result.add(ChainInfo(
+ chainId: config.chainId,
+ name: config.name,
+ shortCode: config.shortCode,
+ currency: config.nativeCurrency,
+ ));
+ }
+ }
+ return result;
+ }
+
+ @override
+ Future<USDT0Quote> quoteUSDT0Transfer({
+ required WalletBase wallet,
+ required int sourceChainId,
+ required int destinationChainId,
+ required BigInt amount,
+ required String recipientAddress,
+ }) {
+ final evmWallet = wallet as EVMChainWallet;
+ final client = evmWallet.getWeb3Client();
+ if (client == null) {
+ throw StateError('Wallet not connected');
+ }
+
+ return USDT0Service.quoteCrossChainTransfer(
+ client: client,
+ sourceChainId: sourceChainId,
+ destinationChainId: destinationChainId,
+ amount: amount,
+ recipientAddress: recipientAddress,
+ );
+ }
+
+ @override
+ Future<PendingTransaction> executeUSDT0Transfer({
+ required WalletBase wallet,
+ required CryptoCurrency token,
+ required int sourceChainId,
+ required int destinationChainId,
+ required BigInt amount,
+ required String recipientAddress,
+ required USDT0Quote quote,
+ required TransactionPriority priority,
+ bool useBlinkProtection = true,
+ }) {
+ final evmWallet = wallet as EVMChainWallet;
+ final tokenErc20 = token as Erc20Token;
+
+ return USDT0Service.executeCrossChainTransfer(
+ wallet: evmWallet,
+ sourceChainId: sourceChainId,
+ destinationChainId: destinationChainId,
+ amount: amount,
+ recipientAddress: recipientAddress,
+ quote: quote,
+ token: tokenErc20,
+ priority: priority as EVMChainTransactionPriority,
+ useBlinkProtection: useBlinkProtection,
+
+ );
+ }
Future<EvmWalletConnectFeeQuote?> getWCBufferedFeeQuote(
WalletBase wallet,
TransactionPriority priority,
diff --git a/lib/main.dart b/lib/main.dart
index 3d3615e..8a4690d 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -362,10 +362,10 @@ Future<void> initialSetup({
powNodeSource: powNodes,
contactSource: contactSource,
tradesSource: tradesSource,
+ ordersSource: ordersSource,
templates: templates,
exchangeTemplates: exchangeTemplates,
transactionDescriptionBox: transactionDescriptions,
- ordersSource: ordersSource,
anonpayInvoiceInfoSource: anonpayInvoiceInfo,
unspentCoinsInfoSource: unspentCoinsInfoSource,
payjoinSessionSource: payjoinSessionSource,
diff --git a/lib/new-ui/pages/bridge/bridge_amount_page.dart b/lib/new-ui/pages/bridge/bridge_amount_page.dart
new file mode 100644
index 0000000..42dc27c
--- /dev/null
+++ b/lib/new-ui/pages/bridge/bridge_amount_page.dart
@@ -0,0 +1,305 @@
+import 'package:cake_wallet/generated/i18n.dart';
+import 'package:cake_wallet/new-ui/pages/bridge/bridge_history_page.dart';
+import 'package:cake_wallet/routes.dart';
+import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
+import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart';
+import 'package:cake_wallet/utils/request_review_handler.dart';
+import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
+import 'package:cake_wallet/new-ui/widgets/modern_button.dart';
+import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
+import 'package:cake_wallet/view_model/bridge_history_view_model.dart';
+import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
+import 'package:cw_core/crypto_currency.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_mobx/flutter_mobx.dart';
+import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
+
+class BridgeAmountPage extends StatefulWidget {
+ const BridgeAmountPage({
+ super.key,
+ required this.bridgeViewModel,
+ required this.bridgeHistoryViewModel,
+ required this.initialToken,
+ });
+
+ final BridgeViewModel bridgeViewModel;
+ final BridgeHistoryViewModel bridgeHistoryViewModel;
+
+ final CryptoCurrency initialToken;
+
+ @override
+ State<BridgeAmountPage> createState() => _BridgeAmountPageState();
+}
+
+class _BridgeAmountPageState extends State<BridgeAmountPage> {
+ late final TextEditingController _amountController;
+ BridgeViewModel get bridgeViewModel => widget.bridgeViewModel;
+
+ @override
+ void initState() {
+ super.initState();
+ bridgeViewModel.applyInitialBridgeToken(widget.initialToken);
+ bridgeViewModel.onBridgeSuccess = _showBridgeSuccessBottomSheet;
+
+ _amountController = TextEditingController();
+
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ bridgeViewModel.ensureFiatPriceForSelectedToken();
+ });
+ }
+
+ void _showBridgeSuccessBottomSheet() {
+ if (!mounted) return;
+ WidgetsBinding.instance.addPostFrameCallback((_) async {
+ if (!mounted) return;
+ final ctx = context;
+ if (!ctx.mounted) return;
+
+ await showModalBottomSheet<void>(
+ context: ctx,
+ isScrollControlled: true,
+ builder: (BuildContext bottomSheetContext) {
+ return InfoBottomSheet(
+ footerType: FooterType.doubleActionButton,
+ titleText: 'Bridge initiated!',
+ contentImage: 'assets/images/birthday_cake.png',
+ content: 'The bridging will take between 30 seconds and 3 '
+ 'minutes to complete.',
+ doubleActionLeftButtonText: S.of(bottomSheetContext).close,
+ doubleActionRightButtonText: 'View history',
+ onLeftActionButtonPressed: () {
+ bridgeViewModel.clearBridgeSuccess();
+ Navigator.of(context, rootNavigator: true).pop();
+ RequestReviewHandler.requestReview();
+ },
+ onRightActionButtonPressed: () {
+ bridgeViewModel.clearBridgeSuccess();
+ Navigator.of(context).popUntil((route) => route.isFirst);
+ showMaterialModalBottomSheet(
+ context: context,
+ backgroundColor: Colors.transparent,
+ builder: (ctx) => BridgeHistoryPage(widget.bridgeHistoryViewModel),
+ );
+ },
+ );
+ },
+ );
+ });
+ }
+
+ @override
+ void dispose() {
+ bridgeViewModel.onBridgeSuccess = null;
+ bridgeViewModel.setAmount('');
+ _amountController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+
+ return KeyboardHideOverlay(
+ unfocusOnTap: true,
+ child: Container(
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surface,
+ borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
+ ),
+ child: Column(
+ children: [
+ ModalTopBar(
+ title: 'Enter Amount',
+ leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
+ onLeadingPressed: () => Navigator.of(context, rootNavigator: true).pop(),
+ trailingIcon: const Icon(Icons.calendar_month, size: 18),
+ onTrailingPressed: () {
+ Navigator.pushNamed(context, Routes.bridgeHistoryPage, arguments: widget.bridgeHistoryViewModel);
+ },
+ ),
+ Expanded(
+ child: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 18),
+ child: Observer(
+ builder: (_) {
+ final canNext = bridgeViewModel.canProceedToDestinationNetwork;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const Spacer(flex: 2),
+ LayoutBuilder(
+ builder: (context, constraints) {
+ return Center(
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.baseline,
+ textBaseline: TextBaseline.alphabetic,
+ children: [
+ ConstrainedBox(
+ constraints: BoxConstraints(
+ maxWidth: (constraints.maxWidth - 200)
+ .clamp(40.0, constraints.maxWidth),
+ minWidth: 40,
+ ),
+ child: TextFormField(
+ controller: _amountController,
+ maxLines: 1,
+ onChanged: bridgeViewModel.setAmount,
+ autovalidateMode: AutovalidateMode.always,
+ validator: bridgeViewModel.decimalAmountValidator,
+ keyboardType: TextInputType.numberWithOptions(
+ signed: false,
+ decimal: true,
+ ),
+ inputFormatters: <TextInputFormatter>[
+ FilteringTextInputFormatter.allow(
+ RegExp(r'^\d*[.,]?\d*$'),
+ ),
+ ],
+ textAlign: TextAlign.center,
+ decoration: InputDecoration(
+ isDense: true,
+ hintText: '0.00',
+ hintStyle: theme.textTheme.displayMedium?.copyWith(
+ fontWeight: FontWeight.w400,
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ style: theme.textTheme.displayMedium?.copyWith(
+ fontWeight: FontWeight.w400,
+ fontSize: 45,
+ color: theme.colorScheme.onSurface,
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ Text(
+ bridgeViewModel.selectedToken?.title ?? '',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.displayMedium?.copyWith(
+ fontWeight: FontWeight.w400,
+ fontSize: 45,
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ ),
+ const SizedBox(height: 12),
+ Center(
+ child: Text(
+ bridgeViewModel.fiatAmountFormatted.isEmpty
+ ? ''
+ : '~${bridgeViewModel.fiatAmountFormatted} ${bridgeViewModel.fiatCurrencyTitle}',
+ style: theme.textTheme.headlineSmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ fontWeight: FontWeight.w600,
+ fontSize: 22,
+ letterSpacing: -0.11,
+ ),
+ ),
+ ),
+ if (bridgeViewModel.amountError != null) ...[
+ const SizedBox(height: 10),
+ Text(
+ bridgeViewModel.amountError!,
+ textAlign: TextAlign.center,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.error,
+ ),
+ ),
+ ],
+ const Spacer(flex: 3),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Expanded(
+ child: RichText(
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ text: TextSpan(
+ children: [
+ TextSpan(
+ text: 'Available ',
+ style: theme.textTheme.bodyLarge?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ fontWeight: FontWeight.w400,
+ fontSize: 16,
+ letterSpacing: -0.08,
+ ),
+ ),
+ TextSpan(
+ text: '${bridgeViewModel.tokenBalanceFormatted} '
+ '${bridgeViewModel.selectedToken?.title ?? ''}',
+ style: theme.textTheme.bodyLarge?.copyWith(
+ color: theme.colorScheme.onSurface,
+ fontWeight: FontWeight.w400,
+ fontSize: 16,
+ letterSpacing: -0.08,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(width: 10),
+ TextButton(
+ style: TextButton.styleFrom(
+ textStyle: theme.textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w400,
+ fontSize: 14,
+ letterSpacing: -0.07,
+ ),
+ foregroundColor: theme.colorScheme.primary,
+ backgroundColor: theme.colorScheme.surfaceContainer,
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(80),
+ ),
+ ),
+ onPressed: () {
+ bridgeViewModel.setMaxAmount();
+ _amountController.text = bridgeViewModel.amount;
+ _amountController.selection = TextSelection.collapsed(
+ offset: _amountController.text.length,
+ );
+ },
+ child: Text(S.of(context).max),
+ ),
+ const SizedBox(width: 10),
+ IgnorePointer(
+ ignoring: !canNext,
+ child: ModernButton(
+ size: 48,
+ backgroundColor: theme.colorScheme.primary,
+ iconColor: theme.colorScheme.onPrimary,
+ icon: Icon(Icons.arrow_forward, size: 25),
+ onPressed: () {
+ _amountController.clear();
+ Navigator.pushNamed(
+ context, Routes.bridgeDestinationNetworkPage, arguments: bridgeViewModel);
+ },
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ },
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
diff --git a/lib/new-ui/pages/bridge/bridge_confirm_sheet.dart b/lib/new-ui/pages/bridge/bridge_confirm_sheet.dart
new file mode 100644
index 0000000..211e257
--- /dev/null
+++ b/lib/new-ui/pages/bridge/bridge_confirm_sheet.dart
@@ -0,0 +1,227 @@
+import 'package:cake_wallet/generated/i18n.dart';
+import 'package:cake_wallet/new-ui/widgets/bridge/confirm_details_card.dart';
+import 'package:cake_wallet/new-ui/widgets/bridge/network_path_pill.dart';
+import 'package:cake_wallet/new-ui/widgets/confirm_swiper.dart';
+import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
+import 'package:cake_wallet/new-ui/widgets/send_page/send_confirm_bottom_widget.dart';
+import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
+import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_mobx/flutter_mobx.dart';
+import 'package:mobx/mobx.dart';
+
+class BridgeConfirmSheet extends StatefulWidget {
+ const BridgeConfirmSheet(this.bridgeViewModel);
+
+ final BridgeViewModel bridgeViewModel;
+
+ @override
+ State<BridgeConfirmSheet> createState() => _BridgeConfirmSheetState();
+}
+
+class _BridgeConfirmSheetState extends State<BridgeConfirmSheet> {
+ late final ReactionDisposer _successDisposer;
+
+ BridgeViewModel get bridgeViewModel => widget.bridgeViewModel;
+
+ @override
+ void initState() {
+ super.initState();
+ _successDisposer = reaction(
+ (_) => bridgeViewModel.bridgeSuccess,
+ (bool success) {
+ if (success && mounted) {
+ Navigator.of(context).maybePop();
+ }
+ },
+ );
+
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ bridgeViewModel.loadQuote();
+ bridgeViewModel.ensureFiatPriceForSelectedToken();
+ bridgeViewModel.ensureFiatPriceForNativeCurrency();
+ });
+ }
+
+ @override
+ void dispose() {
+ _successDisposer();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final scheme = Theme.of(context).colorScheme;
+
+ return Container(
+ decoration: BoxDecoration(
+ color: scheme.surface,
+ borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
+ ),
+ child: SafeArea(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Observer(
+ builder: (_) {
+ return ModalTopBar(
+ title: '',
+ leadingWidget: Row(
+ spacing: 8,
+ children: [
+ CakeImageWidget(
+ imageUrl: bridgeViewModel.selectedToken?.iconPath ?? '',
+ width: 36,
+ height: 36,
+ ),
+ Text(
+ 'Bridge',
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.w500,
+ fontSize: 20,
+ letterSpacing: -0.1,
+ color: scheme.onSurface,
+ ),
+ ),
+ ],
+ ),
+ trailingIcon: const Icon(Icons.close),
+ onTrailingPressed: () => Navigator.of(context).maybePop(),
+ );
+ },
+ ),
+ SingleChildScrollView(
+ padding: const EdgeInsets.symmetric(horizontal: 24),
+ child: Observer(
+ builder: (_) {
+ if (bridgeViewModel.isQuoteLoading && bridgeViewModel.quote == null) {
+ return const Padding(
+ padding: EdgeInsets.symmetric(vertical: 48),
+ child: Center(
+ child: CircularProgressIndicator(),
+ ),
+ );
+ }
+
+ if (bridgeViewModel.quoteError != null) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 24),
+ child: Column(
+ children: [
+ Text(
+ bridgeViewModel.quoteError!,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w400,
+ fontSize: 14,
+ letterSpacing: -0.07,
+ color: scheme.error,
+ ),
+ ),
+ const SizedBox(height: 16),
+ FilledButton(
+ onPressed: () => bridgeViewModel.loadQuote(),
+ child: Text(
+ S.of(context).try_again,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w500,
+ fontSize: 16,
+ letterSpacing: -0.08,
+ color: scheme.onPrimary,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ spacing: 24,
+ children: [
+ Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ spacing: 4,
+ children: [
+ Text(
+ bridgeViewModel.amountDisplayFormatted,
+ style: Theme.of(context).textTheme.displaySmall?.copyWith(
+ fontSize: 36,
+ fontWeight: FontWeight.w400,
+ color: scheme.onSurface,
+ ),
+ ),
+ Text(
+ bridgeViewModel.selectedToken?.title ?? '',
+ style: Theme.of(context).textTheme.displaySmall?.copyWith(
+ fontSize: 36,
+ fontWeight: FontWeight.w400,
+ color: scheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 4),
+ Text(
+ '${bridgeViewModel.fiatCurrencyTitle} ${bridgeViewModel.fiatAmountFormatted}',
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontSize: 20,
+ fontWeight: FontWeight.w500,
+ color: scheme.onSurfaceVariant,
+ letterSpacing: -0.1,
+ ),
+ ),
+ ],
+ ),
+ NetworkPathPill(
+ sourceChainName: bridgeViewModel.wallet.type.name,
+ destChainName: bridgeViewModel.destinationChainInfo?.name ?? '',
+ ),
+ ConfirmDetailsCard(bridgeViewModel: bridgeViewModel),
+ if (bridgeViewModel.executeError != null) ...[
+ Text(
+ bridgeViewModel.executeError!,
+ textAlign: TextAlign.center,
+ style: TextStyle(color: scheme.error),
+ ),
+ ],
+ const SizedBox(height: 8),
+ ],
+ );
+ },
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
+ child: Observer(
+ builder: (_) {
+ if (bridgeViewModel.isExecuting) {
+ return const LoadingBottomWidget(
+ text: 'Bridging...',
+ );
+ }
+ final canSwipe = bridgeViewModel.quote != null && !bridgeViewModel.isQuoteLoading;
+ return IgnorePointer(
+ ignoring: !canSwipe,
+ child: Opacity(
+ opacity: canSwipe ? 1 : 0.45,
+ child: ConfirmSwiper(
+ onConfirmed: () {
+ bridgeViewModel.executeBridge();
+ },
+ swiperText: 'Swipe to bridge',
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/new-ui/pages/bridge/bridge_detail_page.dart b/lib/new-ui/pages/bridge/bridge_detail_page.dart
new file mode 100644
index 0000000..a5076ad
--- /dev/null
+++ b/lib/new-ui/pages/bridge/bridge_detail_page.dart
@@ -0,0 +1,160 @@
+import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart';
+import 'package:cake_wallet/generated/i18n.dart';
+import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
+import 'package:cake_wallet/src/screens/trade_details/track_trade_list_item.dart';
+import 'package:cake_wallet/src/screens/transaction_details/address_list_item.dart';
+import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart';
+import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart';
+import 'package:cake_wallet/themes/core/custom_theme_colors.dart';
+import 'package:cake_wallet/utils/address_formatter.dart';
+import 'package:cake_wallet/utils/show_bar.dart';
+import 'package:cake_wallet/view_model/bridge_details_view_model.dart';
+import 'package:cw_core/generate_name.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+class BridgeDetailPage extends StatelessWidget {
+ BridgeDetailPage({super.key, required this.viewModel});
+
+ final BridgeDetailsViewModel viewModel;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+
+ return DraggableScrollableSheet(
+ expand: false,
+ initialChildSize: 0.6,
+ minChildSize: 0.25,
+ maxChildSize: 0.9,
+ snap: true,
+ snapSizes: const [0.9],
+ builder: (context, controller) {
+ return SafeArea(
+ bottom: false,
+ child: Padding(
+ padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
+ child: GestureDetector(
+ onTap: () => FocusScope.of(context).unfocus(),
+ child: Container(
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surface,
+ borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
+ ),
+ child: Column(
+ children: [
+ ModalTopBar(
+ title: "Bridge Transfer Details",
+ leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
+ onLeadingPressed: () => Navigator.of(context).pop(),
+ ),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ children: [
+ NewListSections(
+ sections: {
+ "": viewModel.items
+ .where((item) => item is! TrackTradeListItem)
+ .map((item) {
+ return ListItemRegularRow(
+ onTap: () {
+ Clipboard.setData(ClipboardData(text: item.value));
+ showBar<void>(context, S.of(context).copied_to_clipboard);
+ },
+ showArrow: false,
+ keyValue: item.title,
+ label: item.title,
+ trailingWidget: _buildTrailingWidget(item, context),
+ bottomWidget: _buildBottomWidget(item, context),
+ );
+ }).toList(),
+ },
+ ),
+ SizedBox(height: 16),
+ NewListSections(sections: {
+ "": viewModel.items
+ .where((item) => item is TrackTradeListItem)
+ .map((item) {
+ return ListItemRegularRow(
+ keyValue: "view tx on",
+ label: item.title,
+ onTap: () => (item as TrackTradeListItem).onTap(),
+ foregroundColor: Theme.of(context).colorScheme.primary,
+ trailingIconPath: "assets/new-ui/link_arrow.svg",
+ trailingIconSize: 8,
+ );
+ }).toList()
+ }),
+ ],
+ ),
+ ),
+ ),
+ SizedBox(height: MediaQuery.of(context).viewPadding.bottom)
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ }
+
+ Widget _buildBottomWidget(TransactionDetailsListItem item, BuildContext context) {
+ if (item is AddressListItem) {
+ return AddressFormatter.buildSegmentedAddress(
+ address: item.value,
+ evenTextStyle: TextStyle(
+ fontSize: 12,
+ fontFamily: "IBM Plex Mono",
+ color: Theme.of(context).colorScheme.onSurface));
+ }
+
+ final isCompleted = item.value.contains("Completed");
+ final isInitiated = item.value.contains("initiated");
+ if (isCompleted || isInitiated) {
+ return Row(
+ children: [
+ Container(
+ height: 8,
+ width: 8,
+ decoration: BoxDecoration(
+ color: isCompleted ? CustomThemeColors.syncGreen : CustomThemeColors.syncYellow,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: Theme.of(context).colorScheme.surface,
+ width: 1.5,
+ ),
+ ),
+ ),
+ SizedBox(width: 8),
+ Text(
+ item.value,
+ style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurfaceVariant),
+ ),
+ ],
+ );
+ }
+
+ return SizedBox.shrink();
+ }
+
+ Widget _buildTrailingWidget(TransactionDetailsListItem item, BuildContext context) {
+ final isCompleted = item.value.contains("Completed");
+ final isInitiated = item.value.contains("initiated");
+ if (item is AddressListItem || isCompleted || isInitiated) {
+ return SizedBox.shrink();
+ }
+
+ return Text(
+ item.value.capitalized(),
+ style: TextStyle(
+ fontWeight: FontWeight.w400,
+ fontFamily: 'Wix Madefor Text',
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ );
+ }
+}
diff --git a/lib/new-ui/pages/bridge/bridge_history_page.dart b/lib/new-ui/pages/bridge/bridge_history_page.dart
new file mode 100644
index 0000000..a50cc56
--- /dev/null
+++ b/lib/new-ui/pages/bridge/bridge_history_page.dart
@@ -0,0 +1,117 @@
+import 'package:cake_wallet/di.dart';
+import 'package:cake_wallet/new-ui/pages/bridge/bridge_detail_page.dart';
+import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
+import 'package:cake_wallet/view_model/bridge_history_view_model.dart';
+import 'package:cake_wallet/new-ui/widgets/bridge/transfer_history_row.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_mobx/flutter_mobx.dart';
+
+class BridgeHistoryPage extends StatelessWidget {
+ BridgeHistoryPage(this.viewModel);
+
+ final BridgeHistoryViewModel viewModel;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+
+ return Container(
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surface,
+ borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
+ ),
+ child: Column(
+ children: [
+ ModalTopBar(
+ title: "Bridge history",
+ leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
+ onLeadingPressed: () => Navigator.of(context).pop(),
+ ),
+ Expanded(
+ child: Observer(
+ builder: (_) {
+ if (viewModel.isEmpty) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Text(
+ "No bridge transfers yet.",
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ ),
+ );
+ }
+
+ final active = viewModel.activeTransfers;
+ final past = viewModel.pastTransfers;
+ final items = <Widget>[];
+
+ if (active.isNotEmpty) {
+ items.add(
+ Padding(
+ padding: const EdgeInsets.only(bottom: 8.0),
+ child: Text(
+ "In progress",
+ style: Theme.of(context).textTheme.titleSmall,
+ ),
+ ),
+ );
+ for (final transfer in active) {
+ items.add(
+ TransferHistoryRow(
+ transfer: transfer,
+ onTap: () {
+ final page = getIt.get<BridgeDetailPage>(param1: transfer);
+ showModalBottomSheet(
+ backgroundColor: Colors.transparent,
+ isScrollControlled: true,
+ context: context,
+ builder: (context) => page,
+ );
+ },
+ ),
+ );
+ }
+ }
+
+ if (past.isNotEmpty) {
+ items.add(
+ Padding(
+ padding: const EdgeInsets.only(bottom: 8.0, top: 16),
+ child: Text(
+ "Past",
+ style: Theme.of(context).textTheme.titleSmall,
+ ),
+ ),
+ );
+ for (final transfer in past) {
+ items.add(
+ TransferHistoryRow(
+ transfer: transfer,
+ onTap: () {
+ final page = getIt.get<BridgeDetailPage>(param1: transfer);
+ showModalBottomSheet(
+ backgroundColor: Colors.transparent,
+ isScrollControlled: true,
+ context: context,
+ builder: (context) => page,
+ );
+ },
+ ),
+ );
+ }
+ }
+
+ return ListView(
+ padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
+ children: items,
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/new-ui/pages/bridge/bridge_network_page.dart b/lib/new-ui/pages/bridge/bridge_network_page.dart
new file mode 100644
index 0000000..0c29cde
--- /dev/null
+++ b/lib/new-ui/pages/bridge/bridge_network_page.dart
@@ -0,0 +1,98 @@
+import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart';
+import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
+import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
+import 'package:cake_wallet/routes.dart';
+import 'package:cake_wallet/src/widgets/new_list_row/list_item_regular_row_widget.dart';
+import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart';
+import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
+import 'package:cw_core/generate_name.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_mobx/flutter_mobx.dart';
+
+class BridgeNetworkPage extends StatelessWidget {
+ const BridgeNetworkPage(this.bridgeViewModel);
+
+ final BridgeViewModel bridgeViewModel;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+
+ return KeyboardHideOverlay(
+ unfocusOnTap: true,
+ child: Container(
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surface,
+ borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
+ ),
+ child: Column(
+ children: [
+ ModalTopBar(
+ title: 'Destination Network',
+ leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
+ onLeadingPressed: () => Navigator.of(context).pop(),
+ ),
+ SizedBox(height: 48),
+ Padding(
+ padding: const EdgeInsets.fromLTRB(18, 8, 18, 16),
+ child: Text(
+ 'Select what Network to transfer your assets to',
+ textAlign: TextAlign.center,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ SizedBox(height: 24),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 18),
+ child: Column(
+ children: [
+ Observer(
+ builder: (_) {
+ return NewListSections(
+ sections: {
+ '': bridgeViewModel.availableDestinationChains.map(
+ (chain) {
+ final chainName = chain.name;
+ return ListItemRegularRow(
+ iconPath: 'assets/images/crypto/${chainName.toLowerCase()}.webp',
+ keyValue: chain.chainId.toString(),
+ label: chainName,
+ onTap: () {
+ bridgeViewModel.setDestinationChain(chain.chainId);
+ Navigator.pushNamed(context, Routes.bridgeReceivingWalletPage, arguments: bridgeViewModel);
+ },
+ );
+ },
+ ).toList(),
+ },
+ );
+ },
+ ),
+ SizedBox(height: 24),
+ Observer(
+ builder: (_) {
+ final src = bridgeViewModel.wallet.type.name;
+
+ return ListItemRegularRowWidget(
+ isFirstInSection: true,
+ isLastInSection: true,
+ keyValue: 'sending_from',
+ label: 'Sending from',
+ trailingIconPath: 'assets/new-ui/chain_badges/${src.toLowerCase()}.svg',
+ trailingText: src.capitalized(),
+ );
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/new-ui/pages/bridge/bridge_receive_address_input_page.dart b/lib/new-ui/pages/bridge/bridge_receive_address_input_page.dart
new file mode 100644
index 0000000..0f322ba
--- /dev/null
+++ b/lib/new-ui/pages/bridge/bridge_receive_address_input_page.dart
@@ -0,0 +1,160 @@
+import 'package:cake_wallet/core/address_validator.dart';
+import 'package:cake_wallet/generated/i18n.dart';
+import 'package:cake_wallet/new-ui/pages/bridge/bridge_confirm_sheet.dart';
+import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
+import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
+import 'package:cake_wallet/new-ui/widgets/send_page/send_address_input.dart';
+import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
+import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_mobx/flutter_mobx.dart';
+import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
+
+class BridgeReceiveAddressInputPage extends StatefulWidget {
+ const BridgeReceiveAddressInputPage({super.key, required this.bridgeViewModel});
+
+ final BridgeViewModel bridgeViewModel;
+
+ @override
+ State<BridgeReceiveAddressInputPage> createState() => _BridgeReceiveAddressInputPageState();
+}
+
+class _BridgeReceiveAddressInputPageState extends State<BridgeReceiveAddressInputPage> {
+ BridgeViewModel get bridgeViewModel => widget.bridgeViewModel;
+
+ late final TextEditingController _controller;
+ final FocusNode _focusNode = FocusNode();
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = TextEditingController();
+ _controller.addListener(() => setState(() {}));
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ _focusNode.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+
+ return KeyboardHideOverlay(
+ unfocusOnTap: true,
+ child: Container(
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surface,
+ borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
+ ),
+ child: Column(
+ children: [
+ ModalTopBar(
+ title: 'Input Receive Address',
+ leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
+ onLeadingPressed: () => Navigator.of(context).pop(),
+ ),
+ SizedBox(height: 48),
+ Expanded(
+ child: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 18),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Observer(
+ builder: (_) {
+ final chainName = bridgeViewModel.destinationChainInfo?.name;
+
+ return Column(
+ children: [
+ Text(
+ 'Make sure it is compatible with:',
+ textAlign: TextAlign.center,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ fontSize: 14,
+ fontWeight: FontWeight.w400,
+ letterSpacing: -0.07,
+ ),
+ ),
+ const SizedBox(height: 12),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ CakeImageWidget(
+ imageUrl:
+ 'assets/new-ui/chain_badges/${chainName?.toLowerCase()}.svg',
+ width: 24,
+ height: 24,
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(width: 4),
+ Text(
+ chainName ?? '',
+ style: theme.textTheme.bodyMedium?.copyWith(
+ fontSize: 14,
+ color: theme.colorScheme.onSurfaceVariant,
+ fontWeight: FontWeight.w400,
+ letterSpacing: -0.07,
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ },
+ ),
+ const SizedBox(height: 24),
+ NewSendAddressInput(
+ hintText: 'Enter Address',
+ addressController: _controller,
+ focusNode: _focusNode,
+ selectedCurrency: bridgeViewModel.wallet.currency,
+ onEditingComplete: () {},
+ validator: AddressValidator(type: bridgeViewModel.destinationChainInfo!.currency),
+ ),
+ const Spacer(),
+ Observer(
+ builder: (_) {
+ return FilledButton(
+ onPressed: () {
+ final overlayCtx = Navigator.of(context).overlay?.context;
+ bridgeViewModel.setRecipientAddress(_controller.text.trim());
+
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (overlayCtx != null && overlayCtx.mounted) {
+ showMaterialModalBottomSheet<void>(
+ context: overlayCtx,
+ backgroundColor: Colors.transparent,
+ builder: (ctx) =>
+ BridgeConfirmSheet(bridgeViewModel),
+ );
+ }
+ });
+ },
+ style: FilledButton.styleFrom(
+ padding: const EdgeInsets.all(16),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(18),
+ ),
+ ),
+ child: Text(S.of(context).continue_text),
+ );
+ },
+ ),
+ const SizedBox(height: 12),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/new-ui/pages/bridge/bridge_receiving_wallet_page.dart b/lib/new-ui/pages/bridge/bridge_receiving_wallet_page.dart
new file mode 100644
index 0000000..741763c
--- /dev/null
+++ b/lib/new-ui/pages/bridge/bridge_receiving_wallet_page.dart
@@ -0,0 +1,264 @@
+import 'package:cake_wallet/view_model/bridge/bridge_receiving_wallet_option.dart';
+import 'package:cake_wallet/new-ui/pages/bridge/bridge_confirm_sheet.dart';
+import 'package:cake_wallet/new-ui/pages/bridge/bridge_receive_address_input_page.dart';
+import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
+import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
+import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
+import 'package:cake_wallet/src/widgets/new_list_row/list_Item_style_wrapper.dart';
+import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_mobx/flutter_mobx.dart';
+import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
+
+class BridgeReceivingWalletPage extends StatefulWidget {
+ const BridgeReceivingWalletPage(this.bridgeViewModel);
+
+ final BridgeViewModel bridgeViewModel;
+
+ @override
+ State<BridgeReceivingWalletPage> createState() => _BridgeReceivingWalletPageState();
+}
+
+class _BridgeReceivingWalletPageState extends State<BridgeReceivingWalletPage> {
+ BridgeViewModel get bridgeViewModel => widget.bridgeViewModel;
+
+ @override
+ void initState() {
+ super.initState();
+ bridgeViewModel.loadReceivingWalletOptions();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return KeyboardHideOverlay(
+ unfocusOnTap: true,
+ child: Container(
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surface,
+ borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
+ ),
+ child: Column(
+ children: [
+ ModalTopBar(
+ title: 'Receiving Wallet',
+ leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18),
+ onLeadingPressed: () => Navigator.of(context).pop(),
+ ),
+ const SizedBox(height: 48),
+ Expanded(
+ child: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 18),
+ child: Observer(
+ builder: (_) {
+ final chain = bridgeViewModel.destinationChainInfo;
+
+ final chainName = chain?.name ?? '';
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const SizedBox(height: 8),
+ Center(
+ child: CakeImageWidget(
+ borderRadius: 8,
+ imageUrl: chainName.isNotEmpty
+ ? 'assets/images/crypto/${chainName.toLowerCase()}.webp'
+ : null,
+ width: 72,
+ height: 72,
+ ),
+ ),
+ const SizedBox(height: 20),
+ Text(
+ 'Select a $chainName wallet or address to send '
+ 'the bridged USDT0 to.',
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w400,
+ fontSize: 14,
+ letterSpacing: -0.07,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 24),
+ if (bridgeViewModel.isBridgeReceivingWalletListLoading)
+ const Expanded(
+ child: Center(
+ child: CircularProgressIndicator(),
+ ),
+ )
+ else
+ Expanded(
+ child: ListView(
+ padding: const EdgeInsets.only(bottom: 16),
+ children: _receivingListRows(context),
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ List<Widget> _receivingListRows(BuildContext context) {
+ final options = bridgeViewModel.bridgeReceivingWalletOptions;
+ final rows = <Widget>[];
+
+ for (int i = 0; i < options.length; i++) {
+ final option = options[i];
+
+ if (i > 0) rows.add(const SizedBox(height: 12));
+
+ rows.add(
+ _BridgeReceivingWalletRow(
+ option: option,
+ onTap: () {
+ bridgeViewModel.setRecipientAddress(option.address, destWalletName: option.name);
+
+ showMaterialModalBottomSheet<void>(
+ context: context,
+ backgroundColor: Colors.transparent,
+ builder: (ctx) => BridgeConfirmSheet(bridgeViewModel),
+ );
+ },
+ ),
+ );
+ }
+
+ if (options.isNotEmpty) rows.add(const SizedBox(height: 12));
+
+ rows.add(
+ ListItemStyleWrapper(
+ isFirstInSection: true,
+ isLastInSection: true,
+ height: 48,
+ contentPadding: const EdgeInsets.all(12),
+ onTap: () {
+ showMaterialModalBottomSheet(
+ context: context,
+ backgroundColor: Colors.transparent,
+ builder: (ctx) => BridgeReceiveAddressInputPage(bridgeViewModel: bridgeViewModel),
+ );
+ },
+ builder: (ctx, textStyle, _) {
+ final scheme = Theme.of(ctx).colorScheme;
+ return Row(
+ children: [
+ Icon(
+ Icons.edit_outlined,
+ size: 24,
+ color: scheme.primary,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ 'Input an Address',
+ style: textStyle.copyWith(
+ color: scheme.primary,
+ fontSize: 15,
+ fontWeight: FontWeight.w500,
+ letterSpacing: -0.3,
+ ),
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ );
+
+ return rows;
+ }
+}
+
+class _BridgeReceivingWalletRow extends StatelessWidget {
+ const _BridgeReceivingWalletRow({
+ required this.option,
+ required this.onTap,
+ });
+
+ final BridgeReceivingWalletOption option;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final hasGroupRow = option.groupLabel != null;
+
+ return ListItemStyleWrapper(
+ onTap: onTap,
+ isFirstInSection: true,
+ isLastInSection: true,
+ height: hasGroupRow ? 62.0 : 48.0,
+ builder: (ctx, textStyle, labelStyle) {
+ return Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Expanded(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ option.name,
+ style: textStyle.copyWith(letterSpacing: -0.07),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ if (hasGroupRow) ...[
+ const SizedBox(height: 4),
+ Row(
+ children: [
+ Icon(
+ Icons.account_balance_wallet_outlined,
+ size: 16,
+ color: Theme.of(ctx).colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(width: 6),
+ Expanded(
+ child: Text(
+ option.groupLabel!,
+ style: labelStyle.copyWith(
+ fontSize: 12,
+ letterSpacing: -0.06,
+ color: Theme.of(ctx).colorScheme.onSurfaceVariant,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ if (option.isCurrent) ...[
+ const SizedBox(width: 8),
+ Text(
+ 'Current',
+ style: textStyle.copyWith(
+ fontSize: 12,
+ color: Theme.of(ctx).colorScheme.primary,
+ letterSpacing: -0.06,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ],
+ ],
+ ),
+ ),
+ CakeImageWidget(
+ imageUrl: 'assets/new-ui/arrow_forward.svg',
+ height: 16,
+ color: Theme.of(ctx).colorScheme.onSurfaceVariant,
+ ),
+ ],
+ );
+ },
+ );
+ }
+}
diff --git a/lib/new-ui/widgets/bridge/confirm_details_card.dart b/lib/new-ui/widgets/bridge/confirm_details_card.dart
new file mode 100644
index 0000000..2ef33f0
--- /dev/null
+++ b/lib/new-ui/widgets/bridge/confirm_details_card.dart
@@ -0,0 +1,62 @@
+import 'package:cake_wallet/generated/i18n.dart';
+import 'package:cake_wallet/new-ui/widgets/bridge/expandable_details_card.dart';
+import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_mobx/flutter_mobx.dart';
+
+class ConfirmDetailsCard extends StatelessWidget {
+ const ConfirmDetailsCard({required this.bridgeViewModel});
+
+ final BridgeViewModel bridgeViewModel;
+
+ @override
+ Widget build(BuildContext context) {
+ final scheme = Theme.of(context).colorScheme;
+
+ return Observer(
+ builder: (_) {
+ final feeParts = <String>[
+ bridgeViewModel.quoteNativeFeeFormattedForDisplay,
+ bridgeViewModel.quoteNativeFiatFeeFormattedForDisplay,
+ ];
+ final feeLine = feeParts.join(' ');
+
+ return Container(
+ decoration: BoxDecoration(
+ color: scheme.surfaceContainer,
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Column(
+ children: [
+ ExpandableBridgeDetailRow(
+ label: S.of(context).from,
+ title: bridgeViewModel.wallet.name,
+ address: bridgeViewModel.sourceAddress,
+ showChevron: true,
+ ),
+ Divider(
+ height: 1,
+ color: scheme.surfaceContainerHigh,
+ ),
+ ExpandableBridgeDetailRow(
+ label: S.of(context).to,
+ title: bridgeViewModel.destinationWalletName ?? bridgeViewModel.recipientAddress.trim(),
+ address: bridgeViewModel.recipientAddress.trim(),
+ showChevron: bridgeViewModel.destinationWalletName != null,
+ ),
+ Divider(
+ height: 1,
+ color: scheme.surfaceContainerHigh,
+ ),
+ ExpandableBridgeDetailRow(
+ label: S.of(context).fee,
+ title: feeLine,
+ showChevron: false,
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/lib/new-ui/widgets/bridge/expandable_details_card.dart b/lib/new-ui/widgets/bridge/expandable_details_card.dart
new file mode 100644
index 0000000..22e81be
--- /dev/null
+++ b/lib/new-ui/widgets/bridge/expandable_details_card.dart
@@ -0,0 +1,113 @@
+import 'package:flutter/material.dart';
+
+class ExpandableBridgeDetailRow extends StatefulWidget {
+ const ExpandableBridgeDetailRow({
+ required this.label,
+ required this.title,
+ this.address,
+ this.showChevron = false,
+ });
+
+ final String label;
+ final String title;
+ final String? address;
+ final bool showChevron;
+
+ @override
+ State<ExpandableBridgeDetailRow> createState() => ExpandableBridgeDetailRowState();
+}
+
+class ExpandableBridgeDetailRowState extends State<ExpandableBridgeDetailRow> {
+ bool _expanded = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final scheme = Theme.of(context).colorScheme;
+ final showChevron = widget.showChevron;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ InkWell(
+ onTap: showChevron ? () => setState(() => _expanded = !_expanded) : null,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ widget.label,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: scheme.onSurface,
+ fontWeight: FontWeight.w400,
+ fontSize: 14,
+ letterSpacing: -0.07,
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Flexible(
+ child: Text(
+ widget.title,
+ textAlign: TextAlign.end,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w400,
+ fontSize: 14,
+ letterSpacing: -0.07,
+ color: scheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ if (showChevron) ...[
+ const SizedBox(width: 2),
+ AnimatedRotation(
+ turns: _expanded ? 0.5 : 0,
+ duration: const Duration(milliseconds: 200),
+ curve: Curves.easeOutCubic,
+ child: Icon(
+ Icons.keyboard_arrow_down,
+ size: 22,
+ color: scheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ AnimatedSize(
+ duration: const Duration(milliseconds: 200),
+ curve: Curves.easeOutCubic,
+ alignment: Alignment.topCenter,
+ child: showChevron && _expanded
+ ? Padding(
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
+ child: Align(
+ alignment: Alignment.centerRight,
+ child: SelectableText(
+ widget.address ?? '',
+ textAlign: TextAlign.end,
+ style: TextStyle(
+ fontSize: 13,
+ height: 1.35,
+ color: scheme.onSurfaceVariant,
+ fontWeight: FontWeight.w400,
+ ),
+ ),
+ ),
+ )
+ : const SizedBox.shrink(),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/new-ui/widgets/bridge/network_path_pill.dart b/lib/new-ui/widgets/bridge/network_path_pill.dart
new file mode 100644
index 0000000..762f5e2
--- /dev/null
+++ b/lib/new-ui/widgets/bridge/network_path_pill.dart
@@ -0,0 +1,89 @@
+import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
+import 'package:cw_core/generate_name.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_mobx/flutter_mobx.dart';
+
+class NetworkPathPill extends StatelessWidget {
+ const NetworkPathPill({
+ required this.sourceChainName,
+ required this.destChainName,
+ this.showBackground = true,
+ });
+
+ final String sourceChainName;
+ final String destChainName;
+ final bool showBackground;
+
+ @override
+ Widget build(BuildContext context) {
+ final scheme = Theme.of(context).colorScheme;
+ final body = Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ CakeImageWidget(
+ imageUrl: 'assets/new-ui/chain_badges/${sourceChainName.toLowerCase()}.svg',
+ width: 24,
+ height: 24,
+ color: scheme.onSurfaceVariant,
+ ),
+ const SizedBox(width: 8),
+ Flexible(
+ child: Text(
+ sourceChainName.capitalized(),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(
+ color: scheme.onSurface,
+ fontWeight: FontWeight.w400,
+ fontSize: 16,
+ letterSpacing: -0.08,
+ ),
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ child: Icon(
+ Icons.arrow_forward,
+ size: 16,
+ color: scheme.primary,
+ ),
+ ),
+ CakeImageWidget(
+ imageUrl: 'assets/new-ui/chain_badges/${destChainName.toLowerCase()}.svg',
+ width: 24,
+ height: 24,
+ color: scheme.onSurfaceVariant,
+ ),
+ const SizedBox(width: 8),
+ Flexible(
+ child: Text(
+ destChainName,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(
+ color: scheme.onSurface,
+ fontWeight: FontWeight.w400,
+ fontSize: 16,
+ letterSpacing: -0.08,
+ ),
+ ),
+ ),
+ ],
+ );
+ return Observer(
+ builder: (_) {
+ return showBackground
+ ? Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ decoration: BoxDecoration(
+ color: scheme.surfaceContainer,
+ borderRadius: BorderRadius.circular(80),
+ ),
+ child: body,
+ )
+ : body;
+ },
+ );
+ }
+}
diff --git a/lib/new-ui/widgets/bridge/transfer_history_row.dart b/lib/new-ui/widgets/bridge/transfer_history_row.dart
new file mode 100644
index 0000000..6a9b3e7
--- /dev/null
+++ b/lib/new-ui/widgets/bridge/transfer_history_row.dart
@@ -0,0 +1,180 @@
+import 'package:cake_wallet/entities/bridge_transfer.dart';
+import 'package:cake_wallet/evm/evm.dart';
+import 'package:cake_wallet/src/widgets/cake_image_widget.dart';
+import 'package:cake_wallet/themes/core/custom_theme_colors.dart';
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+
+class TransferHistoryRow extends StatelessWidget {
+ const TransferHistoryRow({
+ required this.transfer,
+ required this.onTap,
+ super.key,
+ });
+
+ final BridgeTransfer transfer;
+ final VoidCallback onTap;
+
+ String _statusLabel(String status) {
+ switch (status) {
+ case 'submitted':
+ return "Submitted";
+ case 'confirming':
+ return "Confirming on source";
+ case 'initiated':
+ return "Bridge initiated";
+ case 'completed':
+ return "Completed";
+ case 'failed':
+ return "Failed";
+ default:
+ return status;
+ }
+ }
+
+ Color _statusColor(BuildContext context, String status) {
+ switch (status) {
+ case 'completed':
+ return CustomThemeColors.syncGreen;
+ case 'failed':
+ return Theme.of(context).colorScheme.error;
+ case 'submitted':
+ case 'confirming':
+ case 'initiated':
+ default:
+ return CustomThemeColors.syncYellow;
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final sourceChainName = evm?.getChainInfoByChainId(transfer.sourceChainId)?.name;
+ final destChainName = evm?.getChainInfoByChainId(transfer.destinationChainId)?.name;
+ final formattedDate = DateFormat('HH:mm').format(transfer.createdAt);
+ final statusText = _statusLabel(transfer.status);
+
+ return InkWell(
+ onTap: onTap,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16.0),
+ margin: const EdgeInsets.only(bottom: 16),
+ width: double.infinity,
+ height: 72,
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ colors: [
+ Theme.of(context).colorScheme.surfaceContainerHigh,
+ Theme.of(context).colorScheme.surfaceContainer,
+ ],
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ ),
+ borderRadius: BorderRadius.all(Radius.circular(20)),
+ border: Border.all(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ width: 1,
+ ),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.max,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Container(
+ height: 42,
+ width: 42,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Stack(
+ children: [
+ CakeImageWidget(
+ imageUrl: 'assets/images/crypto/${sourceChainName?.toLowerCase()}.webp',
+ width: 28,
+ height: 28,
+ ),
+ Positioned(
+ top: 14,
+ left: 12,
+ child: CakeImageWidget(
+ imageUrl: 'assets/images/crypto/${destChainName?.toLowerCase()}.webp',
+ width: 28,
+ height: 28,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: <Widget>[
+ Text(
+ '$sourceChainName → $destChainName',
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(
+ fontSize: 16,
+ fontWeight: FontWeight.w500,
+ color: Theme.of(context).colorScheme.onSurface,
+ ),
+ ),
+ Text(
+ '${transfer.amount} ${transfer.tokenSymbol}',
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(
+ fontSize: 16,
+ fontWeight: FontWeight.w500,
+ color: Theme.of(context).colorScheme.onSurface,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: <Widget>[
+ Row(
+ children: [
+ Container(
+ height: 8,
+ width: 8,
+ decoration: BoxDecoration(
+ color: _statusColor(context, transfer.status),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: Theme.of(context).colorScheme.surface,
+ width: 1.5,
+ ),
+ ),
+ ),
+ SizedBox(width: 8),
+ Text(
+ statusText,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w500,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ overflow: TextOverflow.ellipsis,
+ ),
+ ],
+ ),
+ Text(
+ formattedDate,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w500,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/new-ui/widgets/coins_page/assets_history/asset_details_modal.dart b/lib/new-ui/widgets/coins_page/assets_history/asset_details_modal.dart
index 77fca71..154d574 100644
--- a/lib/new-ui/widgets/coins_page/assets_history/asset_details_modal.dart
+++ b/lib/new-ui/widgets/coins_page/assets_history/asset_details_modal.dart
@@ -2,6 +2,7 @@ import 'package:cake_wallet/bitcoin/bitcoin.dart';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/modal_navigator.dart';
+import 'package:cake_wallet/new-ui/pages/bridge/bridge_amount_page.dart';
import 'package:cake_wallet/new-ui/pages/receive_page.dart';
import 'package:cake_wallet/new-ui/pages/send_page.dart';
import 'package:cake_wallet/new-ui/pages/swap_page.dart';
@@ -31,6 +32,7 @@ class AssetDetailsModal extends StatelessWidget {
required this.mode,
required this.wallet,
required this.showSwap,
+ required this.showBridgeButton,
this.asset});
final String title;
@@ -44,6 +46,7 @@ class AssetDetailsModal extends StatelessWidget {
final String chainIconPath;
final WalletBase wallet;
final bool showSwap;
+ final bool showBridgeButton;
final AssetDetailsModalModes mode;
@override
@@ -59,7 +62,7 @@ class AssetDetailsModal extends StatelessWidget {
title: "",
trailingIcon: Icon(Icons.close),
onTrailingPressed: Navigator.of(context).pop,
- padding: EdgeInsets.only(top:12,right:18),
+ padding: EdgeInsets.only(top: 12, right: 18),
),
SafeArea(
child: Column(
@@ -125,21 +128,20 @@ class AssetDetailsModal extends StatelessWidget {
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface),
),
- if(asset != null)
- Container(
- decoration: BoxDecoration(
- color: Theme.of(context).colorScheme.surfaceContainer,
- borderRadius: BorderRadius.circular(999999999)),
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4),
-
- child: Text(
- asset?.title??"",
- style: TextStyle(
- color: Theme.of(context).colorScheme.onSurfaceVariant),
+ if (asset != null)
+ Container(
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surfaceContainer,
+ borderRadius: BorderRadius.circular(999999999)),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4),
+ child: Text(
+ asset?.title ?? "",
+ style: TextStyle(
+ color: Theme.of(context).colorScheme.onSurfaceVariant),
+ ),
),
- ),
- )
+ )
],
),
if (subtitle.isNotEmpty)
@@ -147,11 +149,15 @@ class AssetDetailsModal extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
spacing: 4,
children: [
- if(chainIconPath.isNotEmpty)
- CakeImageWidget(
- imageUrl: chainIconPath,
- width:16,height:16,colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.onSurfaceVariant,BlendMode.srcIn),
- ),
+ if (chainIconPath.isNotEmpty)
+ CakeImageWidget(
+ imageUrl: chainIconPath,
+ width: 16,
+ height: 16,
+ colorFilter: ColorFilter.mode(
+ Theme.of(context).colorScheme.onSurfaceVariant,
+ BlendMode.srcIn),
+ ),
Text(
subtitle,
style: TextStyle(
@@ -227,6 +233,18 @@ class AssetDetailsModal extends StatelessWidget {
),
),
),
+ if (showSwap && mode != AssetDetailsModalModes.ltcPrivate)
+ AssetDetailsModalBottomButton(
+ iconPath: "assets/new-ui/exchange.svg",
+ title: S.of(context).swap,
+ onPressed: () => openPage<NewSwapPage>(context, param2: asset),
+ ),
+ if (showBridgeButton)
+ AssetDetailsModalBottomButton(
+ iconPath: "assets/new-ui/bridge.svg",
+ title: "Bridge",
+ onPressed: () => openPage<BridgeAmountPage>(context, param1: asset),
+ ),
AssetDetailsModalBottomButton(
iconPath: "assets/new-ui/receive.svg",
title: S.of(context).receive,
@@ -240,12 +258,6 @@ class AssetDetailsModal extends StatelessWidget {
openPage<NewReceivePage>(context, param2: asset);
},
),
- if (showSwap && mode != AssetDetailsModalModes.ltcPrivate)
- AssetDetailsModalBottomButton(
- iconPath: "assets/new-ui/exchange.svg",
- title: S.of(context).swap,
- onPressed: () => openPage<NewSwapPage>(context, param2: asset),
- ),
],
),
SizedBox()
diff --git a/lib/new-ui/widgets/coins_page/assets_history/asset_tile.dart b/lib/new-ui/widgets/coins_page/assets_history/asset_tile.dart
index 170672c..1e01808 100644
--- a/lib/new-ui/widgets/coins_page/assets_history/asset_tile.dart
+++ b/lib/new-ui/widgets/coins_page/assets_history/asset_tile.dart
@@ -14,6 +14,7 @@ class AssetTile extends StatelessWidget {
required this.balance,
required this.chainIconPath,
this.showSecondary = false,
+ this.showBridgeButton = false,
this.title,
this.trailingText,
this.modalMode = AssetDetailsModalModes.normal,
@@ -22,6 +23,7 @@ class AssetTile extends StatelessWidget {
final BalanceRecord balance;
final bool showSecondary;
final bool showSwap;
+ final bool showBridgeButton;
final String chainIconPath;
final String? title;
final String? trailingText;
@@ -42,6 +44,7 @@ class AssetTile extends StatelessWidget {
builder: (context) {
return AssetDetailsModal(
showSwap: showSwap,
+ showBridgeButton: showBridgeButton,
asset: balance.asset,
title: title ?? balance.asset.fullName ?? balance.asset.name,
chainTitle: "",
diff --git a/lib/new-ui/widgets/coins_page/assets_history/assets_section.dart b/lib/new-ui/widgets/coins_page/assets_history/assets_section.dart
index 6bd2661..e55a3c3 100644
--- a/lib/new-ui/widgets/coins_page/assets_history/assets_section.dart
+++ b/lib/new-ui/widgets/coins_page/assets_history/assets_section.dart
@@ -59,6 +59,7 @@ class AssetsSection extends StatelessWidget {
final balance = dashboardViewModel.balanceViewModel.formattedBalances.elementAt(index);
return AssetTile(
showSwap: dashboardViewModel.isEnabledSwapAction,
+ showBridgeButton: dashboardViewModel.showBridge(balance.asset),
balance: balance,
wallet: dashboardViewModel.wallet,
isFirst: index == 0,
diff --git a/lib/new-ui/widgets/send_page/send_address_input.dart b/lib/new-ui/widgets/send_page/send_address_input.dart
index 405777c..feb0eae 100644
--- a/lib/new-ui/widgets/send_page/send_address_input.dart
+++ b/lib/new-ui/widgets/send_page/send_address_input.dart
@@ -23,6 +23,7 @@ class NewSendAddressInput extends StatefulWidget {
this.validator,
this.focusNode,
this.displayName,
+ this.hintText,
});
final TextEditingController addressController;
@@ -36,7 +37,8 @@ class NewSendAddressInput extends StatefulWidget {
final bool bottomPadding;
final FormFieldValidator<String>? validator;
final FocusNode? focusNode;
-
+ final String? hintText;
+
@override
State<NewSendAddressInput> createState() => _NewSendAddressInputState();
}
@@ -96,7 +98,7 @@ class _NewSendAddressInputState extends State<NewSendAddressInput> {
},
controller: widget.addressController,
decoration: InputDecoration(
- hintText: S.of(context).search_or_enter,
+ hintText: widget.hintText ?? S.of(context).search_or_enter,
errorMaxLines: 3,
),
),
diff --git a/lib/router.dart b/lib/router.dart
index 6adfd31..7af4f85 100644
--- a/lib/router.dart
+++ b/lib/router.dart
@@ -4,6 +4,10 @@ import 'package:cake_wallet/anonpay/anonpay_invoice_info.dart';
import 'package:cake_wallet/core/new_wallet_arguments.dart';
import 'package:cake_wallet/new-ui/new_dashboard.dart';
import 'package:cake_wallet/new-ui/pages/about_page.dart';
+import 'package:cake_wallet/new-ui/pages/bridge/bridge_confirm_sheet.dart';
+import 'package:cake_wallet/new-ui/pages/bridge/bridge_history_page.dart';
+import 'package:cake_wallet/new-ui/pages/bridge/bridge_network_page.dart';
+import 'package:cake_wallet/new-ui/pages/bridge/bridge_receiving_wallet_page.dart';
import 'package:cake_wallet/new-ui/pages/coin_control_page.dart';
import 'package:cake_wallet/new-ui/pages/addresses_page.dart';
import 'package:cake_wallet/new-ui/pages/lightning_username_page.dart';
@@ -89,7 +93,6 @@ import 'package:cake_wallet/src/screens/restore/wallet_restore_page.dart';
import 'package:cake_wallet/src/screens/seed/pre_seed_page.dart';
import 'package:cake_wallet/src/screens/seed/seed_verification/seed_verification_page.dart';
import 'package:cake_wallet/src/screens/seed/wallet_seed_page.dart';
-import 'package:cake_wallet/src/screens/send/send_page.dart';
import 'package:cake_wallet/src/screens/send/send_template_page.dart';
import 'package:cake_wallet/src/screens/send/transaction_success_info_page.dart';
import 'package:cake_wallet/src/screens/settings/background_sync_page.dart';
@@ -136,6 +139,8 @@ import 'package:cake_wallet/src/screens/welcome/welcome_page.dart';
import 'package:cake_wallet/store/settings_store.dart';
import 'package:cake_wallet/utils/payment_request.dart';
import 'package:cake_wallet/view_model/advanced_privacy_settings_view_model.dart';
+import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
+import 'package:cake_wallet/view_model/bridge_history_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/sign_view_model.dart';
@@ -1035,6 +1040,21 @@ Route<dynamic> createRoute(RouteSettings settings) {
builder: (_) => getIt.get<DEuroSavingsPage>(),
);
+ case Routes.bridgeHistoryPage:
+ return handleRouteWithPlatformAwareness(
+ (context) => BridgeHistoryPage(settings.arguments as BridgeHistoryViewModel),
+ );
+
+ case Routes.bridgeDestinationNetworkPage:
+ return handleRouteWithPlatformAwareness(
+ (context) => BridgeNetworkPage(settings.arguments as BridgeViewModel),
+ );
+
+ case Routes.bridgeReceivingWalletPage:
+ return handleRouteWithPlatformAwareness(
+ (context) => BridgeReceivingWalletPage(settings.arguments as BridgeViewModel),
+ );
+
default:
return MaterialPageRoute<void>(
builder: (_) => Scaffold(
diff --git a/lib/routes.dart b/lib/routes.dart
index f61c355..96ac826 100644
--- a/lib/routes.dart
+++ b/lib/routes.dart
@@ -144,4 +144,8 @@ class Routes {
static const lightningUsernamePage = "/lightning_username_page";
static const aboutPage = "/about_page";
+
+ static const bridgeHistoryPage = '/bridge_history_page';
+ static const bridgeDestinationNetworkPage = '/bridge_destination_network_page';
+ static const bridgeReceivingWalletPage = '/bridge_receiving_wallet_page';
}
diff --git a/lib/src/widgets/new_list_row/list_Item_style_wrapper.dart b/lib/src/widgets/new_list_row/list_Item_style_wrapper.dart
index d8a1186..110bf01 100644
--- a/lib/src/widgets/new_list_row/list_Item_style_wrapper.dart
+++ b/lib/src/widgets/new_list_row/list_Item_style_wrapper.dart
@@ -9,6 +9,7 @@ class ListItemStyleWrapper extends StatelessWidget {
this.backgroundColor,
this.onTap,
this.iconPath,
+ this.contentPadding,
this.height,
});
@@ -19,10 +20,10 @@ class ListItemStyleWrapper extends StatelessWidget {
final VoidCallback? onTap;
final Color? backgroundColor;
final Widget Function(BuildContext context, TextStyle textStyle, TextStyle labelStyle) builder;
+ final EdgeInsets? contentPadding;
@override
Widget build(BuildContext context) {
-
final theme = Theme.of(context);
final textStyle = TextStyle(
@@ -70,15 +71,16 @@ class ListItemStyleWrapper extends StatelessWidget {
child: Container(height: 1, color: theme.colorScheme.outlineVariant),
),
)
- else if(!isLastInSection) Container(
+ else if (!isLastInSection)
+ Container(
color: theme.colorScheme.surfaceContainer,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Container(height: 1, color: theme.colorScheme.outlineVariant),
),
)
- ],
- ),
- );
+ ],
+ ),
+ );
}
}
diff --git a/lib/store/bridge_transfers_store.dart b/lib/store/bridge_transfers_store.dart
new file mode 100644
index 0000000..2ebeb82
--- /dev/null
+++ b/lib/store/bridge_transfers_store.dart
@@ -0,0 +1,46 @@
+import 'package:cake_wallet/entities/bridge_transfer.dart';
+import 'package:mobx/mobx.dart';
+
+part 'bridge_transfers_store.g.dart';
+
+class BridgeTransfersStore = BridgeTransfersStoreBase with _$BridgeTransfersStore;
+
+abstract class BridgeTransfersStoreBase with Store {
+ BridgeTransfersStoreBase() : bridgeTransfers = [] {
+ updateList();
+ }
+
+ @observable
+ List<BridgeTransfer> bridgeTransfers;
+
+ @action
+ Future<void> updateList() async {
+ bridgeTransfers = await BridgeTransfer.selectAll();
+ }
+
+ @action
+ Future<void> addTransfer(BridgeTransfer transfer) async {
+ try {
+ await BridgeTransfer.insert(transfer);
+ await updateList();
+ } catch (_) {}
+ }
+
+ @action
+ Future<void> updateTransfer(BridgeTransfer transfer) async {
+ try {
+ await BridgeTransfer.update(transfer);
+ await updateList();
+ } catch (_) {}
+ }
+
+ @computed
+ List<BridgeTransfer> get activeTransfers =>
+ bridgeTransfers.where((b) => b.isActive).toList(growable: false);
+
+ @computed
+ List<BridgeTransfer> get pastTransfers =>
+ bridgeTransfers.where((b) => !b.isActive).toList(growable: false);
+
+ void dispose() {}
+}
diff --git a/lib/view_model/bridge/bridge_receiving_wallet_option.dart b/lib/view_model/bridge/bridge_receiving_wallet_option.dart
new file mode 100644
index 0000000..2e08c06
--- /dev/null
+++ b/lib/view_model/bridge/bridge_receiving_wallet_option.dart
@@ -0,0 +1,17 @@
+import 'package:cw_core/wallet_info.dart';
+
+class BridgeReceivingWalletOption {
+ const BridgeReceivingWalletOption({
+ required this.walletInfo,
+ required this.isCurrent,
+ this.groupLabel,
+ });
+
+ final WalletInfo walletInfo;
+ final bool isCurrent;
+
+ final String? groupLabel;
+
+ String get name => walletInfo.name;
+ String get address => walletInfo.address;
+}
diff --git a/lib/view_model/bridge/bridge_view_model.dart b/lib/view_model/bridge/bridge_view_model.dart
new file mode 100644
index 0000000..e84ccf6
--- /dev/null
+++ b/lib/view_model/bridge/bridge_view_model.dart
@@ -0,0 +1,719 @@
+import 'dart:async';
+
+import 'package:cake_wallet/core/amount_parsing_proxy.dart';
+import 'package:cake_wallet/core/amount_validator.dart';
+import 'package:cake_wallet/core/fiat_conversion_service.dart';
+import 'package:cake_wallet/core/utilities.dart';
+import 'package:cake_wallet/core/wallet_change_listener_view_model.dart';
+import 'package:cake_wallet/entities/calculate_fiat_amount.dart';
+import 'package:cake_wallet/entities/fiat_api_mode.dart';
+import 'package:cake_wallet/view_model/bridge/bridge_receiving_wallet_option.dart';
+import 'package:cake_wallet/entities/bridge_transfer.dart';
+import 'package:cake_wallet/entities/wallet_manager.dart';
+import 'package:cake_wallet/evm/evm.dart';
+import 'package:cake_wallet/reactions/wallet_connect.dart';
+import 'package:cake_wallet/core/layerzero_scan_service.dart';
+import 'package:cake_wallet/store/app_store.dart';
+import 'package:cake_wallet/store/bridge_transfers_store.dart';
+import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart';
+import 'package:cake_wallet/store/settings_store.dart';
+import 'package:cw_core/crypto_currency.dart';
+import 'package:cw_core/erc20_token.dart';
+import 'package:cw_core/transaction_priority.dart';
+import 'package:cw_core/wallet_base.dart';
+import 'package:cw_core/wallet_info.dart';
+import 'package:cw_core/utils/print_verbose.dart';
+import 'package:mobx/mobx.dart';
+
+part 'bridge_view_model.g.dart';
+
+class BridgeViewModel = BridgeViewModelBase with _$BridgeViewModel;
+
+abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with Store {
+ BridgeViewModelBase({
+ required AppStore appStore,
+ required this.bridgeTransfersStore,
+ required this.walletManager,
+ required this.fiatConversionStore,
+ required this.settingsStore,
+ }) : _appStore = appStore,
+ super(appStore: appStore);
+
+ final AppStore _appStore;
+
+ AmountParsingProxy get amountParsingProxy => _appStore.amountParsingProxy;
+
+ void Function()? onBridgeSuccess;
+ final Map<String, Completer<void>> _pollingCancellers = {};
+ final BridgeTransfersStore bridgeTransfersStore;
+ final WalletManager walletManager;
+ final FiatConversionStore fiatConversionStore;
+ final SettingsStore settingsStore;
+
+ @observable
+ ObservableList<BridgeReceivingWalletOption> bridgeReceivingWalletOptions =
+ ObservableList<BridgeReceivingWalletOption>();
+
+ @observable
+ bool isBridgeReceivingWalletListLoading = false;
+
+ @observable
+ CryptoCurrency? selectedToken;
+
+ @observable
+ int? destinationChainId;
+
+ @observable
+ String amount = '';
+
+ @observable
+ String recipientAddress = '';
+
+ @observable
+ String? destinationWalletName;
+
+ @observable
+ USDT0Quote? quote;
+
+ @observable
+ bool isQuoteLoading = false;
+
+ @observable
+ String? quoteError;
+
+ @observable
+ bool isExecuting = false;
+
+ @observable
+ String? executeError;
+
+ @observable
+ bool bridgeSuccess = false;
+
+ @observable
+ BridgeTransfer? lastCreatedBridgeTransfer;
+
+ @computed
+ int? get sourceChainId => evm!.getSelectedChainId(wallet);
+
+ @computed
+ String get sourceAddress => wallet.walletAddresses.address;
+
+ @computed
+ List<ChainInfo> get availableDestinationChains {
+ if (!isEVMCompatibleChain(wallet.type)) return [];
+
+ return evm!.getUSDT0DestinationChains(wallet);
+ }
+
+ @computed
+ List<Erc20Token> get availableUSDT0Tokens {
+ if (!isEVMCompatibleChain(wallet.type)) return [];
+
+ final tokens = wallet.balance.keys.whereType<Erc20Token>();
+ return tokens.where((token) => evm!.isUSDT0Token(wallet, token)).toList(growable: false);
+ }
+
+ @computed
+ ChainInfo? get destinationChainInfo {
+ if (destinationChainId == null) return null;
+
+ return availableDestinationChains.firstWhereOrNull((c) => c.chainId == destinationChainId);
+ }
+
+ @computed
+ String get tokenBalanceFormatted {
+ final token = selectedToken;
+ if (token is! Erc20Token) return '0.00';
+
+ return amountParsingProxy.getDisplayCryptoStringFromBigInt(
+ selectedTokenBalance,
+ token,
+ );
+ }
+
+ @computed
+ String get amountDisplayFormatted {
+ if (amount.isEmpty) return '';
+ final token = selectedToken;
+ if (token is! Erc20Token) return amount.replaceAll(',', '.');
+
+ return amountParsingProxy.getDisplayCryptoAmount(
+ amount.replaceAll(',', '.'),
+ token,
+ );
+ }
+
+
+ DecimalAmountValidator get decimalAmountValidator => DecimalAmountValidator(
+ currency: selectedToken!,
+ isAutovalidate: true,
+ );
+
+ @computed
+ String get fiatAmountFormatted {
+ if (amount.isEmpty) return '';
+ final token = selectedToken;
+ if (token is! Erc20Token) return '';
+
+ final price = fiatConversionStore.prices[token];
+ if (price == null) return '';
+
+ final forFiat = amountParsingProxy.getDisplayCryptoAmount(
+ amount.replaceAll(',', '.'),
+ token,
+ );
+
+ return calculateFiatAmount(
+ price: price,
+ cryptoAmount: forFiat,
+ );
+ }
+
+ @computed
+ String get fiatCurrencyTitle => settingsStore.fiatCurrency.title;
+
+ @computed
+ String get quoteNativeFee {
+ if (quote == null) return '—';
+
+ final cur = wallet.currency;
+ return amountParsingProxy.getDisplayCryptoStringFromBigInt(
+ quote!.nativeFee,
+ cur,
+ );
+ }
+
+ @computed
+ String get quoteNativeFeeFormattedForDisplay {
+ if (quoteNativeFee.isEmpty) return '';
+
+ return '${quoteNativeFee} ${wallet.currency.title}';
+ }
+
+ @computed
+ String get quoteNativeFiatFeeFormattedForDisplay {
+ if (quote == null || quoteNativeFee.isEmpty) return '';
+
+ final price = fiatConversionStore.prices[wallet.currency];
+ if (price == null) return '';
+
+ final fiatFeeFormatted = calculateFiatAmount(
+ price: price,
+ cryptoAmount: amountParsingProxy.getDisplayCryptoAmount(
+ quoteNativeFee.replaceAll(',', '.'),
+ wallet.currency,
+ ),
+ );
+
+ return '(${settingsStore.fiatCurrency.title} $fiatFeeFormatted)';
+ }
+
+ @computed
+ bool get canProceedToDestinationNetwork {
+ if (amount.isEmpty) return false;
+
+ if (selectedToken == null || selectedToken is! Erc20Token) return false;
+
+ if (amountError != null) return false;
+
+ final token = selectedToken as Erc20Token;
+ final validAmount = amountParsingProxy.tryParseCryptoString(
+ amount.replaceAll(',', '.'),
+ token,
+ );
+ return validAmount != null && validAmount > BigInt.zero;
+ }
+
+ @action
+ void applyInitialBridgeToken(CryptoCurrency asset) {
+ for (final t in availableUSDT0Tokens) {
+ if (t == asset) {
+ setSelectedToken(t);
+ break;
+ }
+ }
+ }
+
+ @action
+ void setDestinationChain(int chainId) {
+ destinationChainId = chainId;
+ _clearQuoteState();
+ }
+
+ @action
+ void setSelectedToken(CryptoCurrency token) {
+ selectedToken = token;
+ _clearQuoteState();
+ }
+
+ @action
+ void setAmount(String value) {
+ amount = value;
+ _clearQuoteState();
+ }
+
+ @action
+ void setMaxAmount() {
+ final token = selectedToken;
+ if (token is! Erc20Token) return;
+ if (selectedTokenBalance == BigInt.zero) {
+ setAmount('');
+ return;
+ }
+ setAmount(
+ amountParsingProxy.getDisplayCryptoStringFromBigInt(
+ selectedTokenBalance,
+ token,
+ ),
+ );
+ }
+
+ @action
+ void setRecipientAddress(String value, {String? destWalletName}) {
+ recipientAddress = value;
+ destinationWalletName = destWalletName;
+ _clearQuoteState();
+ }
+
+ void _clearQuoteState() {
+ quote = null;
+ quoteError = null;
+ executeError = null;
+ }
+
+ Future<void> _ensureFiatPriceFor(CryptoCurrency crypto) async {
+ if (fiatConversionStore.prices[crypto] != null) return;
+
+ final p = await FiatConversionService.fetchPrice(
+ crypto: crypto,
+ fiat: settingsStore.fiatCurrency,
+ torOnly: settingsStore.fiatApiMode == FiatApiMode.torOnly,
+ );
+
+ runInAction(() {
+ fiatConversionStore.prices[crypto] = p;
+ });
+ }
+
+ @action
+ Future<void> ensureFiatPriceForSelectedToken() async {
+ await _ensureFiatPriceFor(selectedToken!);
+ }
+
+ @action
+ Future<void> ensureFiatPriceForNativeCurrency() async {
+ await _ensureFiatPriceFor(wallet.currency);
+ }
+
+ @computed
+ BigInt get selectedTokenBalance {
+ final bal = wallet.balance[selectedToken];
+
+ if (bal is EVMChainERC20Balance) return bal.balance;
+ return BigInt.zero;
+ }
+
+ @computed
+ String? get amountError {
+ if (selectedToken == null || amount.isEmpty) return null;
+ if (selectedToken is! Erc20Token) return null;
+
+ final token = selectedToken as Erc20Token;
+ final amountBigInt = amountParsingProxy.tryParseCryptoString(
+ amount.replaceAll(',', '.'),
+ token,
+ );
+ if (amountBigInt == null || amountBigInt == BigInt.zero) return null;
+ if (amountBigInt > selectedTokenBalance) {
+ return 'Insufficient balance for ${token.title} token.';
+ }
+
+ return null;
+ }
+
+ @action
+ Future<void> loadReceivingWalletOptions() async {
+ if (!isEVMCompatibleChain(wallet.type)) return;
+
+ final destWalletType = evm!.getWalletTypeByChainId(destinationChainId!);
+
+ isBridgeReceivingWalletListLoading = true;
+ try {
+ await walletManager.updateWalletGroups();
+ final all = await WalletInfo.getAll();
+
+ if (destWalletType == null) {
+ bridgeReceivingWalletOptions.clear();
+ return;
+ }
+
+ final filtered =
+ all.where((w) => w.type == destWalletType && w.hardwareWalletType == null).toList();
+
+ final options = <BridgeReceivingWalletOption>[];
+
+ for (final wi in filtered) {
+ final isCurrent = wi.name == wallet.name;
+ options.add(
+ BridgeReceivingWalletOption(
+ walletInfo: wi,
+ isCurrent: isCurrent,
+ groupLabel: walletManager.getGroupName(wi),
+ ),
+ );
+ }
+
+ bridgeReceivingWalletOptions
+ ..clear()
+ ..addAll(options);
+ } finally {
+ isBridgeReceivingWalletListLoading = false;
+ }
+ }
+
+ String? missingBridgeFieldsMessage() {
+ final src = sourceChainId;
+ final dst = destinationChainId;
+ final token = selectedToken;
+
+ if (src == null ||
+ dst == null ||
+ token == null ||
+ amount.isEmpty ||
+ recipientAddress.trim().isEmpty) {
+ return 'Fill all fields';
+ }
+ return null;
+ }
+
+ ({String? error, BigInt? parsedAmount}) _parseAndValidateAmount(Erc20Token token) {
+ final amountBigInt = amountParsingProxy.tryParseCryptoString(
+ amount.replaceAll(',', '.'),
+ token,
+ );
+
+ if (amountBigInt == null || amountBigInt == BigInt.zero) {
+ return (error: 'Invalid amount', parsedAmount: null);
+ }
+
+ if (amountBigInt > selectedTokenBalance) {
+ return (
+ error: 'Insufficient balance for ${token.title} token.',
+ parsedAmount: null,
+ );
+ }
+
+ return (error: null, parsedAmount: amountBigInt);
+ }
+
+ @action
+ Future<void> loadQuote() async {
+ final missing = missingBridgeFieldsMessage();
+ if (missing != null) {
+ quoteError = missing;
+ return;
+ }
+
+ final token = selectedToken!;
+ if (token is! Erc20Token) return;
+
+ final check = _parseAndValidateAmount(token);
+ if (check.error != null) {
+ quoteError = check.error;
+ return;
+ }
+
+ final src = sourceChainId!;
+ final dst = destinationChainId!;
+ final amountBigInt = check.parsedAmount!;
+
+ isQuoteLoading = true;
+ quoteError = null;
+ quote = null;
+ executeError = null;
+
+ try {
+ quote = await evm!.quoteUSDT0Transfer(
+ wallet: wallet,
+ sourceChainId: src,
+ destinationChainId: dst,
+ amount: amountBigInt,
+ recipientAddress: recipientAddress.trim(),
+ );
+ } catch (e) {
+ quoteError = e.toString();
+ } finally {
+ isQuoteLoading = false;
+ }
+ }
+
+ @action
+ Future<void> executeBridge() async {
+ if (quote == null) {
+ executeError = 'Get a quote first';
+ return;
+ }
+
+ final missing = missingBridgeFieldsMessage();
+ if (missing != null) {
+ executeError = missing;
+ return;
+ }
+
+ final token = selectedToken!;
+ if (token is! Erc20Token) return;
+
+ final check = _parseAndValidateAmount(token);
+ if (check.error != null) {
+ executeError = check.error;
+ return;
+ }
+
+ final src = sourceChainId!;
+ final dst = destinationChainId!;
+ final amountBigInt = check.parsedAmount!;
+
+ isExecuting = true;
+ executeError = null;
+ try {
+ final priority = EVMChainTransactionPriority.medium;
+ final pending = await evm!.executeUSDT0Transfer(
+ wallet: wallet,
+ token: token,
+ sourceChainId: src,
+ destinationChainId: dst,
+ amount: amountBigInt,
+ recipientAddress: recipientAddress.trim(),
+ quote: quote!,
+ priority: priority as TransactionPriority,
+ useBlinkProtection: canSupportBlinkProtection(src),
+ );
+
+ final sourceTxHash = pending.evmTxHashFromRawHex ?? pending.id;
+ await pending.commit();
+
+ final record = BridgeTransfer(
+ id: '${sourceTxHash}_${DateTime.now().millisecondsSinceEpoch}',
+ walletId: wallet.name,
+ sourceChainId: src,
+ destinationChainId: dst,
+ tokenSymbol: token.title,
+ tokenContract: token.contractAddress,
+ amount: amount,
+ recipientAddress: recipientAddress.trim(),
+ sourceTxHash: sourceTxHash,
+ status: 'submitted',
+ createdAt: DateTime.now(),
+ );
+
+ await bridgeTransfersStore.addTransfer(record);
+ runInAction(() {
+ quote = null;
+ bridgeSuccess = true;
+ lastCreatedBridgeTransfer = record;
+ });
+ onBridgeSuccess?.call();
+ _pollForSourceConfirmation(record, wallet);
+ } catch (e) {
+ executeError = e.toString();
+ } finally {
+ isExecuting = false;
+ }
+ }
+
+ static const _pollInterval = Duration(seconds: 2);
+ static const _pollTimeout = Duration(minutes: 3);
+ static const _destinationPollInterval = Duration(seconds: 5);
+ static const _destinationPollTimeout = Duration(minutes: 10);
+
+ @override
+ void onWalletChange(WalletBase wallet) {
+ _cancelAllPolling();
+ _resumePollingForActiveTransfers(wallet);
+ }
+
+ void _cancelAllPolling() {
+ for (final canceller in _pollingCancellers.values) {
+ if (!canceller.isCompleted) {
+ canceller.complete();
+ }
+ }
+ _pollingCancellers.clear();
+ }
+
+ void _resumePollingForActiveTransfers(WalletBase wallet) {
+ if (!isEVMCompatibleChain(wallet.type)) return;
+
+ final activeTransfers = bridgeTransfersStore.bridgeTransfers
+ .where((t) => t.walletId == wallet.name && t.isActive)
+ .toList();
+
+ for (final transfer in activeTransfers) {
+ if (transfer.status == 'submitted' || transfer.status == 'confirming') {
+ _pollForSourceConfirmation(transfer, wallet);
+ } else if (transfer.status == 'initiated') {
+ _pollForDestinationCompletion(transfer, wallet);
+ }
+ }
+ }
+
+ bool _isValidWalletContext(String expectedWalletId) {
+ return wallet.name == expectedWalletId &&
+ isEVMCompatibleChain(wallet.type) &&
+ !_pollingCancellers.values.any((c) => c.isCompleted);
+ }
+
+ Future<void> _updateTransferStatus(
+ BridgeTransfer record,
+ String status, {
+ String? errorMessage,
+ String? statusMessage,
+ DateTime? confirmedAt,
+ }) async {
+ if (!_isValidWalletContext(record.walletId)) return;
+
+ runInAction(() {
+ record.updatedAt = DateTime.now();
+ record.status = status;
+ if (errorMessage != null) record.errorMessage = errorMessage;
+ if (statusMessage != null) record.statusMessage = statusMessage;
+ if (confirmedAt != null) record.confirmedAt = confirmedAt;
+ });
+ await bridgeTransfersStore.updateTransfer(record);
+ }
+
+ Future<void> _pollForSourceConfirmation(
+ BridgeTransfer record,
+ WalletBase wallet,
+ ) async {
+ final canceller = Completer<void>();
+ _pollingCancellers[record.id] = canceller;
+ final walletId = wallet.name;
+ final deadline = DateTime.now().add(_pollTimeout);
+
+ try {
+ while (DateTime.now().isBefore(deadline)) {
+ await Future.any([
+ Future.delayed(_pollInterval),
+ canceller.future,
+ ]);
+
+ if (canceller.isCompleted || !_isValidWalletContext(walletId)) return;
+
+ bool? receipt;
+ try {
+ receipt = await evm!.getTransactionReceipt(wallet, record.sourceTxHash);
+ } catch (e) {
+ printV('USDT0 bridge: Error fetching receipt: $e');
+ continue;
+ }
+
+ if (receipt == null) continue;
+
+ if (receipt == true) {
+ await _updateTransferStatus(
+ record,
+ 'confirming',
+ confirmedAt: DateTime.now(),
+ );
+
+ await Future.delayed(const Duration(seconds: 1));
+ if (canceller.isCompleted || !_isValidWalletContext(walletId)) return;
+
+ await _updateTransferStatus(record, 'initiated');
+ _pollForDestinationCompletion(record, wallet);
+
+ return;
+ } else if (receipt == false) {
+ await _updateTransferStatus(
+ record,
+ 'failed',
+ errorMessage: 'Transaction reverted',
+ );
+ return;
+ }
+ }
+
+ if (!_isValidWalletContext(walletId)) return;
+
+ await _updateTransferStatus(record, 'initiated');
+ _pollForDestinationCompletion(record, wallet);
+ } finally {
+ _pollingCancellers.remove(record.id);
+ }
+ }
+
+ Future<void> _pollForDestinationCompletion(
+ BridgeTransfer record,
+ WalletBase wallet,
+ ) async {
+ final canceller = Completer<void>();
+ _pollingCancellers['${record.id}_dest'] = canceller;
+ final walletId = wallet.name;
+ final deadline = DateTime.now().add(_destinationPollTimeout);
+
+ try {
+ while (DateTime.now().isBefore(deadline)) {
+ await Future.any([
+ Future.delayed(_destinationPollInterval),
+ canceller.future,
+ ]);
+
+ if (canceller.isCompleted || !_isValidWalletContext(walletId)) return;
+
+ LayerZeroMessageStatus? status;
+ try {
+ status = await LayerZeroScanService.getMessageStatus(record.sourceTxHash);
+ } catch (e) {
+ printV('USDT0 bridge: Error fetching LayerZero status: $e');
+ continue;
+ }
+
+ if (status == null) continue;
+
+ if (status.isDelivered) {
+ await _updateTransferStatus(
+ record,
+ 'completed',
+ statusMessage: status.status?.message,
+ );
+ return;
+ }
+
+ if (status.isFailed) {
+ await _updateTransferStatus(
+ record,
+ 'failed',
+ errorMessage: status.status?.message ?? 'Bridge message failed',
+ statusMessage: status.status?.message,
+ );
+ return;
+ }
+
+ await _updateTransferStatus(
+ record,
+ record.status,
+ statusMessage: status.status?.message,
+ );
+ }
+ } finally {
+ _pollingCancellers.remove('${record.id}_dest');
+ }
+ }
+
+ @action
+ void clearBridgeSuccess() {
+ amount = '';
+ recipientAddress = '';
+ destinationWalletName = null;
+ destinationChainId = null;
+ bridgeSuccess = false;
+ lastCreatedBridgeTransfer = null;
+ _clearQuoteState();
+ }
+
+ void dispose() {
+ _cancelAllPolling();
+ }
+}
diff --git a/lib/view_model/bridge_details_view_model.dart b/lib/view_model/bridge_details_view_model.dart
new file mode 100644
index 0000000..b401f75
--- /dev/null
+++ b/lib/view_model/bridge_details_view_model.dart
@@ -0,0 +1,181 @@
+import 'dart:async';
+
+import 'package:cake_wallet/entities/bridge_transfer.dart';
+import 'package:cake_wallet/evm/evm.dart';
+import 'package:cake_wallet/generated/i18n.dart';
+import 'package:cake_wallet/src/screens/transaction_details/address_list_item.dart';
+import 'package:cake_wallet/src/screens/transaction_details/standart_list_item.dart';
+import 'package:cake_wallet/src/screens/trade_details/trade_details_status_item.dart';
+import 'package:cake_wallet/src/screens/trade_details/track_trade_list_item.dart';
+import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart';
+import 'package:cake_wallet/store/bridge_transfers_store.dart';
+import 'package:mobx/mobx.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+part 'bridge_details_view_model.g.dart';
+
+class BridgeDetailsViewModel = BridgeDetailsViewModelBase with _$BridgeDetailsViewModel;
+
+abstract class BridgeDetailsViewModelBase with Store {
+ BridgeDetailsViewModelBase({
+ required BridgeTransfer transferForDetails,
+ required this.bridgeTransfersStore,
+ required this.walletId,
+ }) : items = ObservableList<TransactionDetailsListItem>(),
+ transfer = _findTransferInStore(
+ bridgeTransfersStore.bridgeTransfers, transferForDetails.id, walletId) ??
+ transferForDetails {
+ _updateItems();
+ _setupReaction();
+ }
+
+ static BridgeTransfer? _findTransferInStore(
+ List<BridgeTransfer> transfers,
+ String transferId,
+ String walletId,
+ ) {
+ try {
+ return transfers.firstWhere(
+ (t) => t.id == transferId && t.walletId == walletId,
+ );
+ } catch (_) {
+ return null;
+ }
+ }
+
+ final BridgeTransfersStore bridgeTransfersStore;
+ final String walletId;
+ ReactionDisposer? _reactionDisposer;
+
+ @observable
+ BridgeTransfer transfer;
+
+ @observable
+ ObservableList<TransactionDetailsListItem> items;
+
+ Timer? timer;
+
+ void _setupReaction() {
+ _reactionDisposer = reaction(
+ (_) => bridgeTransfersStore.bridgeTransfers,
+ (_) => updateTransfer(),
+ );
+ updateTransfer();
+ }
+
+ @action
+ void updateTransfer() {
+ final updatedTransfer = _findTransferInStore(
+ bridgeTransfersStore.bridgeTransfers,
+ transfer.id,
+ walletId,
+ );
+ if (updatedTransfer != null) {
+ transfer = updatedTransfer;
+ _updateItems();
+ }
+ }
+
+ void dispose() {
+ _reactionDisposer?.call();
+ timer?.cancel();
+ }
+
+ void _updateItems() {
+ items.clear();
+
+ final statusText = transfer.statusMessage?.isNotEmpty == true
+ ? '${_statusLabel(transfer.status)} · ${transfer.statusMessage}'
+ : _statusLabel(transfer.status);
+
+ items.add(
+ DetailsListStatusItem(
+ title: "Status",
+ value: statusText,
+ status: transfer.status,
+ ),
+ );
+
+ final sourceName =
+ evm?.getChainNameByChainId(transfer.sourceChainId) ?? '${transfer.sourceChainId}';
+ final destName =
+ evm?.getChainNameByChainId(transfer.destinationChainId) ?? '${transfer.destinationChainId}';
+
+ items.add(
+ StandartListItem(
+ title: "Source chain",
+ value: sourceName,
+ ),
+ );
+
+ items.add(
+ StandartListItem(
+ title: "Destination chain",
+ value: destName,
+ ),
+ );
+
+ items.add(
+ StandartListItem(
+ title: "Amount",
+ value: '${transfer.amount} ${transfer.tokenSymbol}',
+ ),
+ );
+
+ items.add(
+ AddressListItem(
+ title: "Recipient",
+ value: transfer.recipientAddress,
+ ),
+ );
+
+ final sourceExplorerUrl = evm?.getExplorerUrlForChainId(transfer.sourceChainId);
+ final sourceTxUrl = sourceExplorerUrl != null && sourceExplorerUrl.isNotEmpty
+ ? '$sourceExplorerUrl/tx/${transfer.sourceTxHash}'
+ : null;
+
+ if (sourceTxUrl != null) {
+ final explorerDescription = S.current.view_transaction_on + Uri.parse(sourceTxUrl).host;
+ items.add(
+ TrackTradeListItem(
+ title: explorerDescription,
+ value: sourceTxUrl,
+ onTap: () => _launchUrl(sourceTxUrl),
+ ),
+ );
+ }
+
+ if (transfer.errorMessage != null && transfer.errorMessage!.isNotEmpty) {
+ items.add(
+ StandartListItem(
+ title: "Error",
+ value: transfer.errorMessage!,
+ ),
+ );
+ }
+ }
+
+ String _statusLabel(String status) {
+ switch (status) {
+ case 'submitted':
+ return "Submitted";
+ case 'confirming':
+ return "Confirming on source";
+ case 'initiated':
+ return "Bridge initiated";
+ case 'completed':
+ return "Completed";
+ case 'failed':
+ return "Failed";
+ default:
+ return status;
+ }
+ }
+
+ void _launchUrl(String url) {
+ final uri = Uri.parse(url);
+ try {
+ launchUrl(uri, mode: LaunchMode.externalApplication);
+ } catch (_) {}
+ }
+}
diff --git a/lib/view_model/bridge_history_view_model.dart b/lib/view_model/bridge_history_view_model.dart
new file mode 100644
index 0000000..2d27980
--- /dev/null
+++ b/lib/view_model/bridge_history_view_model.dart
@@ -0,0 +1,37 @@
+import 'package:cake_wallet/entities/bridge_transfer.dart';
+import 'package:cake_wallet/store/app_store.dart';
+import 'package:cake_wallet/store/bridge_transfers_store.dart';
+import 'package:mobx/mobx.dart';
+
+part 'bridge_history_view_model.g.dart';
+
+class BridgeHistoryViewModel = BridgeHistoryViewModelBase with _$BridgeHistoryViewModel;
+
+abstract class BridgeHistoryViewModelBase with Store {
+ BridgeHistoryViewModelBase({
+ required this.bridgeTransfersStore,
+ required this.appStore,
+ });
+
+ final BridgeTransfersStore bridgeTransfersStore;
+ final AppStore appStore;
+
+ @computed
+ List<BridgeTransfer> get walletTransfers {
+ final wallet = appStore.wallet;
+ if (wallet == null) return [];
+
+ return bridgeTransfersStore.bridgeTransfers.where((t) => t.walletId == wallet.name).toList();
+ }
+
+ @computed
+ List<BridgeTransfer> get activeTransfers =>
+ walletTransfers.where((b) => b.isActive).toList(growable: false);
+
+ @computed
+ List<BridgeTransfer> get pastTransfers =>
+ walletTransfers.where((b) => !b.isActive).toList(growable: false);
+
+ @computed
+ bool get isEmpty => walletTransfers.isEmpty;
+}
diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart
index 6dd41d4..1f2cb0f 100644
--- a/lib/view_model/dashboard/dashboard_view_model.dart
+++ b/lib/view_model/dashboard/dashboard_view_model.dart
@@ -393,6 +393,14 @@ abstract class DashboardViewModelBase with Store {
return false;
}
+ bool showBridge(CryptoCurrency currency) {
+ if(!isEVMCompatibleChain(wallet.type)) return false;
+
+ if(evm!.isUSDT0Token(wallet, currency)) return true;
+
+ return false;
+ }
+
@action
Future<void> loadCardDesigns() async {
final accountStyleSettings =
diff --git a/res/pictures/bridge.svg b/res/pictures/bridge.svg
new file mode 100644
index 0000000..ab7b0d5
--- /dev/null
+++ b/res/pictures/bridge.svg
@@ -0,0 +1,3 @@
+<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
+<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"/>
+</svg>
diff --git a/tool/configure.dart b/tool/configure.dart
index c6ee96b..798febd 100644
--- a/tool/configure.dart
+++ b/tool/configure.dart
@@ -1392,7 +1392,13 @@ import 'package:cw_evm/evm_chain_wallet_creation_credentials.dart';
import 'package:cw_evm/utils/evm_chain_utils.dart';
import 'package:cw_evm/evm_chain_default_tokens.dart';
import 'package:cw_evm/deuro/deuro_savings.dart';
+import 'package:cw_evm/usdt0/usdt0_config.dart';
+import 'package:cw_evm/usdt0/usdt0_quote.dart';
+import 'package:cw_evm/usdt0/usdt0_service.dart';
import 'package:eth_sig_util/util/utils.dart';
+export 'package:cw_evm/evm_chain_transaction_priority.dart';
+export 'package:cw_evm/evm_erc20_balance.dart';
+export 'package:cw_evm/usdt0/usdt0_quote.dart';
""";
const evmCwPart = "part 'cw_evm.dart';";
@@ -1546,19 +1552,44 @@ abstract class EVM {
WalletType? getWalletTypeByChainId(int chainId);
String getChainNameByChainId(int chainId);
String getTokenNameByChainId(int chainId);
-
// Chain selection methods
List<ChainInfo> getAllChains();
ChainInfo? getCurrentChain(WalletBase wallet);
+ ChainInfo? getChainInfoByChainId(int chainId);
+
int? getSelectedChainId(WalletBase wallet);
Future<void> selectChain(WalletBase wallet, int chainId, {required Node node});
String? getExplorerUrlForChainId(int chainId, {bool showProtocol = true});
+ Future<bool?> getTransactionReceipt(WalletBase wallet, String txHash);
+
bool hasPriorityFee(int chainId);
+ bool isUSDT0Token(WalletBase wallet, CryptoCurrency token);
+ List<ChainInfo> getUSDT0DestinationChains(WalletBase wallet);
+
+ Future<USDT0Quote> quoteUSDT0Transfer({
+ required WalletBase wallet,
+ required int sourceChainId,
+ required int destinationChainId,
+ required BigInt amount,
+ required String recipientAddress,
+ });
+ Future<PendingTransaction> executeUSDT0Transfer({
+ required WalletBase wallet,
+ required CryptoCurrency token,
+ required int sourceChainId,
+ required int destinationChainId,
+ required BigInt amount,
+ required String recipientAddress,
+ required USDT0Quote quote,
+ required TransactionPriority priority,
+ bool useBlinkProtection = true,
+ });
+
Future<EvmWalletConnectFeeQuote?> getWCBufferedFeeQuote(
WalletBase wallet,
TransactionPriority priority,
@@ -1572,11 +1603,13 @@ class ChainInfo {
required this.chainId,
required this.name,
required this.shortCode,
+ required this.currency,
});
final int chainId;
final String name;
final String shortCode;
+ final CryptoCurrency currency;
@override
bool operator ==(Object other) =>
Why this scored 34/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.