| 1 | import 'dart:async'; |
| 2 | |
| 3 | import 'package:cake_wallet/evm/evm.dart'; |
| 4 | import 'package:cake_wallet/exchange/exchange_provider_description.dart'; |
| 5 | import 'package:cake_wallet/exchange/trade_state.dart'; |
| 6 | import 'package:cw_core/crypto_currency.dart'; |
| 7 | import 'package:cw_core/db/sqlite.dart'; |
| 8 | import 'package:cw_core/format_amount.dart'; |
| 9 | import 'package:cw_core/generate_name.dart'; |
| 10 | import 'package:sqflite/sqflite.dart'; |
| 11 | |
| 12 | class Trade { |
| 13 | Trade({ |
| 14 | this.internalId = 0, |
| 15 | required this.id, |
| 16 | required this.amount, |
| 17 | ExchangeProviderDescription? provider, |
| 18 | this.from, |
| 19 | this.to, |
| 20 | TradeState? state, |
| 21 | this.receiveAmount, |
| 22 | this.createdAt, |
| 23 | this.expiredAt, |
| 24 | this.inputAddress, |
| 25 | this.extraId, |
| 26 | this.outputTransaction, |
| 27 | this.refundAddress, |
| 28 | this.walletId, |
| 29 | this.payoutAddress, |
| 30 | this.toAddressExtraId, |
| 31 | this.password, |
| 32 | this.providerId, |
| 33 | this.providerName, |
| 34 | this.fromWalletAddress, |
| 35 | this.memo, |
| 36 | this.fee, |
| 37 | this.txId, |
| 38 | this.isRefund, |
| 39 | this.isSendAll, |
| 40 | this.router, |
| 41 | // The following fields are used for SwapXyz trades only |
| 42 | this.needToRegisterInSwapXyz, |
| 43 | this.sourceTokenAddress, |
| 44 | this.sourceTokenDecimals, |
| 45 | this.routerData, |
| 46 | this.routerValue, |
| 47 | this.routerChainId, |
| 48 | this.sourceTokenAmountRaw, |
| 49 | this.requiresTokenApproval, |
| 50 | this.chainId, |
| 51 | }) { |
| 52 | if (provider != null) providerRaw = provider.raw; |
| 53 | if (state != null) stateRaw = state.raw; |
| 54 | } |
| 55 | |
| 56 | static const tableName = 'Trade'; |
| 57 | static const selfIdColumn = 'tradeId'; |
| 58 | |
| 59 | static const boxName = 'Trades'; |
| 60 | static const boxKey = 'tradesBoxKey'; |
| 61 | |
| 62 | static final StreamController<void> onChanged = StreamController<void>.broadcast(); |
| 63 | |
| 64 | int internalId; |
| 65 | |
| 66 | String id; |
| 67 | |
| 68 | int providerRaw = 0; |
| 69 | |
| 70 | ExchangeProviderDescription get provider => |
| 71 | ExchangeProviderDescription.deserialize(raw: providerRaw); |
| 72 | |
| 73 | CryptoCurrency? from; |
| 74 | CryptoCurrency? to; |
| 75 | |
| 76 | String stateRaw = ''; |
| 77 | |
| 78 | TradeState get state => TradeState.deserialize(raw: stateRaw); |
| 79 | |
| 80 | DateTime? createdAt; |
| 81 | DateTime? expiredAt; |
| 82 | String amount; |
| 83 | String? receiveAmount; |
| 84 | String? inputAddress; |
| 85 | String? extraId; |
| 86 | String? outputTransaction; |
| 87 | String? refundAddress; |
| 88 | String? walletId; |
| 89 | String? payoutAddress; |
| 90 | |
| 91 | // holds the receive address memo or destination tag that was passed for this trade |
| 92 | String? toAddressExtraId; |
| 93 | String? password; |
| 94 | String? providerId; |
| 95 | String? providerName; |
| 96 | String? fromWalletAddress; |
| 97 | String? memo; |
| 98 | String? txId; |
| 99 | bool? isRefund; |
| 100 | bool? isSendAll; |
| 101 | String? router; |
| 102 | |
| 103 | // The following fields are used for SwapXyz trades only |
| 104 | bool? needToRegisterInSwapXyz; |
| 105 | String? sourceTokenAddress; |
| 106 | int? sourceTokenDecimals; |
| 107 | String? routerData; |
| 108 | String? routerValue; |
| 109 | int? routerChainId; |
| 110 | String? sourceTokenAmountRaw; |
| 111 | bool? requiresTokenApproval; |
| 112 | |
| 113 | int? chainId; |
| 114 | double? fee; |
| 115 | |
| 116 | String get chainName { |
| 117 | if (chainId == null) return ''; |
| 118 | |
| 119 | return evm!.getChainNameByChainId(chainId!).capitalized(); |
| 120 | } |
| 121 | |
| 122 | // ── SQLite CRUD ────────────────────────────────────── |
| 123 | |
| 124 | Future<int> save() async { |
| 125 | final json = toSqliteMap(); |
| 126 | if (json[selfIdColumn] == 0) { |
| 127 | json[selfIdColumn] = null; |
| 128 | } |
| 129 | internalId = await db!.insert( |
| 130 | tableName, |
| 131 | json, |
| 132 | conflictAlgorithm: ConflictAlgorithm.replace, |
| 133 | ); |
| 134 | onChanged.add(null); |
| 135 | return internalId; |
| 136 | } |
| 137 | |
| 138 | static Future<List<Trade>> getAll({String? orderBy}) async { |
| 139 | final list = await db!.query( |
| 140 | tableName, |
| 141 | orderBy: orderBy ?? 'createdAt DESC', |
| 142 | ); |
| 143 | return List.generate( |
| 144 | list.length, |
| 145 | (i) => Trade.fromSqliteRow(list[i]), |
| 146 | ); |
| 147 | } |
| 148 | |
| 149 | static Future<Trade?> getByTradeId(String id) async { |
| 150 | final list = await db!.query( |
| 151 | tableName, |
| 152 | where: 'id = ?', |
| 153 | whereArgs: [id], |
| 154 | limit: 1, |
| 155 | ); |
| 156 | if (list.isEmpty) return null; |
| 157 | return Trade.fromSqliteRow(list.first); |
| 158 | } |
| 159 | |
| 160 | static Future<int> deleteTrade(Trade trade) async { |
| 161 | final rows = await db!.delete( |
| 162 | tableName, |
| 163 | where: '$selfIdColumn = ?', |
| 164 | whereArgs: [trade.internalId], |
| 165 | ); |
| 166 | onChanged.add(null); |
| 167 | return rows; |
| 168 | } |
| 169 | |
| 170 | // ── SQLite serialization ───────────────────────────── |
| 171 | void mergeFindTradeByIdResult(Trade updated) { |
| 172 | if (updated.stateRaw.isNotEmpty) stateRaw = updated.stateRaw; |
| 173 | if (createdAt == null && updated.createdAt != null) { |
| 174 | createdAt = updated.createdAt; |
| 175 | } |
| 176 | if (updated.expiredAt != null) expiredAt = updated.expiredAt; |
| 177 | if (updated.isRefund != null) isRefund = updated.isRefund; |
| 178 | |
| 179 | if (updated.receiveAmount != null) receiveAmount = updated.receiveAmount; |
| 180 | if (updated.inputAddress != null) inputAddress = updated.inputAddress; |
| 181 | if (updated.extraId != null) extraId = updated.extraId; |
| 182 | if (updated.outputTransaction != null) { |
| 183 | outputTransaction = updated.outputTransaction; |
| 184 | } |
| 185 | if (updated.refundAddress != null) refundAddress = updated.refundAddress; |
| 186 | if (updated.payoutAddress != null) payoutAddress = updated.payoutAddress; |
| 187 | if (updated.password != null) password = updated.password; |
| 188 | if (updated.providerId != null) providerId = updated.providerId; |
| 189 | if (updated.providerName != null) providerName = updated.providerName; |
| 190 | if (updated.memo != null) memo = updated.memo; |
| 191 | if (updated.txId != null) txId = updated.txId; |
| 192 | } |
| 193 | |
| 194 | Map<String, dynamic> toSqliteMap() { |
| 195 | return <String, dynamic>{ |
| 196 | selfIdColumn: internalId, |
| 197 | 'id': id, |
| 198 | 'providerRaw': providerRaw, |
| 199 | 'fromTitle': from?.title, |
| 200 | 'fromName': from?.name, |
| 201 | 'fromTag': from?.tag, |
| 202 | 'fromFullName': from?.fullName, |
| 203 | 'fromDecimals': from?.decimals, |
| 204 | 'fromRaw': from?.raw, |
| 205 | 'fromIconPath': from?.iconPath, |
| 206 | 'fromFlatIconPath': from?.flatIconPath, |
| 207 | 'fromChainIconPath': from?.chainIconPath, |
| 208 | 'toTitle': to?.title, |
| 209 | 'toName': to?.name, |
| 210 | 'toTag': to?.tag, |
| 211 | 'toFullName': to?.fullName, |
| 212 | 'toDecimals': to?.decimals, |
| 213 | 'toRaw': to?.raw, |
| 214 | 'toIconPath': to?.iconPath, |
| 215 | 'toFlatIconPath': to?.flatIconPath, |
| 216 | 'toChainIconPath': to?.chainIconPath, |
| 217 | 'stateRaw': stateRaw, |
| 218 | 'createdAt': createdAt?.millisecondsSinceEpoch, |
| 219 | 'expiredAt': expiredAt?.millisecondsSinceEpoch, |
| 220 | 'amount': amount, |
| 221 | 'receiveAmount': receiveAmount, |
| 222 | 'inputAddress': inputAddress, |
| 223 | 'extraId': extraId, |
| 224 | 'outputTransaction': outputTransaction, |
| 225 | 'refundAddress': refundAddress, |
| 226 | 'walletId': walletId, |
| 227 | 'payoutAddress': payoutAddress, |
| 228 | 'toAddressExtraId': toAddressExtraId, |
| 229 | 'password': password, |
| 230 | 'providerId': providerId, |
| 231 | 'providerName': providerName, |
| 232 | 'fromWalletAddress': fromWalletAddress, |
| 233 | 'memo': memo, |
| 234 | 'txId': txId, |
| 235 | 'isRefund': isRefund == true ? 1 : 0, |
| 236 | 'isSendAll': isSendAll == true ? 1 : 0, |
| 237 | 'router': router, |
| 238 | 'needToRegisterInSwapXyz': needToRegisterInSwapXyz == true ? 1 : 0, |
| 239 | 'sourceTokenAddress': sourceTokenAddress, |
| 240 | 'sourceTokenDecimals': sourceTokenDecimals, |
| 241 | 'routerData': routerData, |
| 242 | 'routerValue': routerValue, |
| 243 | 'routerChainId': routerChainId, |
| 244 | 'sourceTokenAmountRaw': sourceTokenAmountRaw, |
| 245 | 'requiresTokenApproval': requiresTokenApproval == true ? 1 : 0, |
| 246 | 'chainId': chainId, |
| 247 | 'fee': fee, |
| 248 | }; |
| 249 | } |
| 250 | |
| 251 | factory Trade.fromSqliteRow(Map<String, dynamic> row) { |
| 252 | final trade = Trade( |
| 253 | id: row['id'] as String? ?? '', |
| 254 | amount: row['amount'] as String? ?? '', |
| 255 | receiveAmount: row['receiveAmount'] as String?, |
| 256 | createdAt: row['createdAt'] != null |
| 257 | ? DateTime.fromMillisecondsSinceEpoch( |
| 258 | row['createdAt'] as int, |
| 259 | ) |
| 260 | : null, |
| 261 | expiredAt: row['expiredAt'] != null |
| 262 | ? DateTime.fromMillisecondsSinceEpoch( |
| 263 | row['expiredAt'] as int, |
| 264 | ) |
| 265 | : null, |
| 266 | inputAddress: row['inputAddress'] as String?, |
| 267 | extraId: row['extraId'] as String?, |
| 268 | outputTransaction: row['outputTransaction'] as String?, |
| 269 | refundAddress: row['refundAddress'] as String?, |
| 270 | walletId: row['walletId'] as String?, |
| 271 | payoutAddress: row['payoutAddress'] as String?, |
| 272 | toAddressExtraId: row['toAddressExtraId'] as String?, |
| 273 | password: row['password'] as String?, |
| 274 | providerId: row['providerId'] as String?, |
| 275 | providerName: row['providerName'] as String?, |
| 276 | fromWalletAddress: row['fromWalletAddress'] as String?, |
| 277 | memo: row['memo'] as String?, |
| 278 | fee: row['fee'] as double?, |
| 279 | txId: row['txId'] as String?, |
| 280 | isRefund: (row['isRefund'] as int?) == 1, |
| 281 | isSendAll: (row['isSendAll'] as int?) == 1, |
| 282 | router: row['router'] as String?, |
| 283 | from: _currencyFromRow(row, 'from'), |
| 284 | to: _currencyFromRow(row, 'to'), |
| 285 | needToRegisterInSwapXyz: (row['needToRegisterInSwapXyz'] as int?) == 1, |
| 286 | sourceTokenAddress: row['sourceTokenAddress'] as String?, |
| 287 | sourceTokenDecimals: row['sourceTokenDecimals'] as int?, |
| 288 | routerData: row['routerData'] as String?, |
| 289 | routerValue: row['routerValue'] as String?, |
| 290 | routerChainId: row['routerChainId'] as int?, |
| 291 | sourceTokenAmountRaw: row['sourceTokenAmountRaw'] as String?, |
| 292 | requiresTokenApproval: (row['requiresTokenApproval'] as int?) == 1, |
| 293 | chainId: row['chainId'] as int?, |
| 294 | ); |
| 295 | trade.internalId = row[selfIdColumn] as int? ?? 0; |
| 296 | trade.providerRaw = row['providerRaw'] as int? ?? 0; |
| 297 | trade.stateRaw = row['stateRaw'] as String? ?? ''; |
| 298 | return trade; |
| 299 | } |
| 300 | |
| 301 | static CryptoCurrency? _currencyFromRow(Map<String, dynamic> row, String prefix) { |
| 302 | final title = row['${prefix}Title'] as String?; |
| 303 | if (title == null || title.isEmpty) return null; |
| 304 | |
| 305 | final tag = row['${prefix}Tag'] as String?; |
| 306 | |
| 307 | final live = CryptoCurrency.safeParseCurrencyFromString(title, tag: tag); |
| 308 | if (live != null) return live; |
| 309 | |
| 310 | return CryptoCurrency( |
| 311 | title: title, |
| 312 | name: row['${prefix}Name'] as String? ?? '', |
| 313 | tag: tag, |
| 314 | fullName: row['${prefix}FullName'] as String?, |
| 315 | decimals: row['${prefix}Decimals'] as int? ?? 1, |
| 316 | raw: row['${prefix}Raw'] as int? ?? -1, |
| 317 | iconPath: row['${prefix}IconPath'] as String?, |
| 318 | flatIconPath: row['${prefix}FlatIconPath'] as String?, |
| 319 | chainIconPath: row['${prefix}ChainIconPath'] as String?, |
| 320 | ); |
| 321 | } |
| 322 | |
| 323 | String amountFormatted() => formatAmount(amount); |
| 324 | String receiveAmountFormatted() => formatAmount(receiveAmount ?? ''); |
| 325 | } |