| 1 | import 'dart:convert'; |
| 2 | import 'dart:math'; |
| 3 | import 'dart:typed_data'; |
| 4 | |
| 5 | import 'package:cw_core/get_height_by_date_zec.dart'; |
| 6 | import 'package:cw_core/transaction_direction.dart'; |
| 7 | import 'package:cw_zcash/src/util/hex.dart'; |
| 8 | import 'package:zkool/src/rust/api/account.dart' as zkool_account; |
| 9 | |
| 10 | enum TxType { |
| 11 | unknown, |
| 12 | selfTransfer, // 0 |
| 13 | receive, // 1 |
| 14 | sent, // 2 |
| 15 | unshield, // 4 |
| 16 | shield, // 8 |
| 17 | transparentSelfTransfer, // 12 |
| 18 | } |
| 19 | |
| 20 | enum NotePool { transparent, sapling, orchard, ironwood } |
| 21 | |
| 22 | class ZkoolTx { |
| 23 | ZkoolTx(final zkool_account.Tx tx, final zkool_account.TxAccount txAccount) |
| 24 | : this._tx = tx, |
| 25 | this._txAccount = txAccount; |
| 26 | final zkool_account.Tx _tx; |
| 27 | final zkool_account.TxAccount _txAccount; |
| 28 | |
| 29 | int get height => max( |
| 30 | _tx.height, |
| 31 | _txAccount.height, |
| 32 | ); // sometimes returns 0 - higher number would be correct in this case |
| 33 | |
| 34 | DateTime get time { |
| 35 | final ts = max(_tx.time, _txAccount.time) * 1000; |
| 36 | if (ts != 0) { |
| 37 | return DateTime.fromMillisecondsSinceEpoch(ts); |
| 38 | } |
| 39 | if (height == 0) { |
| 40 | return DateTime.now(); |
| 41 | } |
| 42 | return ZcashHeight.getTimeByBlockHeight(height); |
| 43 | } |
| 44 | |
| 45 | String? get to => _txAccount.outputs.firstOrNull?.address; |
| 46 | Iterable<String> get outputAddresses => |
| 47 | _txAccount.outputs.map((final o) => o.address).whereType<String>(); |
| 48 | Iterable<({String address, int pool, BigInt value})> get outputsWithAddress => _txAccount.outputs |
| 49 | .where((final o) => o.address.isNotEmpty) |
| 50 | .map((final o) => (address: o.address, pool: o.pool, value: o.value)); |
| 51 | Iterable<int> get spendPools => _txAccount.spends.map((final s) => s.pool); |
| 52 | Iterable<int> get notePools => _txAccount.notes.map((final n) => n.pool); |
| 53 | String? get memo => _txAccount.memos.firstOrNull?.memo; |
| 54 | |
| 55 | BigInt get transparentOrSaplingSpent { |
| 56 | if (_txAccount.spends.isEmpty) { |
| 57 | return BigInt.zero; |
| 58 | } |
| 59 | return _txAccount.spends |
| 60 | .where( |
| 61 | (final s) => s.pool == NotePool.transparent.index || s.pool == NotePool.sapling.index, |
| 62 | ) |
| 63 | .fold(BigInt.zero, (final a, final s) => a + s.value); |
| 64 | } |
| 65 | |
| 66 | BigInt get orchardReceived { |
| 67 | final fromNotes = _txAccount.notes |
| 68 | .where((final n) => n.pool == NotePool.orchard.index) |
| 69 | .fold(BigInt.zero, (final a, final n) => a + n.value); |
| 70 | if (fromNotes > BigInt.zero) { |
| 71 | return fromNotes; |
| 72 | } |
| 73 | return _txAccount.outputs |
| 74 | .where((final o) => o.pool == NotePool.orchard.index) |
| 75 | .fold(BigInt.zero, (final a, final o) => a + o.value); |
| 76 | } |
| 77 | |
| 78 | BigInt get ironwoodReceived { |
| 79 | final fromNotes = _txAccount.notes |
| 80 | .where((final n) => n.pool == NotePool.ironwood.index) |
| 81 | .fold(BigInt.zero, (final a, final n) => a + n.value); |
| 82 | if (fromNotes > BigInt.zero) { |
| 83 | return fromNotes; |
| 84 | } |
| 85 | return _txAccount.outputs |
| 86 | .where((final o) => o.pool == NotePool.ironwood.index) |
| 87 | .fold(BigInt.zero, (final a, final o) => a + o.value); |
| 88 | } |
| 89 | |
| 90 | BigInt get orchardSpent => _txAccount.spends |
| 91 | .where((final s) => s.pool == NotePool.orchard.index) |
| 92 | .fold(BigInt.zero, (final a, final s) => a + s.value); |
| 93 | |
| 94 | String get txHash { |
| 95 | final reversed = Uint8List.fromList(_txAccount.txid.reversed.toList()); |
| 96 | final txId = uint8ListToHex(reversed); |
| 97 | return txId; |
| 98 | } |
| 99 | |
| 100 | BigInt get value => _value.abs(); |
| 101 | |
| 102 | BigInt get fee => BigInt.from(_tx.fee); |
| 103 | |
| 104 | |
| 105 | BigInt get _value { |
| 106 | if (_tx.value != 0) { |
| 107 | return BigInt.from(_tx.value); |
| 108 | } |
| 109 | |
| 110 | return _calcValue; |
| 111 | } |
| 112 | |
| 113 | /////////// calc is short for calculator (calculated in this case) |
| 114 | BigInt get _calcValue { |
| 115 | final noteSum = _txAccount.notes.isEmpty |
| 116 | ? BigInt.from(0) |
| 117 | : _txAccount.notes.map((final note) => note.value).reduce((final a, final b) => a + b); |
| 118 | final outputSum = _txAccount.outputs.isEmpty |
| 119 | ? BigInt.from(0) |
| 120 | : _txAccount.outputs |
| 121 | .map((final output) => output.value) |
| 122 | .reduce((final a, final b) => a + b); |
| 123 | final spentSum = _txAccount.spends.isEmpty |
| 124 | ? BigInt.from(0) |
| 125 | : _txAccount.spends.map((final spent) => spent.value).reduce((final a, final b) => a + b); |
| 126 | |
| 127 | return noteSum - outputSum - spentSum; |
| 128 | } |
| 129 | |
| 130 | TransactionDirection get direction { |
| 131 | switch (type) { |
| 132 | case TxType.selfTransfer: |
| 133 | case TxType.receive: |
| 134 | case TxType.unshield: |
| 135 | case TxType.shield: |
| 136 | case TxType.transparentSelfTransfer: |
| 137 | return TransactionDirection.incoming; |
| 138 | case TxType.sent: |
| 139 | return TransactionDirection.outgoing; |
| 140 | case TxType.unknown: |
| 141 | return _value > BigInt.from(0) |
| 142 | ? TransactionDirection.incoming |
| 143 | : TransactionDirection.outgoing; |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | TxType get type { |
| 148 | switch (_tx.tpe) { |
| 149 | case 0: |
| 150 | return TxType.selfTransfer; |
| 151 | case 1: |
| 152 | return TxType.receive; |
| 153 | case 2: |
| 154 | return TxType.sent; |
| 155 | case 4: |
| 156 | return TxType.unshield; |
| 157 | case 8: |
| 158 | return TxType.shield; |
| 159 | case 12: |
| 160 | return TxType.transparentSelfTransfer; |
| 161 | default: |
| 162 | return TxType.unknown; |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | Map<String, dynamic> toJson() => { |
| 167 | "tx": { |
| 168 | "id": _tx.id, |
| 169 | "txid": base64.encode(_tx.txid), |
| 170 | "height": _tx.height, |
| 171 | "time": _tx.time, |
| 172 | "value": value.toInt(), |
| 173 | |
| 174 | "tpe": _tx.tpe, |
| 175 | "zsaValue": _tx.zsaValue, |
| 176 | "assetDisplay": _tx.assetDisplay, |
| 177 | }, |
| 178 | "txaccount": { |
| 179 | "id": _txAccount.id, |
| 180 | "account": _txAccount.account, |
| 181 | "txid": base64.encode(_txAccount.txid), |
| 182 | "height": _txAccount.height, |
| 183 | "time": _txAccount.time, |
| 184 | "notes": _txAccount.notes |
| 185 | .map( |
| 186 | (final note) => { |
| 187 | "id": note.id, |
| 188 | "pool": note.pool, |
| 189 | "height": note.height, |
| 190 | "tx": note.tx, |
| 191 | "scope": note.scope, |
| 192 | "value": note.value.toString(), |
| 193 | "locked": note.locked, |
| 194 | }, |
| 195 | ) |
| 196 | .toList(), |
| 197 | "spends": _txAccount.spends |
| 198 | .map( |
| 199 | (final spend) => { |
| 200 | "id": spend.id, |
| 201 | "pool": spend.pool, |
| 202 | "height": spend.height, |
| 203 | "value": spend.value.toString(), |
| 204 | "assetDisplay": spend.assetDisplay, |
| 205 | }, |
| 206 | ) |
| 207 | .toList(), |
| 208 | "outputs": _txAccount.outputs |
| 209 | .map( |
| 210 | (final output) => { |
| 211 | "id": output.id, |
| 212 | "pool": output.pool, |
| 213 | "height": output.height, |
| 214 | "value": output.value.toString(), |
| 215 | "address": output.address, |
| 216 | }, |
| 217 | ) |
| 218 | .toList(), |
| 219 | "memos": _txAccount.memos |
| 220 | .map( |
| 221 | (final memo) => { |
| 222 | "note": memo.note, |
| 223 | "pool": memo.pool, |
| 224 | "output": memo.output, |
| 225 | "memo": memo.memo, |
| 226 | }, |
| 227 | ) |
| 228 | .toList(), |
| 229 | }, |
| 230 | }; |
| 231 | |
| 232 | static int _asInt(final dynamic value, {final int fallback = 0}) { |
| 233 | if (value == null) { |
| 234 | return fallback; |
| 235 | } |
| 236 | if (value is int) { |
| 237 | return value; |
| 238 | } |
| 239 | if (value is num) { |
| 240 | return value.toInt(); |
| 241 | } |
| 242 | return int.parse(value.toString()); |
| 243 | } |
| 244 | |
| 245 | static Uint8List _txidFromJson(final dynamic raw) { |
| 246 | return _bytesFromJson(raw, fieldName: "txid"); |
| 247 | } |
| 248 | |
| 249 | static Uint8List _bytesFromJson(final dynamic raw, {required final String fieldName}) { |
| 250 | if (raw == null) { |
| 251 | return Uint8List(0); |
| 252 | } |
| 253 | if (raw is Uint8List) { |
| 254 | return raw; |
| 255 | } |
| 256 | if (raw is List) { |
| 257 | return Uint8List.fromList(List<int>.from(raw)); |
| 258 | } |
| 259 | if (raw is String) { |
| 260 | return base64.decode(raw); |
| 261 | } |
| 262 | throw FormatException("Invalid $fieldName in ZkoolTx json: $raw"); |
| 263 | } |
| 264 | |
| 265 | static List<T> _listFromJson<T>(final dynamic raw, final T Function(Map<String, dynamic>) parse) { |
| 266 | if (raw is! List) { |
| 267 | return const []; |
| 268 | } |
| 269 | return raw.map((final entry) => parse(Map<String, dynamic>.from(entry as Map))).toList(); |
| 270 | } |
| 271 | |
| 272 | static ZkoolTx fromJson(final Map<String, dynamic> json) { |
| 273 | final txJson = Map<String, dynamic>.from((json["tx"] as Map?)?.cast<String, dynamic>() ?? json); |
| 274 | final txAccountJson = Map<String, dynamic>.from( |
| 275 | (json["txaccount"] as Map?)?.cast<String, dynamic>() ?? json, |
| 276 | ); |
| 277 | |
| 278 | return ZkoolTx( |
| 279 | zkool_account.Tx( |
| 280 | id: _asInt(txJson["id"]), |
| 281 | txid: _txidFromJson(txJson["txid"]), |
| 282 | height: _asInt(txJson["height"]), |
| 283 | time: _asInt(txJson["time"]), |
| 284 | value: _asInt(txJson["value"]), |
| 285 | fee: _asInt(txJson["fee"]), |
| 286 | tpe: txJson["tpe"] == null ? null : _asInt(txJson["tpe"]), |
| 287 | zsaValue: _asInt(txJson["zsaValue"]), |
| 288 | assetDisplay: txJson["assetDisplay"] as String? ?? "", |
| 289 | isUserMemo: txJson["isUserMemo"] as bool? ?? false, |
| 290 | ), |
| 291 | zkool_account.TxAccount( |
| 292 | id: _asInt(txAccountJson["id"]), |
| 293 | account: _asInt(txAccountJson["account"]), |
| 294 | txid: _txidFromJson(txAccountJson["txid"]), |
| 295 | height: _asInt(txAccountJson["height"]), |
| 296 | time: _asInt(txAccountJson["time"]), |
| 297 | notes: _listFromJson( |
| 298 | txAccountJson["notes"], |
| 299 | (final a) => zkool_account.TxNote( |
| 300 | id: _asInt(a["id"]), |
| 301 | pool: _asInt(a["pool"]), |
| 302 | height: _asInt(a["height"]), |
| 303 | tx: _asInt(a["tx"]), |
| 304 | value: BigInt.parse(a["value"].toString()), |
| 305 | scope: _asInt(a["scope"]), |
| 306 | locked: a["locked"] as bool? ?? false, |
| 307 | assetDisplay: a["assetDisplay"] as String? ?? "", |
| 308 | ), |
| 309 | ), |
| 310 | spends: _listFromJson( |
| 311 | txAccountJson["spends"], |
| 312 | (final a) => zkool_account.TxSpend( |
| 313 | id: _asInt(a["id"]), |
| 314 | pool: _asInt(a["pool"]), |
| 315 | height: _asInt(a["height"]), |
| 316 | value: BigInt.parse(a["value"].toString()), |
| 317 | assetDisplay: a["assetDisplay"] as String? ?? "", |
| 318 | ), |
| 319 | ), |
| 320 | outputs: _listFromJson( |
| 321 | txAccountJson["outputs"], |
| 322 | (final a) => zkool_account.TxOutput( |
| 323 | id: _asInt(a["id"]), |
| 324 | pool: _asInt(a["pool"]), |
| 325 | height: _asInt(a["height"]), |
| 326 | value: BigInt.parse(a["value"].toString()), |
| 327 | address: a["address"] as String? ?? "", |
| 328 | ), |
| 329 | ), |
| 330 | memos: _listFromJson( |
| 331 | txAccountJson["memos"], |
| 332 | (final a) => zkool_account.TxMemo( |
| 333 | note: a["note"] == null ? null : _asInt(a["note"]), |
| 334 | pool: _asInt(a["pool"]), |
| 335 | output: a["output"] == null ? null : _asInt(a["output"]), |
| 336 | memo: a["memo"] as String?, |
| 337 | memoBytes: _bytesFromJson(a["memoBytes"], fieldName: "memoBytes"), |
| 338 | ), |
| 339 | ), |
| 340 | ), |
| 341 | ); |
| 342 | } |
| 343 | } |