CW-1273: Implement USDT0 Bridge (#2855)

* feat: Implement USDT0 Bridging using OFT Standard by Layer Zero. POC available in apps menu.

* feat: Implement USDT0 Bridging using OFT Standard by Layer Zero. POC available in apps menu.

* feat: Implement USDT0 Bridging using OFT Standard by Layer Zero. POC available in apps menu.

* Add transaction history and status polling for USDT0 bridging

* Add  Transaction history and status polling for USDT0 Bridging

* USDT0 Bridging Implementation

* Update Arbitrum USDT token symbol

* fix: Merge conflicts

* fix: Merge conflicts

* chore: Rever formatting

* feat: Implement new flow

* feat: Implement new ui flow

* Add currency to configure

* feat: new ui flow

* Update USDT0 implementation
- Switch to sqlite for storage instead of hive
- Switch bridge history and details to modals
- Switch bridge transfer details page to new tx details ui

* refactor: rearrange modal action buttons

* enhance usdt0 bridge flows

* Update lib/entities/wallet_manager.dart [skip ci]

---------

Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
David Adegoke
2026-04-15 05:06:05 +02:00
committed by GitHub
co-authored by Omar Hatem
parent 162e0a5300
commit 586a040e4a
45 changed files with 3958 additions and 43 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.
Binary file not shown.
+34 -1
View File
@@ -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);
''');
}
+1 -1
View File
@@ -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;
+174
View File
@@ -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');
}
+13
View File
@@ -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 '';
+8 -3
View File
@@ -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();
}
}
+58
View File
@@ -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();
}
}
+15
View File
@@ -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)';
}
+146
View File
@@ -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;
}
}
+318
View File
@@ -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,
);
}
}
+45 -1
View File
@@ -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;
}
+127
View File
@@ -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']),
);
}
}
+10 -1
View File
@@ -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;
}
}
}
+110
View File
@@ -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,
+1 -1
View File
@@ -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,
@@ -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);
},
),
),
],
),
],
);
},
),
),
),
),
],
),
),
);
}
}
@@ -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',
),
),
);
},
),
),
],
),
),
);
}
}
@@ -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,
),
);
}
}
@@ -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,
);
},
),
),
],
),
);
}
}
@@ -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(),
);
},
),
],
),
),
),
],
),
),
);
}
}
@@ -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),
],
),
),
),
),
],
),
),
);
}
}
@@ -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,
),
],
);
},
);
}
}
@@ -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,
),
],
),
);
},
);
}
}
@@ -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(),
),
],
);
}
}
@@ -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;
},
);
}
}
@@ -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,
),
),
],
),
],
),
),
],
),
),
);
}
}
@@ -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()
@@ -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: "",
@@ -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,
@@ -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,
),
),
+21 -1
View File
@@ -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(
+4
View File
@@ -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';
}
@@ -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),
),
)
],
),
);
],
),
);
}
}
+46
View File
@@ -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() {}
}
@@ -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;
}
@@ -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();
}
}
@@ -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 (_) {}
}
}
@@ -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;
}
@@ -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 =
+3
View File
@@ -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>

After

Width:  |  Height:  |  Size: 2.3 KiB

+34 -1
View File
@@ -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) =>