diff --git a/.gitignore b/.gitignore index f373c61fe1..05f3ede799 100644 --- a/.gitignore +++ b/.gitignore @@ -70,10 +70,6 @@ secp256k1.dll /lib/app_config.g.dart /android/app/src/main/app_icon-playstore.png -# Dart generated files (Freezed, Riverpod, GoRouter etc..) -lib/**/*.g.dart -lib/**/*.freezed.dart - ## other generated project files pubspec.yaml diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart index 94f27e1f8b..be46edf024 100644 --- a/lib/db/isar/main_db.dart +++ b/lib/db/isar/main_db.dart @@ -59,6 +59,7 @@ class MainDB { AddressSchema, AddressLabelSchema, EthContractSchema, + SolContractSchema, TransactionBlockExplorerSchema, StackThemeSchema, ContactEntrySchema, @@ -69,6 +70,7 @@ class MainDB { WalletInfoMetaSchema, TokenWalletInfoSchema, FrostWalletInfoSchema, + WalletSolanaTokenInfoSchema, ], directory: (await StackFileSystem.applicationIsarDirectory()).path, // inspector: kDebugMode, @@ -621,4 +623,26 @@ class MainDB { isar.writeTxn(() async { await isar.ethContracts.putAll(contracts); }); + + // ========== Solana ========================================================= + + // Solana tokens. + + QueryBuilder getSolContracts() => + isar.solContracts.where(); + + Future getSolContract(String tokenMint) => + isar.solContracts.where().addressEqualTo(tokenMint).findFirst(); + + SolContract? getSolContractSync(String tokenMint) => + isar.solContracts.where().addressEqualTo(tokenMint).findFirstSync(); + + Future putSolContract(SolContract token) => isar.writeTxn(() async { + return await isar.solContracts.put(token); + }); + + Future putSolContracts(List tokens) => + isar.writeTxn(() async { + await isar.solContracts.putAll(tokens); + }); } diff --git a/lib/models/add_wallet_list_entity/sub_classes/sol_token_entity.dart b/lib/models/add_wallet_list_entity/sub_classes/sol_token_entity.dart new file mode 100644 index 0000000000..1f4c01ff06 --- /dev/null +++ b/lib/models/add_wallet_list_entity/sub_classes/sol_token_entity.dart @@ -0,0 +1,31 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../isar/models/solana/sol_contract.dart'; +import '../add_wallet_list_entity.dart'; + +class SolTokenEntity extends AddWalletListEntity { + SolTokenEntity(this.token); + + final SolContract token; + + @override + CryptoCurrency get cryptoCurrency => Solana(CryptoCurrencyNetwork.main); + + @override + String get name => token.name; + + @override + String get ticker => token.symbol; + + @override + List get props => + [cryptoCurrency.identifier, name, ticker, token.address]; +} diff --git a/lib/models/balance.dart b/lib/models/balance.dart index 9680412549..4fc728ade0 100644 --- a/lib/models/balance.dart +++ b/lib/models/balance.dart @@ -19,19 +19,18 @@ class Balance { final Amount blockedTotal; final Amount pendingSpendable; - Balance({ + const Balance({ required this.total, required this.spendable, required this.blockedTotal, required this.pendingSpendable, }); - factory Balance.zeroFor({required CryptoCurrency currency}) { - final amount = Amount( - rawValue: BigInt.zero, - fractionDigits: currency.fractionDigits, - ); + factory Balance.zeroFor({required CryptoCurrency currency}) => + .zeroWith(fractionDigits: currency.fractionDigits); + factory Balance.zeroWith({required int fractionDigits}) { + final amount = Amount.zeroWith(fractionDigits: fractionDigits); return Balance( total: amount, spendable: amount, @@ -41,11 +40,11 @@ class Balance { } String toJsonIgnoreCoin() => jsonEncode({ - "total": total.toJsonString(), - "spendable": spendable.toJsonString(), - "blockedTotal": blockedTotal.toJsonString(), - "pendingSpendable": pendingSpendable.toJsonString(), - }); + "total": total.toJsonString(), + "spendable": spendable.toJsonString(), + "blockedTotal": blockedTotal.toJsonString(), + "pendingSpendable": pendingSpendable.toJsonString(), + }); // need to fall back to parsing from int due to cached balances being previously // stored as int values instead of Amounts @@ -82,11 +81,11 @@ class Balance { } Map toMap() => { - "total": total, - "spendable": spendable, - "blockedTotal": blockedTotal, - "pendingSpendable": pendingSpendable, - }; + "total": total, + "spendable": spendable, + "blockedTotal": blockedTotal, + "pendingSpendable": pendingSpendable, + }; @override String toString() { diff --git a/lib/models/isar/models/blockchain_data/transaction.dart b/lib/models/isar/models/blockchain_data/transaction.dart index 3e43ffb219..a4c808bfbc 100644 --- a/lib/models/isar/models/blockchain_data/transaction.dart +++ b/lib/models/isar/models/blockchain_data/transaction.dart @@ -261,4 +261,5 @@ enum TransactionSubType { sparkSpend, // firo specific ordinal, mweb, + splToken, // Solana token. } diff --git a/lib/models/isar/models/blockchain_data/transaction.g.dart b/lib/models/isar/models/blockchain_data/transaction.g.dart index aa41d834b7..c87673a99a 100644 --- a/lib/models/isar/models/blockchain_data/transaction.g.dart +++ b/lib/models/isar/models/blockchain_data/transaction.g.dart @@ -356,6 +356,7 @@ const _TransactionsubTypeEnumValueMap = { 'sparkSpend': 7, 'ordinal': 8, 'mweb': 9, + 'splToken': 10, }; const _TransactionsubTypeValueEnumMap = { 0: TransactionSubType.none, @@ -368,6 +369,7 @@ const _TransactionsubTypeValueEnumMap = { 7: TransactionSubType.sparkSpend, 8: TransactionSubType.ordinal, 9: TransactionSubType.mweb, + 10: TransactionSubType.splToken, }; const _TransactiontypeEnumValueMap = { 'outgoing': 0, diff --git a/lib/models/isar/models/blockchain_data/v2/transaction_v2.g.dart b/lib/models/isar/models/blockchain_data/v2/transaction_v2.g.dart index 1335d783a2..c604f36f7c 100644 --- a/lib/models/isar/models/blockchain_data/v2/transaction_v2.g.dart +++ b/lib/models/isar/models/blockchain_data/v2/transaction_v2.g.dart @@ -378,6 +378,7 @@ const _TransactionV2subTypeEnumValueMap = { 'sparkSpend': 7, 'ordinal': 8, 'mweb': 9, + 'splToken': 10, }; const _TransactionV2subTypeValueEnumMap = { 0: TransactionSubType.none, @@ -390,6 +391,7 @@ const _TransactionV2subTypeValueEnumMap = { 7: TransactionSubType.sparkSpend, 8: TransactionSubType.ordinal, 9: TransactionSubType.mweb, + 10: TransactionSubType.splToken, }; const _TransactionV2typeEnumValueMap = { 'outgoing': 0, diff --git a/lib/models/isar/models/contract.dart b/lib/models/isar/models/contract.dart index 3260df084a..a11383af1e 100644 --- a/lib/models/isar/models/contract.dart +++ b/lib/models/isar/models/contract.dart @@ -9,5 +9,15 @@ */ abstract class Contract { - // for possible future use + /// Token/contract address (mint address for Solana, contract address for Ethereum). + String get address; + + /// Token name. + String get name; + + /// Token symbol. + String get symbol; + + /// Token decimals. + int get decimals; } diff --git a/lib/models/isar/models/ethereum/eth_contract.dart b/lib/models/isar/models/ethereum/eth_contract.dart index adaec31226..ad98f29887 100644 --- a/lib/models/isar/models/ethereum/eth_contract.dart +++ b/lib/models/isar/models/ethereum/eth_contract.dart @@ -9,6 +9,7 @@ */ import 'package:isar_community/isar.dart'; + import '../contract.dart'; part 'eth_contract.g.dart'; @@ -26,13 +27,17 @@ class EthContract extends Contract { Id id = Isar.autoIncrement; + @override @Index(unique: true, replace: true) late final String address; + @override late final String name; + @override late final String symbol; + @override late final int decimals; late final String? abi; @@ -50,21 +55,16 @@ class EthContract extends Contract { List? walletIds, String? abi, String? otherData, - }) => - EthContract( - address: address ?? this.address, - name: name ?? this.name, - symbol: symbol ?? this.symbol, - decimals: decimals ?? this.decimals, - type: type ?? this.type, - abi: abi ?? this.abi, - )..id = id ?? this.id; + }) => EthContract( + address: address ?? this.address, + name: name ?? this.name, + symbol: symbol ?? this.symbol, + decimals: decimals ?? this.decimals, + type: type ?? this.type, + abi: abi ?? this.abi, + )..id = id ?? this.id; } // Used in Isar db and stored there as int indexes so adding/removing values // in this definition should be done extremely carefully in production -enum EthContractType { - unknown, - erc20, - erc721; -} +enum EthContractType { unknown, erc20, erc721 } diff --git a/lib/models/isar/models/isar_models.dart b/lib/models/isar/models/isar_models.dart index ce7652a466..cf27091bf1 100644 --- a/lib/models/isar/models/isar_models.dart +++ b/lib/models/isar/models/isar_models.dart @@ -16,4 +16,6 @@ export 'blockchain_data/transaction.dart'; export 'blockchain_data/utxo.dart'; export 'ethereum/eth_contract.dart'; export 'log.dart'; +export 'solana/sol_contract.dart'; export 'transaction_note.dart'; +export '../../../wallets/isar/models/wallet_solana_token_info.dart'; diff --git a/lib/models/isar/models/solana/sol_contract.dart b/lib/models/isar/models/solana/sol_contract.dart new file mode 100644 index 0000000000..ba2493e2a5 --- /dev/null +++ b/lib/models/isar/models/solana/sol_contract.dart @@ -0,0 +1,62 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:isar_community/isar.dart'; + +import '../contract.dart'; + +part 'sol_contract.g.dart'; + +@collection +class SolContract extends Contract { + SolContract({ + required this.address, + required this.name, + required this.symbol, + required this.decimals, + this.logoUri, + this.metadataAddress, + }); + + Id id = Isar.autoIncrement; + + @override + @Index(unique: true, replace: true) + late final String address; // Mint address. + + @override + late final String name; + + @override + late final String symbol; + + @override + late final int decimals; + + late final String? logoUri; + + late final String? metadataAddress; + + SolContract copyWith({ + Id? id, + String? address, + String? name, + String? symbol, + int? decimals, + String? logoUri, + String? metadataAddress, + }) => SolContract( + address: address ?? this.address, + name: name ?? this.name, + symbol: symbol ?? this.symbol, + decimals: decimals ?? this.decimals, + logoUri: logoUri ?? this.logoUri, + metadataAddress: metadataAddress ?? this.metadataAddress, + )..id = id ?? this.id; +} diff --git a/lib/models/isar/models/solana/sol_contract.g.dart b/lib/models/isar/models/solana/sol_contract.g.dart new file mode 100644 index 0000000000..b75906e254 --- /dev/null +++ b/lib/models/isar/models/solana/sol_contract.g.dart @@ -0,0 +1,1498 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sol_contract.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetSolContractCollection on Isar { + IsarCollection get solContracts => this.collection(); +} + +const SolContractSchema = CollectionSchema( + name: r'SolContract', + id: 1474803837279318906, + properties: { + r'address': PropertySchema(id: 0, name: r'address', type: IsarType.string), + r'decimals': PropertySchema(id: 1, name: r'decimals', type: IsarType.long), + r'logoUri': PropertySchema(id: 2, name: r'logoUri', type: IsarType.string), + r'metadataAddress': PropertySchema( + id: 3, + name: r'metadataAddress', + type: IsarType.string, + ), + r'name': PropertySchema(id: 4, name: r'name', type: IsarType.string), + r'symbol': PropertySchema(id: 5, name: r'symbol', type: IsarType.string), + }, + + estimateSize: _solContractEstimateSize, + serialize: _solContractSerialize, + deserialize: _solContractDeserialize, + deserializeProp: _solContractDeserializeProp, + idName: r'id', + indexes: { + r'address': IndexSchema( + id: -259407546592846288, + name: r'address', + unique: true, + replace: true, + properties: [ + IndexPropertySchema( + name: r'address', + type: IndexType.hash, + caseSensitive: true, + ), + ], + ), + }, + links: {}, + embeddedSchemas: {}, + + getId: _solContractGetId, + getLinks: _solContractGetLinks, + attach: _solContractAttach, + version: '3.3.0-dev.2', +); + +int _solContractEstimateSize( + SolContract object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + bytesCount += 3 + object.address.length * 3; + { + final value = object.logoUri; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + { + final value = object.metadataAddress; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + bytesCount += 3 + object.name.length * 3; + bytesCount += 3 + object.symbol.length * 3; + return bytesCount; +} + +void _solContractSerialize( + SolContract object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeString(offsets[0], object.address); + writer.writeLong(offsets[1], object.decimals); + writer.writeString(offsets[2], object.logoUri); + writer.writeString(offsets[3], object.metadataAddress); + writer.writeString(offsets[4], object.name); + writer.writeString(offsets[5], object.symbol); +} + +SolContract _solContractDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = SolContract( + address: reader.readString(offsets[0]), + decimals: reader.readLong(offsets[1]), + logoUri: reader.readStringOrNull(offsets[2]), + metadataAddress: reader.readStringOrNull(offsets[3]), + name: reader.readString(offsets[4]), + symbol: reader.readString(offsets[5]), + ); + object.id = id; + return object; +} + +P _solContractDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readString(offset)) as P; + case 1: + return (reader.readLong(offset)) as P; + case 2: + return (reader.readStringOrNull(offset)) as P; + case 3: + return (reader.readStringOrNull(offset)) as P; + case 4: + return (reader.readString(offset)) as P; + case 5: + return (reader.readString(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _solContractGetId(SolContract object) { + return object.id; +} + +List> _solContractGetLinks(SolContract object) { + return []; +} + +void _solContractAttach( + IsarCollection col, + Id id, + SolContract object, +) { + object.id = id; +} + +extension SolContractByIndex on IsarCollection { + Future getByAddress(String address) { + return getByIndex(r'address', [address]); + } + + SolContract? getByAddressSync(String address) { + return getByIndexSync(r'address', [address]); + } + + Future deleteByAddress(String address) { + return deleteByIndex(r'address', [address]); + } + + bool deleteByAddressSync(String address) { + return deleteByIndexSync(r'address', [address]); + } + + Future> getAllByAddress(List addressValues) { + final values = addressValues.map((e) => [e]).toList(); + return getAllByIndex(r'address', values); + } + + List getAllByAddressSync(List addressValues) { + final values = addressValues.map((e) => [e]).toList(); + return getAllByIndexSync(r'address', values); + } + + Future deleteAllByAddress(List addressValues) { + final values = addressValues.map((e) => [e]).toList(); + return deleteAllByIndex(r'address', values); + } + + int deleteAllByAddressSync(List addressValues) { + final values = addressValues.map((e) => [e]).toList(); + return deleteAllByIndexSync(r'address', values); + } + + Future putByAddress(SolContract object) { + return putByIndex(r'address', object); + } + + Id putByAddressSync(SolContract object, {bool saveLinks = true}) { + return putByIndexSync(r'address', object, saveLinks: saveLinks); + } + + Future> putAllByAddress(List objects) { + return putAllByIndex(r'address', objects); + } + + List putAllByAddressSync( + List objects, { + bool saveLinks = true, + }) { + return putAllByIndexSync(r'address', objects, saveLinks: saveLinks); + } +} + +extension SolContractQueryWhereSort + on QueryBuilder { + QueryBuilder anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension SolContractQueryWhere + on QueryBuilder { + QueryBuilder idEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between(lower: id, upper: id)); + }); + } + + QueryBuilder idNotEqualTo( + Id id, + ) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ); + } + }); + } + + QueryBuilder idGreaterThan( + Id id, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder idLessThan( + Id id, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder idBetween( + Id lowerId, + Id upperId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.between( + lower: lowerId, + includeLower: includeLower, + upper: upperId, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder addressEqualTo( + String address, + ) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IndexWhereClause.equalTo(indexName: r'address', value: [address]), + ); + }); + } + + QueryBuilder addressNotEqualTo( + String address, + ) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'address', + lower: [], + upper: [address], + includeUpper: false, + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'address', + lower: [address], + includeLower: false, + upper: [], + ), + ); + } else { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'address', + lower: [address], + includeLower: false, + upper: [], + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'address', + lower: [], + upper: [address], + includeUpper: false, + ), + ); + } + }); + } +} + +extension SolContractQueryFilter + on QueryBuilder { + QueryBuilder addressEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + addressGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'address', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + addressStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressContains( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'address', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder addressMatches( + String pattern, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'address', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + addressIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'address', value: ''), + ); + }); + } + + QueryBuilder + addressIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'address', value: ''), + ); + }); + } + + QueryBuilder decimalsEqualTo( + int value, + ) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'decimals', value: value), + ); + }); + } + + QueryBuilder + decimalsGreaterThan(int value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'decimals', + value: value, + ), + ); + }); + } + + QueryBuilder + decimalsLessThan(int value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'decimals', + value: value, + ), + ); + }); + } + + QueryBuilder decimalsBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'decimals', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder idEqualTo( + Id value, + ) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'id', value: value), + ); + }); + } + + QueryBuilder idGreaterThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder idLessThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder idBetween( + Id lower, + Id upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'id', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder + logoUriIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNull(property: r'logoUri'), + ); + }); + } + + QueryBuilder + logoUriIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNotNull(property: r'logoUri'), + ); + }); + } + + QueryBuilder logoUriEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + logoUriGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'logoUri', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + logoUriStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriContains( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'logoUri', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder logoUriMatches( + String pattern, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'logoUri', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + logoUriIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'logoUri', value: ''), + ); + }); + } + + QueryBuilder + logoUriIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'logoUri', value: ''), + ); + }); + } + + QueryBuilder + metadataAddressIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNull(property: r'metadataAddress'), + ); + }); + } + + QueryBuilder + metadataAddressIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNotNull(property: r'metadataAddress'), + ); + }); + } + + QueryBuilder + metadataAddressEqualTo(String? value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'metadataAddress', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'metadataAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'metadataAddress', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + metadataAddressIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'metadataAddress', value: ''), + ); + }); + } + + QueryBuilder + metadataAddressIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'metadataAddress', value: ''), + ); + }); + } + + QueryBuilder nameEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'name', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameContains( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'name', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameMatches( + String pattern, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'name', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder nameIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'name', value: ''), + ); + }); + } + + QueryBuilder + nameIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'name', value: ''), + ); + }); + } + + QueryBuilder symbolEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + symbolGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'symbol', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + symbolStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolContains( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'symbol', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder symbolMatches( + String pattern, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'symbol', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + symbolIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'symbol', value: ''), + ); + }); + } + + QueryBuilder + symbolIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'symbol', value: ''), + ); + }); + } +} + +extension SolContractQueryObject + on QueryBuilder {} + +extension SolContractQueryLinks + on QueryBuilder {} + +extension SolContractQuerySortBy + on QueryBuilder { + QueryBuilder sortByAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'address', Sort.asc); + }); + } + + QueryBuilder sortByAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'address', Sort.desc); + }); + } + + QueryBuilder sortByDecimals() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'decimals', Sort.asc); + }); + } + + QueryBuilder sortByDecimalsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'decimals', Sort.desc); + }); + } + + QueryBuilder sortByLogoUri() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'logoUri', Sort.asc); + }); + } + + QueryBuilder sortByLogoUriDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'logoUri', Sort.desc); + }); + } + + QueryBuilder sortByMetadataAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'metadataAddress', Sort.asc); + }); + } + + QueryBuilder + sortByMetadataAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'metadataAddress', Sort.desc); + }); + } + + QueryBuilder sortByName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.asc); + }); + } + + QueryBuilder sortByNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.desc); + }); + } + + QueryBuilder sortBySymbol() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'symbol', Sort.asc); + }); + } + + QueryBuilder sortBySymbolDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'symbol', Sort.desc); + }); + } +} + +extension SolContractQuerySortThenBy + on QueryBuilder { + QueryBuilder thenByAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'address', Sort.asc); + }); + } + + QueryBuilder thenByAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'address', Sort.desc); + }); + } + + QueryBuilder thenByDecimals() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'decimals', Sort.asc); + }); + } + + QueryBuilder thenByDecimalsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'decimals', Sort.desc); + }); + } + + QueryBuilder thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } + + QueryBuilder thenByLogoUri() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'logoUri', Sort.asc); + }); + } + + QueryBuilder thenByLogoUriDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'logoUri', Sort.desc); + }); + } + + QueryBuilder thenByMetadataAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'metadataAddress', Sort.asc); + }); + } + + QueryBuilder + thenByMetadataAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'metadataAddress', Sort.desc); + }); + } + + QueryBuilder thenByName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.asc); + }); + } + + QueryBuilder thenByNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.desc); + }); + } + + QueryBuilder thenBySymbol() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'symbol', Sort.asc); + }); + } + + QueryBuilder thenBySymbolDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'symbol', Sort.desc); + }); + } +} + +extension SolContractQueryWhereDistinct + on QueryBuilder { + QueryBuilder distinctByAddress({ + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'address', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByDecimals() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'decimals'); + }); + } + + QueryBuilder distinctByLogoUri({ + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'logoUri', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByMetadataAddress({ + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy( + r'metadataAddress', + caseSensitive: caseSensitive, + ); + }); + } + + QueryBuilder distinctByName({ + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'name', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctBySymbol({ + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'symbol', caseSensitive: caseSensitive); + }); + } +} + +extension SolContractQueryProperty + on QueryBuilder { + QueryBuilder idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } + + QueryBuilder addressProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'address'); + }); + } + + QueryBuilder decimalsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'decimals'); + }); + } + + QueryBuilder logoUriProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'logoUri'); + }); + } + + QueryBuilder + metadataAddressProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'metadataAddress'); + }); + } + + QueryBuilder nameProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'name'); + }); + } + + QueryBuilder symbolProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'symbol'); + }); + } +} diff --git a/lib/pages/add_wallet_views/add_token_view/add_custom_solana_token_view.dart b/lib/pages/add_wallet_views/add_token_view/add_custom_solana_token_view.dart new file mode 100644 index 0000000000..c5f13088b8 --- /dev/null +++ b/lib/pages/add_wallet_views/add_token_view/add_custom_solana_token_view.dart @@ -0,0 +1,525 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../models/isar/models/solana/sol_contract.dart'; +import '../../../services/solana/solana_token_api.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../widgets/background.dart'; +import '../../../widgets/conditional_parent.dart'; +import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../widgets/desktop/desktop_dialog.dart'; +import '../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/stack_dialog.dart'; + +class AddCustomSolanaTokenView extends ConsumerStatefulWidget { + const AddCustomSolanaTokenView({super.key, this.walletId}); + + static const routeName = "/addCustomSolanaToken"; + + final String? walletId; + + @override + ConsumerState createState() => + _AddCustomSolanaTokenViewState(); +} + +class _AddCustomSolanaTokenViewState + extends ConsumerState { + final isDesktop = Util.isDesktop; + + final mintController = TextEditingController(); + final nameController = TextEditingController(); + final symbolController = TextEditingController(); + final decimalsController = TextEditingController(); + + bool enableSubFields = false; + bool addTokenButtonEnabled = false; + + SolContract? currentToken; + + Future _searchTokenMetadata() async { + debugPrint('[ADD_CUSTOM_SOLANA_TOKEN] Search button pressed'); + + // Validate mint address format first. + final tokenApi = SolanaTokenAPI(); + final isValid = tokenApi.isValidSolanaMintAddress( + mintController.text.trim(), + ); + + debugPrint('[ADD_CUSTOM_SOLANA_TOKEN] Mint address valid: $isValid'); + + // Check if token is already in the wallet. + if (widget.walletId != null) { + final walletInfo = ref.read(pWalletInfo(widget.walletId!)); + final allTokenMints = { + ...walletInfo.solanaTokenMintAddresses, + ...walletInfo.solanaCustomTokenMintAddresses, + }; + + if (allTokenMints.contains(mintController.text.trim())) { + debugPrint('[ADD_CUSTOM_SOLANA_TOKEN] Token already in wallet'); + setState(() { + addTokenButtonEnabled = false; + }); + // Show error dialog for duplicate token. + if (mounted) { + unawaited( + showDialog( + context: context, + builder: (dialogContext) => ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopDialog( + maxWidth: 500, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.all(32), + child: child, + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => StackDialogBase(child: child), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Token Already Added", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 8), + Text( + "This token is already in your wallet.", + style: STextStyles.smallMed14(context), + ), + const SizedBox(height: 20), + Row( + children: [ + const Spacer(), + Expanded( + child: PrimaryButton( + label: "OK", + onPressed: () => + Navigator.of(dialogContext).pop(), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + return; + } + } + + if (!isValid) { + setState(() { + addTokenButtonEnabled = false; + }); + // Show error dialog for invalid address. + if (mounted) { + unawaited( + showDialog( + context: context, + builder: (dialogContext) => ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopDialog( + maxWidth: 500, + maxHeight: double.infinity, + child: Padding(padding: const EdgeInsets.all(32), child: child), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => StackDialogBase(child: child), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Invalid Mint Address", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 8), + Text( + "Please enter a valid Solana token mint address " + "(base58 encoded, ~44 characters).", + style: STextStyles.smallMed14(context), + ), + const SizedBox(height: 20), + Row( + children: [ + const Spacer(), + Expanded( + child: PrimaryButton( + label: "OK", + onPressed: () => Navigator.of(dialogContext).pop(), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + return; + } + + // Fetch token metadata. + debugPrint( + '[ADD_CUSTOM_SOLANA_TOKEN] Fetching metadata for' + ' mint: ${mintController.text.trim()}', + ); + final response = await tokenApi.fetchTokenMetadataByMint( + mintController.text.trim(), + ); + + if (!mounted) return; + + debugPrint( + '[ADD_CUSTOM_SOLANA_TOKEN] Metadata response: ${response.value}', + ); + + if (response.value != null && response.value!.isNotEmpty) { + final metadata = response.value!; + currentToken = SolContract( + address: mintController.text.trim(), + name: metadata['name'] as String? ?? 'Unknown Token', + symbol: metadata['symbol'] as String? ?? '???', + decimals: int.tryParse(metadata['decimals']?.toString() ?? "") ?? 6, + logoUri: metadata['logoUri'] as String?, + ); + + nameController.text = currentToken!.name; + symbolController.text = currentToken!.symbol; + decimalsController.text = currentToken!.decimals.toString(); + + // Disable editing when we have metadata. + setState(() { + enableSubFields = false; + addTokenButtonEnabled = currentToken != null; + }); + debugPrint('[ADD_CUSTOM_SOLANA_TOKEN] Metadata found, fields populated'); + } else { + // Token not found, allow user to manually enter details. + debugPrint( + '[ADD_CUSTOM_SOLANA_TOKEN] Metadata not found, enabling manual entry', + ); + nameController.text = ""; + symbolController.text = ""; + decimalsController.text = ""; + + // Enable fields for manual entry and allow user to create token with + // custom values. + setState(() { + enableSubFields = true; + currentToken = SolContract( + address: mintController.text.trim(), + name: '', + symbol: '', + decimals: 6, + logoUri: null, + ); + // Allow adding token once mint is validated. + addTokenButtonEnabled = true; + }); + + // Show dialog for manual entry & alert the user they need to enter + // details manually. + if (mounted) { + unawaited( + showDialog( + context: context, + builder: (dialogContext) => ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopDialog( + maxWidth: 500, + maxHeight: double.infinity, + child: Padding(padding: const EdgeInsets.all(32), child: child), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => StackDialogBase(child: child), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Metadata Not Found", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 8), + Text( + "Could not fetch token metadata. Please enter the token" + " details manually below.", + style: STextStyles.smallMed14(context), + ), + const SizedBox(height: 20), + Row( + children: [ + const Spacer(), + Expanded( + child: PrimaryButton( + label: "OK", + onPressed: () => Navigator.of(dialogContext).pop(), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only( + top: 10, + left: 16, + right: 16, + bottom: 16, + ), + child: child, + ), + ), + ), + ), + child: ConditionalParent( + condition: isDesktop, + builder: (child) => Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Add custom SOL token", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + top: 16, + ), + child: child, + ), + ), + ], + ), + child: Column( + children: [ + if (!isDesktop) + Text( + "Add custom SOL token", + style: STextStyles.pageTitleH1(context), + ), + if (!isDesktop) const SizedBox(height: 16), + TextField( + autocorrect: !isDesktop, + enableSuggestions: !isDesktop, + controller: mintController, + style: STextStyles.field(context), + decoration: InputDecoration( + hintText: "SOL token mint address", + hintStyle: STextStyles.fieldLabel(context), + ), + ), + SizedBox(height: isDesktop ? 16 : 8), + PrimaryButton(label: "Search", onPressed: _searchTokenMetadata), + SizedBox(height: isDesktop ? 16 : 8), + TextField( + enabled: enableSubFields, + autocorrect: !isDesktop, + enableSuggestions: !isDesktop, + controller: nameController, + style: STextStyles.field(context), + decoration: InputDecoration( + hintText: "Token name", + hintStyle: STextStyles.fieldLabel(context), + ), + ), + SizedBox(height: isDesktop ? 16 : 8), + if (isDesktop) + Row( + children: [ + Expanded( + child: TextField( + enabled: enableSubFields, + autocorrect: !isDesktop, + enableSuggestions: !isDesktop, + controller: symbolController, + style: STextStyles.field(context), + decoration: InputDecoration( + hintText: "Ticker", + hintStyle: STextStyles.fieldLabel(context), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextField( + enabled: enableSubFields, + autocorrect: !isDesktop, + enableSuggestions: !isDesktop, + controller: decimalsController, + style: STextStyles.field(context), + inputFormatters: [ + TextInputFormatter.withFunction( + (oldValue, newValue) => + RegExp(r'^([0-9]*)$').hasMatch(newValue.text) + ? newValue + : oldValue, + ), + ], + keyboardType: const TextInputType.numberWithOptions( + signed: false, + decimal: false, + ), + decoration: InputDecoration( + hintText: "Decimals", + hintStyle: STextStyles.fieldLabel(context), + ), + ), + ), + ], + ), + if (!isDesktop) + TextField( + enabled: enableSubFields, + autocorrect: !isDesktop, + enableSuggestions: !isDesktop, + controller: symbolController, + style: STextStyles.field(context), + decoration: InputDecoration( + hintText: "Ticker", + hintStyle: STextStyles.fieldLabel(context), + ), + ), + if (!isDesktop) const SizedBox(height: 8), + if (!isDesktop) + TextField( + enabled: enableSubFields, + autocorrect: !isDesktop, + enableSuggestions: !isDesktop, + controller: decimalsController, + style: STextStyles.field(context), + inputFormatters: [ + TextInputFormatter.withFunction( + (oldValue, newValue) => + RegExp(r'^([0-9]*)$').hasMatch(newValue.text) + ? newValue + : oldValue, + ), + ], + keyboardType: const TextInputType.numberWithOptions( + signed: false, + decimal: false, + ), + decoration: InputDecoration( + hintText: "Decimals", + hintStyle: STextStyles.fieldLabel(context), + ), + ), + const SizedBox(height: 16), + const Spacer(), + Row( + children: [ + if (isDesktop) + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ), + if (isDesktop) const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Add token", + enabled: addTokenButtonEnabled, + onPressed: () { + // Update currentToken with user-entered values. + if (currentToken != null) { + final finalToken = currentToken!.copyWith( + name: nameController.text.isNotEmpty + ? nameController.text + : currentToken!.name, + symbol: symbolController.text.isNotEmpty + ? symbolController.text + : currentToken!.symbol, + decimals: decimalsController.text.isNotEmpty + ? int.tryParse(decimalsController.text) ?? + currentToken!.decimals + : currentToken!.decimals, + ); + Navigator.of(context).pop(finalToken); + } + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + + @override + void dispose() { + mintController.dispose(); + nameController.dispose(); + symbolController.dispose(); + decimalsController.dispose(); + super.dispose(); + } +} diff --git a/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart b/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart index b1f07cec75..d81afe51e5 100644 --- a/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart +++ b/lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart @@ -17,6 +17,7 @@ import 'package:isar_community/isar.dart'; import '../../../db/isar/main_db.dart'; import '../../../models/isar/models/ethereum/eth_contract.dart'; +import '../../../models/isar/models/solana/sol_contract.dart'; import '../../../notifications/show_flush_bar.dart'; import '../../../pages_desktop_specific/desktop_home_view.dart'; import '../../../providers/global/price_provider.dart'; @@ -25,10 +26,12 @@ import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; import '../../../utilities/constants.dart'; import '../../../utilities/default_eth_tokens.dart'; +import '../../../utilities/default_sol_tokens.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../wallets/wallet/impl/ethereum_wallet.dart'; +import '../../../wallets/wallet/impl/solana_wallet.dart'; import '../../../widgets/background.dart'; import '../../../widgets/conditional_parent.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -44,6 +47,7 @@ import '../../../widgets/stack_text_field.dart'; import '../../../widgets/textfield_icon_button.dart'; import '../../home_view/home_view.dart'; import 'add_custom_token_view.dart'; +import 'add_custom_solana_token_view.dart'; import 'sub_widgets/add_token_list.dart'; import 'sub_widgets/add_token_list_element.dart'; import 'sub_widgets/add_token_text.dart'; @@ -102,10 +106,14 @@ class _EditWalletTokensViewState extends ConsumerState { .map((e) => e.token.address) .toList(); - final ethWallet = - ref.read(pWallets).getWallet(widget.walletId) as EthereumWallet; + final wallet = ref.read(pWallets).getWallet(widget.walletId); - await ethWallet.updateTokenContracts(selectedTokens); + // Handle tokens. + if (wallet is EthereumWallet) { + await wallet.updateTokenContracts(selectedTokens); + } else if (wallet is SolanaWallet) { + await wallet.updateSolanaTokens(selectedTokens); + } if (mounted) { if (widget.contractsToMarkSelected == null) { Navigator.of(context).pop(42); @@ -123,7 +131,7 @@ class _EditWalletTokensViewState extends ConsumerState { unawaited( showFloatingFlushBar( type: FlushBarType.success, - message: "${ethWallet.info.name} tokens saved", + message: "${wallet.info.name} tokens saved", context: context, ), ); @@ -133,35 +141,95 @@ class _EditWalletTokensViewState extends ConsumerState { } Future _addToken() async { - EthContract? contract; + final wallet = ref.read(pWallets).getWallet(widget.walletId); + + if (wallet is SolanaWallet) { + // For Solana wallets, navigate to custom token addition screen. + await _addCustomSolanaToken(); + } else { + // Original Ethereum token handling. + EthContract? contract; + + if (isDesktop) { + contract = await showDialog( + context: context, + builder: (context) => const DesktopDialog( + maxWidth: 580, + maxHeight: 500, + child: AddCustomTokenView(), + ), + ); + } else { + final result = await Navigator.of( + context, + ).pushNamed(AddCustomTokenView.routeName); + contract = result as EthContract?; + } + + if (contract != null) { + await MainDB.instance.putEthContract(contract); + unawaited(ref.read(priceAnd24hChangeNotifierProvider).updatePrice()); + if (mounted) { + setState(() { + if (tokenEntities + .where((e) => e.token.address == contract!.address) + .isEmpty) { + tokenEntities.add( + AddTokenListElementData(contract!)..selected = true, + ); + tokenEntities.sort((a, b) => a.token.name.compareTo(b.token.name)); + } + }); + } + } + } + } + + /// Navigate to add custom Solana token view and handle the result. + Future _addCustomSolanaToken() async { + SolContract? token; if (isDesktop) { - contract = await showDialog( + token = await showDialog( context: context, - builder: - (context) => const DesktopDialog( - maxWidth: 580, - maxHeight: 500, - child: AddCustomTokenView(), - ), + builder: (context) => DesktopDialog( + maxWidth: 580, + maxHeight: 500, + child: AddCustomSolanaTokenView(walletId: widget.walletId), + ), ); } else { final result = await Navigator.of( context, - ).pushNamed(AddCustomTokenView.routeName); - contract = result as EthContract?; + ).pushNamed( + AddCustomSolanaTokenView.routeName, + arguments: widget.walletId, + ); + token = result as SolContract?; } - if (contract != null) { - await MainDB.instance.putEthContract(contract); + if (token != null) { + await MainDB.instance.putSolContract(token); + + // Also add the custom token mint address to the wallet's custom token list. + final wallet = ref.read(pWallets).getWallet(widget.walletId); + if (wallet is SolanaWallet) { + final currentCustomTokens = wallet.info.solanaCustomTokenMintAddresses; + currentCustomTokens.add(token.address); + await wallet.info.updateSolanaCustomTokenMintAddresses( + newMintAddresses: currentCustomTokens, + isar: MainDB.instance.isar, + ); + } + unawaited(ref.read(priceAnd24hChangeNotifierProvider).updatePrice()); if (mounted) { setState(() { if (tokenEntities - .where((e) => e.token.address == contract!.address) + .where((e) => e.token.address == token!.address) .isEmpty) { tokenEntities.add( - AddTokenListElementData(contract!)..selected = true, + AddTokenListElementData(token!)..selected = true, ); tokenEntities.sort((a, b) => a.token.name.compareTo(b.token.name)); } @@ -175,20 +243,43 @@ class _EditWalletTokensViewState extends ConsumerState { _searchFieldController = TextEditingController(); _searchFocusNode = FocusNode(); - final contracts = - MainDB.instance.getEthContracts().sortByName().findAllSync(); + final wallet = ref.read(pWallets).getWallet(widget.walletId); - if (contracts.isEmpty) { - contracts.addAll(DefaultTokens.list); - MainDB.instance - .putEthContracts(contracts) - .then( - (_) => ref.read(priceAnd24hChangeNotifierProvider).updatePrice(), - ); - } + if (wallet is SolanaWallet) { + final contracts = MainDB.instance + .getSolContracts() + .sortByName() + .findAllSync(); + + if (contracts.isEmpty) { + contracts.addAll(DefaultSolTokens.list); + MainDB.instance + .putSolContracts(contracts) + .then( + (_) => ref.read(priceAnd24hChangeNotifierProvider).updatePrice(), + ); + } - tokenEntities.addAll(contracts.map((e) => AddTokenListElementData(e))); + tokenEntities.addAll(contracts.map((e) => AddTokenListElementData(e))); + } else { + final contracts = MainDB.instance + .getEthContracts() + .sortByName() + .findAllSync(); + + if (contracts.isEmpty) { + contracts.addAll(DefaultTokens.list); + MainDB.instance + .putEthContracts(contracts) + .then( + (_) => ref.read(priceAnd24hChangeNotifierProvider).updatePrice(), + ); + } + tokenEntities.addAll(contracts.map((e) => AddTokenListElementData(e))); + } + + // Get token addresses. final walletContracts = ref.read(pWalletTokenAddresses(widget.walletId)); final shouldMarkAsSelectedContracts = [ @@ -218,135 +309,129 @@ class _EditWalletTokensViewState extends ConsumerState { if (isDesktop) { return ConditionalParent( condition: !widget.isDesktopPopup, - builder: - (child) => DesktopScaffold( - appBar: DesktopAppBar( - isCompactHeight: false, - useSpacers: false, - leading: const AppBarBackButton(), - overlayCenter: Text( - walletName, - style: STextStyles.desktopSubtitleH2(context), - ), - trailing: - widget.contractsToMarkSelected == null - ? Padding( - padding: const EdgeInsets.only(right: 24), - child: SizedBox( - height: 56, - child: TextButton( - style: Theme.of(context) - .extension()! - .getSmallSecondaryEnabledButtonStyle(context), - onPressed: _addToken, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 30, - ), - child: Text( - "Add custom token", - style: - STextStyles.desktopButtonSmallSecondaryEnabled( - context, - ), + builder: (child) => DesktopScaffold( + appBar: DesktopAppBar( + isCompactHeight: false, + useSpacers: false, + leading: const AppBarBackButton(), + overlayCenter: Text( + walletName, + style: STextStyles.desktopSubtitleH2(context), + ), + trailing: widget.contractsToMarkSelected == null + ? Padding( + padding: const EdgeInsets.only(right: 24), + child: SizedBox( + height: 56, + child: TextButton( + style: Theme.of(context) + .extension()! + .getSmallSecondaryEnabledButtonStyle(context), + onPressed: _addToken, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 30), + child: Text( + "Add custom token", + style: + STextStyles.desktopButtonSmallSecondaryEnabled( + context, ), - ), - ), ), - ) - : null, - ), - body: SizedBox( - width: 480, - child: Column( - children: [ - const AddTokenText(isDesktop: true), - const SizedBox(height: 16), - Expanded( - child: RoundedWhiteContainer( - radiusMultiplier: 2, - padding: const EdgeInsets.only( - left: 20, - top: 20, - right: 20, - bottom: 0, ), - child: child, ), ), - const SizedBox(height: 26), - SizedBox( - height: 70, - width: 480, - child: PrimaryButton( - label: - widget.contractsToMarkSelected != null - ? "Save" - : "Next", - onPressed: onNextPressed, - ), + ) + : null, + ), + body: SizedBox( + width: 480, + child: Column( + children: [ + const AddTokenText(isDesktop: true), + const SizedBox(height: 16), + Expanded( + child: RoundedWhiteContainer( + radiusMultiplier: 2, + padding: const EdgeInsets.only( + left: 20, + top: 20, + right: 20, + bottom: 0, ), - const SizedBox(height: 32), - ], + child: child, + ), ), - ), + const SizedBox(height: 26), + SizedBox( + height: 70, + width: 480, + child: PrimaryButton( + label: widget.contractsToMarkSelected != null + ? "Save" + : "Next", + onPressed: onNextPressed, + ), + ), + const SizedBox(height: 32), + ], ), + ), + ), child: ConditionalParent( condition: widget.isDesktopPopup, - builder: - (child) => DesktopDialog( - maxHeight: 670, - child: Column( - mainAxisSize: MainAxisSize.min, + builder: (child) => DesktopDialog( + maxHeight: 670, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Edit tokens", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, - ), - child: child, - ), - ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Row( - children: [ - Expanded( - child: SecondaryButton( - label: "Add custom token", - buttonHeight: ButtonHeight.l, - onPressed: _addToken, - ), - ), - const SizedBox(width: 16), - Expanded( - child: PrimaryButton( - label: "Done", - buttonHeight: ButtonHeight.l, - onPressed: onNextPressed, - ), - ), - ], + padding: const EdgeInsets.only(left: 32), + child: Text( + "Edit tokens", + style: STextStyles.desktopH3(context), ), ), - const SizedBox(height: 32), + const DesktopDialogCloseButton(), ], ), - ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + child: child, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Add custom token", + buttonHeight: ButtonHeight.l, + onPressed: _addToken, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Done", + buttonHeight: ButtonHeight.l, + onPressed: onNextPressed, + ), + ), + ], + ), + ), + const SizedBox(height: 32), + ], + ), + ), child: Column( children: [ ClipRRect( @@ -366,49 +451,53 @@ class _EditWalletTokensViewState extends ConsumerState { style: STextStyles.desktopTextMedium( context, ).copyWith(height: 2), - decoration: standardInputDecoration( - "Search", - _searchFocusNode, - context, - ).copyWith( - contentPadding: const EdgeInsets.symmetric(vertical: 10), - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - // vertical: 20, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 24, - height: 24, - color: - Theme.of(context) + decoration: + standardInputDecoration( + "Search", + _searchFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.symmetric( + vertical: 10, + ), + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + // vertical: 20, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 24, + height: 24, + color: Theme.of(context) .extension()! .textFieldDefaultSearchIconLeft, - ), - ), - suffixIcon: - _searchFieldController.text.isNotEmpty + ), + ), + suffixIcon: _searchFieldController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 10), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(width: 24, height: 24), - onTap: () async { - setState(() { - _searchFieldController.text = ""; - _searchTerm = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 10), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon( + width: 24, + height: 24, + ), + onTap: () async { + setState(() { + _searchFieldController.text = ""; + _searchTerm = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), const SizedBox(height: 12), @@ -427,8 +516,9 @@ class _EditWalletTokensViewState extends ConsumerState { } else { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -443,14 +533,14 @@ class _EditWalletTokensViewState extends ConsumerState { child: AppBarIconButton( size: 36, shadows: const [], - color: - Theme.of(context).extension()!.background, + color: Theme.of( + context, + ).extension()!.background, icon: SvgPicture.asset( Assets.svg.circlePlusFilled, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, width: 20, height: 20, ), @@ -480,49 +570,49 @@ class _EditWalletTokensViewState extends ConsumerState { enableSuggestions: !isDesktop, controller: _searchFieldController, focusNode: _searchFocusNode, - onChanged: - (value) => setState(() => _searchTerm = value), + onChanged: (value) => + setState(() => _searchTerm = value), style: STextStyles.field(context), - decoration: standardInputDecoration( - "Search", - _searchFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 16, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 16, - height: 16, - ), - ), - suffixIcon: - _searchFieldController.text.isNotEmpty + decoration: + standardInputDecoration( + "Search", + _searchFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 16, + height: 16, + ), + ), + suffixIcon: _searchFieldController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _searchFieldController.text = - ""; - _searchTerm = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _searchFieldController.text = + ""; + _searchTerm = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), const SizedBox(height: 10), @@ -535,10 +625,9 @@ class _EditWalletTokensViewState extends ConsumerState { ), const SizedBox(height: 16), PrimaryButton( - label: - widget.contractsToMarkSelected != null - ? "Save" - : "Next", + label: widget.contractsToMarkSelected != null + ? "Save" + : "Next", onPressed: onNextPressed, ), ], diff --git a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list.dart b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list.dart index 23bfb36c2d..b479a62aa7 100644 --- a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list.dart +++ b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list.dart @@ -46,6 +46,7 @@ class AddTokenList extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: AddTokenListElement( + key: Key(items[index].token.address), data: items[index], ), ), diff --git a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart index a477c57691..eecf914d73 100644 --- a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart +++ b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart @@ -14,6 +14,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:isar_community/isar.dart'; import '../../../../models/isar/exchange_cache/currency.dart'; +import '../../../../models/isar/models/contract.dart'; import '../../../../models/isar/models/ethereum/eth_contract.dart'; import '../../../../services/exchange/change_now/change_now_exchange.dart'; import '../../../../services/exchange/exchange_data_loading_service.dart'; @@ -29,7 +30,7 @@ import '../../../../widgets/rounded_white_container.dart'; class AddTokenListElementData { AddTokenListElementData(this.token); - final EthContract token; + final Contract token; bool selected = false; } @@ -102,13 +103,7 @@ class _AddTokenListElementState extends ConsumerState { placeholderBuilder: (_) => AppIcon(width: iconSize, height: iconSize), ) - : SvgPicture.asset( - widget.data.token.symbol == "BNB" - ? Assets.svg.bnbIcon - : Assets.svg.ethereum, - width: iconSize, - height: iconSize, - ), + : AppIcon(width: iconSize, height: iconSize), const SizedBox(width: 12), ConditionalParent( condition: isDesktop, diff --git a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart index 4d289a4662..13cb7a022e 100644 --- a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart +++ b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart @@ -20,13 +20,16 @@ import '../../../db/isar/main_db.dart'; import '../../../models/add_wallet_list_entity/add_wallet_list_entity.dart'; import '../../../models/add_wallet_list_entity/sub_classes/coin_entity.dart'; import '../../../models/add_wallet_list_entity/sub_classes/eth_token_entity.dart'; +import '../../../models/add_wallet_list_entity/sub_classes/sol_token_entity.dart'; import '../../../models/isar/models/ethereum/eth_contract.dart'; +import '../../../models/isar/models/solana/sol_contract.dart'; import '../../../pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart'; import '../../../providers/providers.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; import '../../../utilities/constants.dart'; import '../../../utilities/default_eth_tokens.dart'; +import '../../../utilities/default_sol_tokens.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; @@ -40,6 +43,7 @@ import '../../../widgets/icon_widgets/x_icon.dart'; import '../../../widgets/rounded_white_container.dart'; import '../../../widgets/stack_text_field.dart'; import '../../../widgets/textfield_icon_button.dart'; +import '../add_token_view/add_custom_solana_token_view.dart'; import '../add_token_view/add_custom_token_view.dart'; import '../add_token_view/sub_widgets/add_custom_token_selector.dart'; import 'sub_widgets/add_wallet_text.dart'; @@ -68,6 +72,7 @@ class _AddWalletViewState extends ConsumerState { final List coinEntities = []; final List coinTestnetEntities = []; final List tokenEntities = []; + final List solTokenEntities = []; final bool isDesktop = Util.isDesktop; @@ -84,6 +89,8 @@ class _AddWalletViewState extends ConsumerState { e.name.toLowerCase().contains(lowercaseTerm) || e.cryptoCurrency.identifier.toLowerCase().contains(lowercaseTerm) || (e is EthTokenEntity && + e.token.address.toLowerCase().contains(lowercaseTerm)) || + (e is SolTokenEntity && e.token.address.toLowerCase().contains(lowercaseTerm)), ); } @@ -125,6 +132,38 @@ class _AddWalletViewState extends ConsumerState { } } + Future _addSolToken() async { + SolContract? token; + if (isDesktop) { + token = await showDialog( + context: context, + builder: + (context) => const DesktopDialog( + maxWidth: 580, + maxHeight: 500, + child: AddCustomSolanaTokenView(), + ), + ); + } else { + token = await Navigator.of( + context, + ).pushNamed(AddCustomSolanaTokenView.routeName); + } + + if (token != null) { + await MainDB.instance.putSolContract(token); + if (mounted) { + setState(() { + if (solTokenEntities + .where((e) => e.token.address == token!.address) + .isEmpty) { + solTokenEntities.add(SolTokenEntity(token!)); + } + }); + } + } + } + @override void initState() { _searchFieldController = TextEditingController(); @@ -153,6 +192,25 @@ class _AddWalletViewState extends ConsumerState { tokenEntities.addAll(contracts.map((e) => EthTokenEntity(e))); } + if (AppConfig.coins.whereType().isNotEmpty) { + final contracts = MainDB.instance + .getSolContracts() + .sortByName() + .findAllSync(); + + if (contracts.isEmpty) { + contracts.addAll(DefaultSolTokens.list); + MainDB.instance + .putSolContracts(contracts) + .then( + (value) => + ref.read(priceAnd24hChangeNotifierProvider).updatePrice(), + ); + } + + solTokenEntities.addAll(contracts.map((e) => SolTokenEntity(e))); + } + WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { ref.refresh(addWalletSelectedEntityStateProvider); @@ -296,7 +354,7 @@ class _AddWalletViewState extends ConsumerState { ), if (tokenEntities.isNotEmpty) ExpandingSubListItem( - title: "Tokens", + title: "Ethereum tokens", entities: filter(_searchTerm, tokenEntities), initialState: ExpandableState.expanded, animationDurationMultiplier: 0.5, @@ -304,6 +362,16 @@ class _AddWalletViewState extends ConsumerState { addFunction: _addToken, ), ), + if (solTokenEntities.isNotEmpty) + ExpandingSubListItem( + title: "Solana tokens", + entities: filter(_searchTerm, solTokenEntities), + initialState: ExpandableState.expanded, + animationDurationMultiplier: 0.5, + trailing: AddCustomTokenSelector( + addFunction: _addSolToken, + ), + ), ], ), ), @@ -427,10 +495,16 @@ class _AddWalletViewState extends ConsumerState { ), if (tokenEntities.isNotEmpty) ExpandingSubListItem( - title: "Tokens", + title: "Ethereum tokens", entities: filter(_searchTerm, tokenEntities), initialState: ExpandableState.expanded, ), + if (solTokenEntities.isNotEmpty) + ExpandingSubListItem( + title: "Solana tokens", + entities: filter(_searchTerm, solTokenEntities), + initialState: ExpandableState.expanded, + ), ], ), ), diff --git a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart index 72adf390bb..4f860c1fac 100644 --- a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart +++ b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart @@ -17,6 +17,7 @@ import 'package:isar_community/isar.dart'; import '../../../../models/add_wallet_list_entity/add_wallet_list_entity.dart'; import '../../../../models/add_wallet_list_entity/sub_classes/eth_token_entity.dart'; +import '../../../../models/add_wallet_list_entity/sub_classes/sol_token_entity.dart'; import '../../../../models/isar/exchange_cache/currency.dart'; import '../../../../providers/providers.dart'; import '../../../../services/exchange/change_now/change_now_exchange.dart'; @@ -27,6 +28,7 @@ import '../../../../utilities/assets.dart'; import '../../../../utilities/constants.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/util.dart'; +import '../../../../widgets/app_icon.dart'; class CoinSelectItem extends ConsumerStatefulWidget { const CoinSelectItem({super.key, required this.entity}); @@ -69,6 +71,39 @@ class _CoinSelectItemState extends ConsumerState { }); } }); + } else if (widget.entity is SolTokenEntity) { + final solToken = (widget.entity as SolTokenEntity).token; + + ExchangeDataLoadingService.instance.isar.then((isar) async { + final currency = + await isar.currencies + .where() + .exchangeNameEqualTo(ChangeNowExchange.exchangeName) + .filter() + .tokenContractEqualTo( + solToken.address, + caseSensitive: false, + ) + .and() + .imageIsNotEmpty() + .findFirst(); + + if (mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + // Use exchange cache image if available, otherwise use logoUri if it's a PNG. + String? fallbackUri; + if (solToken.logoUri != null && + solToken.logoUri!.endsWith('.png')) { + fallbackUri = solToken.logoUri; + } + tokenImageUri = currency?.image ?? fallbackUri; + }); + } + }); + } + }); } } @@ -108,7 +143,12 @@ class _CoinSelectItemState extends ConsumerState { child: Row( children: [ tokenImageUri != null - ? SvgPicture.network(tokenImageUri!, width: 26, height: 26) + ? SvgPicture.network( + tokenImageUri!, + width: 26, + height: 26, + placeholderBuilder: (_) => AppIcon(width: 26, height: 26), + ) : SvgPicture.file( File( ref.watch(coinIconProvider(widget.entity.cryptoCurrency)), diff --git a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/next_button.dart b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/next_button.dart index 4447cc7a9f..f88b491781 100644 --- a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/next_button.dart +++ b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/next_button.dart @@ -11,8 +11,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../models/add_wallet_list_entity/sub_classes/eth_token_entity.dart'; +import '../../../../models/add_wallet_list_entity/sub_classes/sol_token_entity.dart'; import '../../create_or_restore_wallet_view/create_or_restore_wallet_view.dart'; import '../../select_wallet_for_token_view.dart'; +import '../../select_wallet_for_sol_token_view.dart'; import '../../../../providers/providers.dart'; import '../../../../themes/stack_colors.dart'; import '../../../../utilities/text_styles.dart'; @@ -42,6 +44,11 @@ class AddWalletNextButton extends ConsumerWidget { SelectWalletForTokenView.routeName, arguments: selectedCoin, ); + } else if (selectedCoin is SolTokenEntity) { + Navigator.of(context).pushNamed( + SelectWalletForSolTokenView.routeName, + arguments: selectedCoin, + ); } else { Navigator.of(context).pushNamed( CreateOrRestoreWalletView.routeName, diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart index 55016b713a..a1ea19c405 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart @@ -40,6 +40,8 @@ import '../../../utilities/logger.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/crypto_currency/coins/ethereum.dart'; +import '../../../wallets/crypto_currency/coins/solana.dart'; import '../../../wallets/isar/models/wallet_info.dart'; import '../../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; @@ -412,7 +414,7 @@ class _RestoreWalletViewState extends ConsumerState { (route) => false, ), ); - if (info.coin is Ethereum) { + if (info.coin is Ethereum || info.coin is Solana) { unawaited( Navigator.of(context).pushNamed( EditWalletTokensView.routeName, diff --git a/lib/pages/add_wallet_views/select_wallet_for_sol_token_view.dart b/lib/pages/add_wallet_views/select_wallet_for_sol_token_view.dart new file mode 100644 index 0000000000..d5c012b53a --- /dev/null +++ b/lib/pages/add_wallet_views/select_wallet_for_sol_token_view.dart @@ -0,0 +1,252 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/add_wallet_list_entity/sub_classes/coin_entity.dart'; +import '../../models/add_wallet_list_entity/sub_classes/sol_token_entity.dart'; +import 'add_token_view/edit_wallet_tokens_view.dart'; +import 'create_or_restore_wallet_view/create_or_restore_wallet_view.dart'; +import 'verify_recovery_phrase_view/verify_recovery_phrase_view.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/isar/providers/all_wallets_info_provider.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_app_bar.dart'; +import '../../widgets/desktop/desktop_scaffold.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/eth_wallet_radio.dart'; +import '../../widgets/rounded_container.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/wallet_info_row/wallet_info_row.dart'; +import 'package:tuple/tuple.dart'; + +final newSolWalletTriggerTempUntilHiveCompletelyDeleted = + StateProvider((ref) => false); + +class SelectWalletForSolTokenView extends ConsumerStatefulWidget { + const SelectWalletForSolTokenView({ + super.key, + required this.entity, + }); + + static const String routeName = "/selectWalletForSolTokenView"; + + final SolTokenEntity entity; + + @override + ConsumerState createState() => + _SelectWalletForSolTokenViewState(); +} + +class _SelectWalletForSolTokenViewState + extends ConsumerState { + final isDesktop = Util.isDesktop; + + String? _selectedWalletId; + + void _onContinue() { + Navigator.of(context).pushNamed( + EditWalletTokensView.routeName, + arguments: Tuple2( + _selectedWalletId!, + [widget.entity.token.address], + ), + ); + } + + void _onAddNewSolWallet() { + ref.read(newSolWalletTriggerTempUntilHiveCompletelyDeleted.notifier).state = true; + Navigator.of(context).pushNamed( + CreateOrRestoreWalletView.routeName, + arguments: CoinEntity(widget.entity.cryptoCurrency), + ); + } + + @override + Widget build(BuildContext context) { + final solWalletInfos = ref + .watch(pAllWalletsInfo) + .where((e) => e.coin == widget.entity.cryptoCurrency) + .toList(); + + final _hasSolWallets = solWalletInfos.isNotEmpty; + + final List solWalletIds = []; + + for (final walletId in solWalletInfos.map((e) => e.walletId).toList()) { + final walletTokens = ref.read(pWalletTokenAddresses(walletId)); + if (!walletTokens.contains(widget.entity.token.address)) { + solWalletIds.add(walletId); + } + } + + return WillPopScope( + onWillPop: () async { + ref.read(newSolWalletTriggerTempUntilHiveCompletelyDeleted.notifier).state = false; + return true; + }, + child: ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ), + ), + child: ConditionalParent( + condition: isDesktop, + builder: (child) => DesktopScaffold( + appBar: const DesktopAppBar( + isCompactHeight: false, + leading: AppBarBackButton(), + ), + body: SizedBox( + width: 500, + child: child, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (isDesktop) + const SizedBox( + height: 24, + ), + Text( + "Select Solana wallet", + textAlign: TextAlign.center, + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox( + height: isDesktop ? 16 : 8, + ), + Text( + "You are adding a Solana token.", + textAlign: TextAlign.center, + style: isDesktop + ? STextStyles.desktopSubtitleH2(context) + : STextStyles.subtitle(context), + ), + const SizedBox( + height: 8, + ), + Text( + "You must choose a Solana wallet in order to use ${widget.entity.name}", + textAlign: TextAlign.center, + style: isDesktop + ? STextStyles.desktopSubtitleH2(context) + : STextStyles.subtitle(context), + ), + SizedBox( + height: isDesktop ? 60 : 16, + ), + solWalletIds.isEmpty + ? RoundedWhiteContainer( + padding: EdgeInsets.all(isDesktop ? 16 : 12), + child: Text( + _hasSolWallets + ? "All current Solana wallets already have ${widget.entity.name}" + : "You do not have any Solana wallets", + style: isDesktop + ? STextStyles.desktopSubtitleH2(context) + : STextStyles.label(context), + ), + ) + : ConditionalParent( + condition: !isDesktop, + builder: (child) => Expanded( + child: Column( + children: [ + RoundedWhiteContainer( + padding: const EdgeInsets.all(8), + child: child, + ), + ], + ), + ), + child: ListView.separated( + itemCount: solWalletIds.length, + shrinkWrap: true, + separatorBuilder: (_, __) => SizedBox( + height: isDesktop ? 12 : 6, + ), + itemBuilder: (_, index) { + return RoundedContainer( + padding: EdgeInsets.all(isDesktop ? 16 : 8), + onPressed: () { + setState(() { + _selectedWalletId = solWalletIds[index]; + }); + }, + color: isDesktop + ? Theme.of(context) + .extension()! + .popupBG + : _selectedWalletId == solWalletIds[index] + ? Theme.of(context) + .extension()! + .highlight + : Colors.transparent, + child: isDesktop + ? EthWalletRadio( + walletId: solWalletIds[index], + selectedWalletId: _selectedWalletId, + ) + : WalletInfoRow( + walletId: solWalletIds[index], + ), + ); + }, + ), + ), + if (solWalletIds.isEmpty || isDesktop) + const SizedBox( + height: 16, + ), + if (isDesktop) + const SizedBox( + height: 16, + ), + solWalletIds.isEmpty + ? PrimaryButton( + label: "Add new Solana wallet", + onPressed: _onAddNewSolWallet, + ) + : PrimaryButton( + label: "Continue", + enabled: _selectedWalletId != null, + onPressed: _onContinue, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart b/lib/pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart index c00dab02e3..2b9802b0dd 100644 --- a/lib/pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart +++ b/lib/pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart @@ -31,6 +31,8 @@ import '../../../utilities/show_loading.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/crypto_currency/coins/ethereum.dart'; +import '../../../wallets/crypto_currency/coins/solana.dart'; import '../../../wallets/crypto_currency/intermediate/bip39_hd_currency.dart'; import '../../../wallets/isar/models/wallet_info.dart'; import '../../../wallets/wallet/impl/epiccash_wallet.dart'; @@ -356,7 +358,7 @@ class _VerifyRecoveryPhraseViewState Navigator.of( context, ).popUntil(ModalRoute.withName(DesktopHomeView.routeName)); - if (_coin is Ethereum) { + if (_coin is Ethereum || _coin is Solana) { unawaited( Navigator.of(context).pushNamed( EditWalletTokensView.routeName, @@ -376,7 +378,7 @@ class _VerifyRecoveryPhraseViewState context, ).pushNamedAndRemoveUntil(HomeView.routeName, (route) => false), ); - if (_coin is Ethereum) { + if (_coin is Ethereum || _coin is Solana) { WidgetsBinding.instance.addPostFrameCallback((_) { ref .read(pNavKey) diff --git a/lib/pages/receive_view/sol_token_receive_view.dart b/lib/pages/receive_view/sol_token_receive_view.dart new file mode 100644 index 0000000000..084335b008 --- /dev/null +++ b/lib/pages/receive_view/sol_token_receive_view.dart @@ -0,0 +1,262 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../models/isar/models/isar_models.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../providers/providers.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/clipboard_interface.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/text_styles.dart'; +import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/icon_widgets/sol_token_icon.dart'; +import '../../widgets/qr.dart'; +import '../../widgets/rounded_white_container.dart'; + +class SolTokenReceiveView extends ConsumerStatefulWidget { + const SolTokenReceiveView({ + super.key, + required this.walletId, + required this.tokenMint, + this.clipboard = const ClipboardWrapper(), + }); + + static const String routeName = "/solTokenReceiveView"; + + final String walletId; + final String tokenMint; + final ClipboardInterface clipboard; + + @override + ConsumerState createState() => + _SolTokenReceiveViewState(); +} + +class _SolTokenReceiveViewState extends ConsumerState { + late final String walletId; + late final String tokenMint; + late final ClipboardInterface clipboard; + + @override + void initState() { + walletId = widget.walletId; + tokenMint = widget.tokenMint; + clipboard = widget.clipboard; + super.initState(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); + final walletName = ref.watch(pWalletName(walletId)); + final receivingAddress = ref.watch(pWalletReceivingAddress(walletId)); + + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + tokenWallet != null + ? "Receive ${tokenWallet.tokenSymbol}" + : "Receive Token", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 12), + Text( + "Your Solana address", + style: STextStyles.itemSubtitle(context), + ), + const SizedBox(height: 12), + RoundedWhiteContainer( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: SizedBox( + width: 200, + height: 200, + child: QR( + data: receivingAddress, + size: 200, + ), + ), + ), + const SizedBox(height: 24), + Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.popupBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + if (tokenWallet != null) + SolTokenIcon( + mintAddress: tokenMint, + ) + else + SizedBox.square(dimension: 32), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + walletName, + style: STextStyles.titleBold12( + context, + ).copyWith(fontSize: 14), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + Text( + "Solana wallet", + style: STextStyles.label( + context, + ).copyWith(fontSize: 10), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () async { + await clipboard.setData( + ClipboardData(text: receivingAddress), + ); + if (mounted) { + showFloatingFlushBar( + type: FlushBarType.info, + message: "Address copied", + context: context, + ); + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.highlight, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + Assets.svg.copy, + width: 16, + height: 16, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.textDark, + BlendMode.srcIn, + ), + ), + const SizedBox(width: 8), + Text( + "Copy", + style: + STextStyles.smallMed12(context) + .copyWith( + color: Theme.of( + context, + ).extension()! + .textDark, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + Text( + "Address", + style: STextStyles.itemSubtitle(context), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.popupBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: SelectableText( + receivingAddress, + style: STextStyles.label(context), + ), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 5cb12e02a6..91570b63bb 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import '../../models/isar/models/solana/sol_contract.dart'; import '../../models/isar/models/transaction_note.dart'; import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart'; @@ -37,10 +38,12 @@ import '../../wallets/crypto_currency/coins/ethereum.dart'; import '../../wallets/crypto_currency/coins/mimblewimblecoin.dart'; import '../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; +import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/models/tx_data.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../wallets/wallet/impl/solana_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; import '../../widgets/background.dart'; import '../../widgets/conditional_parent.dart'; @@ -214,9 +217,17 @@ class _ConfirmTransactionViewState try { if (widget.isTokenTx) { - txDataFuture = ref - .read(pCurrentTokenWallet)! - .confirmSend(txData: widget.txData); + if (wallet is SolanaWallet) { + // For Solana tokens, use the Solana token wallet. + txDataFuture = ref + .read(pCurrentSolanaTokenWallet)! + .confirmSend(txData: widget.txData); + } else { + // For Ethereum tokens, use the Ethereum token wallet. + txDataFuture = ref + .read(pCurrentTokenWallet)! + .confirmSend(txData: widget.txData); + } } else if (widget.isPaynymNotificationTransaction) { txDataFuture = (wallet as PaynymInterface).broadcastNotificationTx( txData: widget.txData, @@ -301,7 +312,11 @@ class _ConfirmTransactionViewState } if (widget.isTokenTx) { - unawaited(ref.read(pCurrentTokenWallet)!.refresh()); + if (wallet is SolanaWallet) { + unawaited(ref.read(pCurrentSolanaTokenWallet)!.refresh()); + } else { + unawaited(ref.read(pCurrentTokenWallet)!.refresh()); + } } else { unawaited(wallet.refresh()); } @@ -439,10 +454,19 @@ class _ConfirmTransactionViewState final coin = ref.watch(pWalletCoin(walletId)); final String unit; + final wallet = ref.watch(pWallets).getWallet(walletId); if (widget.isTokenTx) { - unit = ref.watch( - pCurrentTokenWallet.select((value) => value!.tokenContract.symbol), - ); + if (wallet is SolanaWallet) { + // For Solana tokens, use the Solana token wallet provider or TxData as fallback. + unit = ref.watch( + pCurrentSolanaTokenWallet.select((value) => value?.tokenSymbol), + ) ?? widget.txData.tokenSymbol ?? "TOKEN"; + } else { + // For Ethereum tokens, use the Ethereum token wallet provider. + unit = ref.watch( + pCurrentTokenWallet.select((value) => value!.tokenContract.symbol), + ); + } } else { unit = coin.ticker; } @@ -450,8 +474,6 @@ class _ConfirmTransactionViewState final Amount? fee; final Amount amountWithoutChange; - final wallet = ref.watch(pWallets).getWallet(walletId); - if (wallet is FiroWallet) { switch (ref.read(publicPrivateBalanceStateProvider.state).state) { case BalanceType.public: @@ -604,11 +626,19 @@ class _ConfirmTransactionViewState .watch(pAmountFormatter(coin)) .format( amountWithoutChange, - ethContract: widget.isTokenTx + ethContract: widget.isTokenTx && wallet is! SolanaWallet ? ref .watch(pCurrentTokenWallet)! .tokenContract : null, + solContract: widget.isTokenTx && wallet is SolanaWallet + ? SolContract( + address: widget.txData.tokenMint ?? "unknown", + name: widget.txData.tokenSymbol ?? "Token", + symbol: widget.txData.tokenSymbol ?? "TOKEN", + decimals: widget.txData.tokenDecimals ?? 9, + ) + : null, ), style: STextStyles.itemSubtitle12(context), textAlign: TextAlign.right, @@ -794,17 +824,34 @@ class _ConfirmTransactionViewState if (externalCalls) { final price = widget.isTokenTx - ? ref - .read( - priceAnd24hChangeNotifierProvider, - ) - .getTokenPrice( - ref - .read(pCurrentTokenWallet)! - .tokenContract - .address, - ) - ?.value + ? (wallet is SolanaWallet + ? // For Solana tokens, use tokenMint from provider or TxData. + ref + .read( + priceAnd24hChangeNotifierProvider, + ) + .getTokenPrice( + ref + .read( + pCurrentSolanaTokenWallet, + ) + ?.tokenMint ?? + widget.txData.tokenMint ?? + "unknown", + ) + ?.value + : // For Ethereum tokens, use contract address. + ref + .read( + priceAnd24hChangeNotifierProvider, + ) + .getTokenPrice( + ref + .read(pCurrentTokenWallet)! + .tokenContract + .address, + ) + ?.value) : ref .read( priceAnd24hChangeNotifierProvider, @@ -832,13 +879,21 @@ class _ConfirmTransactionViewState .watch(pAmountFormatter(coin)) .format( amountWithoutChange, - ethContract: widget.isTokenTx + ethContract: widget.isTokenTx && wallet is! SolanaWallet ? ref .watch( pCurrentTokenWallet, )! .tokenContract : null, + solContract: widget.isTokenTx && wallet is SolanaWallet + ? SolContract( + address: widget.txData.tokenMint ?? "unknown", + name: widget.txData.tokenSymbol ?? "Token", + symbol: widget.txData.tokenSymbol ?? "TOKEN", + decimals: widget.txData.tokenDecimals ?? 9, + ) + : null, ), style: STextStyles.desktopTextExtraExtraSmall( diff --git a/lib/pages/send_view/sol_token_send_view.dart b/lib/pages/send_view/sol_token_send_view.dart new file mode 100644 index 0000000000..62dfbbc225 --- /dev/null +++ b/lib/pages/send_view/sol_token_send_view.dart @@ -0,0 +1,1298 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'dart:async'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../models/isar/models/isar_models.dart'; +import '../../models/send_view_auto_fill_data.dart'; +import '../../providers/providers.dart'; +import '../../providers/ui/fee_rate_type_state_provider.dart'; +import '../../providers/ui/preview_tx_button_state_provider.dart'; +import '../../route_generator.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/address_utils.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/amount/amount_formatter.dart'; +import '../../utilities/amount/amount_input_formatter.dart'; +import '../../utilities/barcode_scanner_interface.dart'; +import '../../utilities/clipboard_interface.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/enums/fee_rate_type_enum.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/prefs.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/models/tx_data.dart'; +import '../../widgets/animated_text.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/icon_widgets/clipboard_icon.dart'; +import '../../widgets/icon_widgets/qrcode_icon.dart'; +import '../../widgets/icon_widgets/sol_token_icon.dart'; +import '../../widgets/icon_widgets/x_icon.dart'; +import '../../widgets/stack_dialog.dart'; +import '../../widgets/stack_text_field.dart'; +import '../../widgets/textfield_icon_button.dart'; +import '../token_view/sol_token_view.dart'; +import 'confirm_transaction_view.dart'; +import 'sub_widgets/building_transaction_dialog.dart'; +import 'sub_widgets/transaction_fee_selection_sheet.dart'; + +class SolTokenSendView extends ConsumerStatefulWidget { + const SolTokenSendView({ + super.key, + required this.walletId, + required this.tokenMint, + this.autoFillData, + this.clipboard = const ClipboardWrapper(), + }); + + static const String routeName = "/solTokenSendView"; + + final String walletId; + final String tokenMint; + final SendViewAutoFillData? autoFillData; + final ClipboardInterface clipboard; + + @override + ConsumerState createState() => _SolTokenSendViewState(); +} + +class _SolTokenSendViewState extends ConsumerState { + late final String walletId; + late final String tokenMint; + late final ClipboardInterface clipboard; + + late TextEditingController sendToController; + late TextEditingController cryptoAmountController; + late TextEditingController baseAmountController; + late TextEditingController noteController; + late TextEditingController feeController; + + late final SendViewAutoFillData? _data; + + final _addressFocusNode = FocusNode(); + final _noteFocusNode = FocusNode(); + final _cryptoFocus = FocusNode(); + final _baseFocus = FocusNode(); + + Amount? _amountToSend; + Amount? _cachedAmountToSend; + String? _address; + + bool _addressToggleFlag = false; + + bool _cryptoAmountChangeLock = false; + late VoidCallback onCryptoAmountChanged; + + final updateFeesTimerDuration = const Duration(milliseconds: 500); + + Timer? _cryptoAmountChangedFeeUpdateTimer; + Timer? _baseAmountChangedFeeUpdateTimer; + late Future _calculateFeesFuture; + String cachedFees = ""; + + void _onTokenSendViewPasteAddressFieldButtonPressed() async { + final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null && data!.text!.isNotEmpty) { + String content = data.text!.trim(); + if (content.contains("\n")) { + content = content.substring(0, content.indexOf("\n")); + } + sendToController.text = content.trim(); + _address = content.trim(); + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + } + + void _onTokenSendViewScanQrButtonPressed() async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + + final qrResult = await ref.read(pBarcodeScanner).scan(context: context); + + Logging.instance.d("qrResult content: ${qrResult.rawContent}"); + if (qrResult.rawContent == null) return; + + final paymentData = AddressUtils.parsePaymentUri( + qrResult.rawContent!, + logging: Logging.instance, + ); + + Logging.instance.d("qrResult parsed: $paymentData"); + + if (paymentData != null) { + // auto fill address + _address = paymentData.address.trim(); + sendToController.text = _address!; + + // autofill notes field + if (paymentData.message != null) { + noteController.text = paymentData.message!; + } else if (paymentData.label != null) { + noteController.text = paymentData.label!; + } + + // autofill amount field + if (paymentData.amount != null) { + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + if (tokenWallet != null) { + final Amount amount = Decimal.parse( + paymentData.amount!, + ).toAmount(fractionDigits: tokenWallet.tokenDecimals); + cryptoAmountController.text = ref + .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main))) + .format( + amount, + withUnitName: false, + indicatePrecisionLoss: false, + ); + _amountToSend = amount; + } + } + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + + // now check for non standard encoded basic address + } else { + _address = qrResult.rawContent!.split("\n").first.trim(); + sendToController.text = _address ?? ""; + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + } on PlatformException catch (e, s) { + if (mounted) { + try { + await checkCamPermDeniedMobileAndOpenAppSettings( + context, + logging: Logging.instance, + ); + } catch (e, s) { + Logging.instance.e( + "Failed to check cam permissions", + error: e, + stackTrace: s, + ); + } + } else { + Logging.instance.w( + "Failed to get camera permissions while trying to scan qr code in SolTokenSendView: ", + error: e, + stackTrace: s, + ); + } + } + } + + void _onFiatAmountFieldChanged(String baseAmountString) { + final baseAmount = Amount.tryParseFiatString( + baseAmountString, + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + if (baseAmount != null) { + final _price = ref + .read(priceAnd24hChangeNotifierProvider) + .getTokenPrice(tokenMint) + ?.value; + + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + if (tokenWallet == null) return; + + if (_price == null || _price == Decimal.zero) { + _amountToSend = Amount.zero; + } else { + _amountToSend = baseAmount <= Amount.zero + ? Amount.zero + : Amount.fromDecimal( + (baseAmount.decimal / _price).toDecimal( + scaleOnInfinitePrecision: tokenWallet.tokenDecimals, + ), + fractionDigits: tokenWallet.tokenDecimals, + ); + } + if (_cachedAmountToSend != null && _cachedAmountToSend == _amountToSend) { + return; + } + _cachedAmountToSend = _amountToSend; + + _cryptoAmountChangeLock = true; + cryptoAmountController.text = ref + .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main))) + .format(_amountToSend!, withUnitName: false); + _cryptoAmountChangeLock = false; + } else { + _amountToSend = Amount.zero; + _cryptoAmountChangeLock = true; + cryptoAmountController.text = ""; + _cryptoAmountChangeLock = false; + } + _updatePreviewButtonState(_address, _amountToSend); + } + + void _cryptoAmountChanged() async { + if (!_cryptoAmountChangeLock) { + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + if (tokenWallet == null) return; + + final cryptoAmount = Decimal.tryParse( + cryptoAmountController.text, + )?.toAmount(fractionDigits: tokenWallet.tokenDecimals); + if (cryptoAmount != null) { + _amountToSend = cryptoAmount; + if (_cachedAmountToSend != null && + _cachedAmountToSend == _amountToSend) { + return; + } + _cachedAmountToSend = _amountToSend; + + final price = ref + .read(priceAnd24hChangeNotifierProvider) + .getTokenPrice(tokenMint) + ?.value; + + if (price != null && price > Decimal.zero) { + baseAmountController.text = (_amountToSend!.decimal * price) + .toAmount(fractionDigits: 2) + .fiatString( + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + } + } else { + _amountToSend = null; + baseAmountController.text = ""; + } + + _updatePreviewButtonState(_address, _amountToSend); + + _cryptoAmountChangedFeeUpdateTimer?.cancel(); + _cryptoAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () { + if (mounted) { + setState(() { + _calculateFeesFuture = calculateFees(); + }); + } + }); + } + } + + void _baseAmountChanged() { + _baseAmountChangedFeeUpdateTimer?.cancel(); + _baseAmountChangedFeeUpdateTimer = Timer(updateFeesTimerDuration, () { + if (mounted && !_cryptoFocus.hasFocus) { + setState(() { + _calculateFeesFuture = calculateFees(); + }); + } + }); + } + + String? _updateInvalidAddressText(String address) { + if (_data != null && _data.contactLabel == address) { + return null; + } + if (address.isNotEmpty) { + if (!Solana(CryptoCurrencyNetwork.main).validateAddress(address)) { + return "Invalid address"; + } + } + return null; + } + + void _updatePreviewButtonState(String? address, Amount? amount) { + final isValidAddress = + address != null && + address.isNotEmpty && + Solana(CryptoCurrencyNetwork.main).validateAddress(address); + ref.read(previewTokenTxButtonStateProvider.state).state = + (isValidAddress && amount != null && amount > Amount.zero); + } + + Future calculateFees() async { + try { + final wallet = ref.read(pCurrentSolanaTokenWallet); + if (wallet == null) { + return "0.000005 SOL"; + } + + final feeObject = await wallet.fees; + + late final BigInt feeRate; + + switch (ref.read(feeRateTypeMobileStateProvider.state).state) { + case FeeRateType.fast: + feeRate = feeObject.fast; + break; + case FeeRateType.average: + feeRate = feeObject.medium; + break; + case FeeRateType.slow: + feeRate = feeObject.slow; + break; + default: + feeRate = BigInt.from(-1); + } + + final Amount fee = await wallet.estimateFeeFor(Amount.zero, feeRate); + cachedFees = ref + .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main))) + .format(fee, withUnitName: true, indicatePrecisionLoss: false); + + return cachedFees; + } catch (e, s) { + Logging.instance.w( + "Failed to calculate Solana token fees: ", + error: e, + stackTrace: s, + ); + // Return minimum fee as fallback. + return "0.000005 SOL"; + } + } + + Future _previewTransaction() async { + // wait for keyboard to disappear + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 100)); + + final tokenWallet = ref.read(pCurrentSolanaTokenWallet); + if (tokenWallet == null) { + if (mounted) { + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Error", + message: "Token wallet not initialized", + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Ok", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ); + }, + ); + } + return; + } + + final wallet = ref.read(pWallets).getWallet(walletId); + final Amount amount = _amountToSend!; + + try { + bool wasCancelled = false; + + if (mounted) { + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return BuildingTransactionDialog( + coin: wallet.info.coin, + isSpark: false, + onCancel: () { + wasCancelled = true; + + Navigator.of(context).pop(); + }, + ); + }, + ), + ); + } + + final time = Future.delayed(const Duration(milliseconds: 2500)); + + TxData txData; + Future txDataFuture; + + Logging.instance.i( + "SolTokenSendView: Preparing transaction - amount: ${amount.decimal} " + "(raw: ${amount.raw}), decimals: ${tokenWallet.tokenDecimals}, " + "tokenSymbol: ${tokenWallet.tokenSymbol}", + ); + + txDataFuture = tokenWallet.prepareSend( + txData: TxData( + recipients: [ + TxRecipient( + address: _address!, + amount: amount, + isChange: false, + addressType: AddressType.solana, + ), + ], + feeRateType: ref.read(feeRateTypeMobileStateProvider), + note: noteController.text, + tokenMint: tokenMint, + tokenSymbol: tokenWallet.tokenSymbol, + tokenDecimals: tokenWallet.tokenDecimals, + ), + ); + + final results = await Future.wait([txDataFuture, time]); + + txData = results.first as TxData; + + if (!wasCancelled && mounted) { + // pop building dialog + Navigator.of(context).pop(); + + unawaited( + Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => ConfirmTransactionView( + txData: txData, + walletId: walletId, + isTokenTx: true, + onSuccess: clearSendForm, + routeOnSuccessName: SolTokenView.routeName, + ), + settings: const RouteSettings( + name: ConfirmTransactionView.routeName, + ), + ), + ), + ); + } + } catch (e, s) { + Logging.instance.e("$e\n$s", error: e, stackTrace: s); + if (mounted) { + // pop building dialog + Navigator.of(context).pop(); + + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Transaction failed", + message: e.toString(), + rightButton: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + child: Text( + "Ok", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ); + }, + ), + ); + } + } + } + + void clearSendForm() { + sendToController.text = ""; + cryptoAmountController.text = ""; + baseAmountController.text = ""; + noteController.text = ""; + feeController.text = ""; + _address = ""; + _addressToggleFlag = false; + if (mounted) { + setState(() {}); + } + } + + @override + void initState() { + ref.refresh(feeSheetSessionCacheProvider); + + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(feeRateTypeMobileStateProvider.state).state = FeeRateType.slow; + }); + + _calculateFeesFuture = calculateFees(); + _data = widget.autoFillData; + walletId = widget.walletId; + tokenMint = widget.tokenMint; + clipboard = widget.clipboard; + + sendToController = TextEditingController(); + cryptoAmountController = TextEditingController(); + baseAmountController = TextEditingController(); + noteController = TextEditingController(); + feeController = TextEditingController(); + + onCryptoAmountChanged = _cryptoAmountChanged; + cryptoAmountController.addListener(onCryptoAmountChanged); + baseAmountController.addListener(_baseAmountChanged); + + if (_data != null) { + if (_data.amount != null) { + cryptoAmountController.text = _data.amount!.toString(); + } + sendToController.text = _data.contactLabel; + _address = _data.address.trim(); + _addressToggleFlag = true; + } + + super.initState(); + } + + @override + void dispose() { + _cryptoAmountChangedFeeUpdateTimer?.cancel(); + _baseAmountChangedFeeUpdateTimer?.cancel(); + + cryptoAmountController.removeListener(onCryptoAmountChanged); + baseAmountController.removeListener(_baseAmountChanged); + + sendToController.dispose(); + cryptoAmountController.dispose(); + baseAmountController.dispose(); + noteController.dispose(); + feeController.dispose(); + + _noteFocusNode.dispose(); + _addressFocusNode.dispose(); + _cryptoFocus.dispose(); + _baseFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + final String locale = ref.watch( + localeServiceChangeNotifierProvider.select((value) => value.locale), + ); + + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); + + Decimal? price; + if (ref.watch(prefsChangeNotifierProvider.select((s) => s.externalCalls))) { + price = ref.watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getTokenPrice(tokenMint)?.value, + ), + ); + } + + if (tokenWallet == null) { + return Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 50)); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: const Text("Send Token"), + ), + body: const SafeArea(child: Center(child: Text("Loading token..."))), + ), + ); + } + + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 50)); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "Send ${tokenWallet.tokenSymbol}", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return Padding( + padding: const EdgeInsets.only(left: 12, top: 12, right: 12), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 24, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.popupBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + children: [ + SolTokenIcon(mintAddress: tokenMint), + const SizedBox(width: 6), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + ref.watch(pWalletName(walletId)), + style: STextStyles.titleBold12( + context, + ).copyWith(fontSize: 14), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + Text( + "Available balance", + style: STextStyles.label( + context, + ).copyWith(fontSize: 10), + ), + ], + ), + const Spacer(), + GestureDetector( + onTap: () { + cryptoAmountController.text = ref + .watch( + pAmountFormatter( + Solana( + CryptoCurrencyNetwork.main, + ), + ), + ) + .format( + ref + .read( + pSolanaTokenBalance(( + walletId: widget.walletId, + tokenMint: tokenMint, + )), + ) + .spendable, + withUnitName: false, + indicatePrecisionLoss: true, + ); + }, + child: Container( + color: Colors.transparent, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.end, + children: [ + Text( + ref + .watch( + pAmountFormatter( + Solana( + CryptoCurrencyNetwork + .main, + ), + ), + ) + .format( + ref + .watch( + pSolanaTokenBalance(( + walletId: + widget.walletId, + tokenMint: + tokenMint, + )), + ) + .spendable, + ), + style: STextStyles.titleBold12( + context, + ).copyWith(fontSize: 10), + textAlign: TextAlign.right, + ), + if (price != null) + Text( + "${(ref.watch(pSolanaTokenBalance((walletId: widget.walletId, tokenMint: tokenMint))).spendable.decimal * price).toAmount(fractionDigits: 2).fiatString(locale: locale)} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", + style: STextStyles.subtitle( + context, + ).copyWith(fontSize: 8), + textAlign: TextAlign.right, + ), + ], + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Text( + "Send to", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key( + "solTokenSendViewAddressFieldKey", + ), + controller: sendToController, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + toolbarOptions: const ToolbarOptions( + copy: false, + cut: false, + paste: true, + selectAll: false, + ), + onChanged: (newValue) { + _address = newValue.trim(); + _updatePreviewButtonState( + _address, + _amountToSend, + ); + + setState(() { + _addressToggleFlag = newValue.isNotEmpty; + }); + }, + focusNode: _addressFocusNode, + style: STextStyles.field(context), + decoration: + standardInputDecoration( + "Enter Solana address", + _addressFocusNode, + context, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 6, + bottom: 8, + right: 5, + ), + suffixIcon: Padding( + padding: sendToController.text.isEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: [ + _addressToggleFlag + ? TextFieldIconButton( + key: const Key( + "solTokenSendViewClearAddressFieldButtonKey", + ), + onTap: () { + sendToController.text = + ""; + _address = ""; + _updatePreviewButtonState( + _address, + _amountToSend, + ); + setState(() { + _addressToggleFlag = + false; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "solTokenSendViewPasteAddressFieldButtonKey", + ), + onTap: + _onTokenSendViewPasteAddressFieldButtonPressed, + child: + sendToController + .text + .isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (sendToController.text.isEmpty) + TextFieldIconButton( + key: const Key( + "solSendViewScanQrButtonKey", + ), + onTap: + _onTokenSendViewScanQrButtonPressed, + child: const QrCodeIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + Builder( + builder: (_) { + final error = _updateInvalidAddressText( + _address ?? "", + ); + + if (error == null || error.isEmpty) { + return Container(); + } else { + return Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only( + left: 12.0, + top: 4.0, + ), + child: Text( + error, + textAlign: TextAlign.left, + style: STextStyles.label(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textError, + ), + ), + ), + ); + } + }, + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Amount", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + ], + ), + const SizedBox(height: 8), + TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop ? false : true, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + key: const Key( + "solAmountInputFieldCryptoTextFieldKey", + ), + controller: cryptoAmountController, + focusNode: _cryptoFocus, + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + textAlign: TextAlign.right, + inputFormatters: [ + AmountInputFormatter( + decimals: tokenWallet.tokenDecimals, + // TODO: Implement token-specific unit lookup + // similar to Ethereum's pAmountUnit(coin).unitForContract(tokenContract) + unit: ref.watch( + pAmountUnit( + Solana(CryptoCurrencyNetwork.main), + ), + ), + locale: locale, + ), + ], + decoration: InputDecoration( + contentPadding: const EdgeInsets.only( + top: 12, + right: 12, + ), + hintText: "0", + hintStyle: STextStyles.fieldLabel( + context, + ).copyWith(fontSize: 14), + prefixIcon: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + tokenWallet.tokenSymbol, + style: STextStyles.smallMed14(context) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark, + ), + ), + ), + ), + ), + ), + if (Prefs.instance.externalCalls) + const SizedBox(height: 8), + if (Prefs.instance.externalCalls) + TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop + ? false + : true, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + key: const Key( + "solAmountInputFieldFiatTextFieldKey", + ), + controller: baseAmountController, + focusNode: _baseFocus, + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + textAlign: TextAlign.right, + inputFormatters: [ + AmountInputFormatter( + decimals: 2, + locale: locale, + ), + ], + onChanged: _onFiatAmountFieldChanged, + decoration: InputDecoration( + contentPadding: const EdgeInsets.only( + top: 12, + right: 12, + ), + hintText: "0", + hintStyle: STextStyles.fieldLabel( + context, + ).copyWith(fontSize: 14), + prefixIcon: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + ref.watch( + prefsChangeNotifierProvider.select( + (value) => value.currency, + ), + ), + style: STextStyles.smallMed14(context) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 12), + Text( + "Note (optional)", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop + ? false + : true, + controller: noteController, + focusNode: _noteFocusNode, + style: STextStyles.field(context), + onChanged: (_) => setState(() {}), + decoration: + standardInputDecoration( + "Type something...", + _noteFocusNode, + context, + ).copyWith( + suffixIcon: noteController.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only( + right: 0, + ), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + noteController.text = + ""; + }); + }, + ), + ], + ), + ), + ) + : null, + ), + ), + ), + const SizedBox(height: 12), + Text( + "Transaction fee", + style: STextStyles.smallMed12(context), + textAlign: TextAlign.left, + ), + const SizedBox(height: 8), + Stack( + children: [ + TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop + ? false + : true, + controller: feeController, + readOnly: true, + textInputAction: TextInputAction.none, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + ), + child: RawMaterialButton( + splashColor: Theme.of( + context, + ).extension()!.highlight, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: () { + showModalBottomSheet( + backgroundColor: Colors.transparent, + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + builder: (_) => + TransactionFeeSelectionSheet( + walletId: walletId, + isToken: true, + amount: + (Decimal.tryParse( + cryptoAmountController + .text, + ) ?? + Decimal.zero) + .toAmount( + fractionDigits: + tokenWallet + .tokenDecimals, + ), + updateChosen: (String fee) { + setState(() { + _calculateFeesFuture = Future( + () => fee, + ); + }); + }, + ), + ); + }, + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + ref + .watch( + feeRateTypeMobileStateProvider + .state, + ) + .state + .prettyName, + style: STextStyles.itemSubtitle12( + context, + ), + ), + const SizedBox(width: 10), + FutureBuilder( + future: _calculateFeesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.done && + snapshot.hasData) { + return Text( + "~${snapshot.data!}", + style: + STextStyles.itemSubtitle( + context, + ), + ); + } else { + return AnimatedText( + stringsToLoopThrough: + const [ + "Calculating", + "Calculating.", + "Calculating..", + "Calculating...", + ], + style: + STextStyles.itemSubtitle( + context, + ), + ); + } + }, + ), + ], + ), + SvgPicture.asset( + Assets.svg.chevronDown, + width: 8, + height: 4, + colorFilter: ColorFilter.mode( + Theme.of(context) + .extension()! + .textSubtitle2, + BlendMode.srcIn, + ), + ), + ], + ), + ), + ), + ], + ), + const Spacer(), + const SizedBox(height: 12), + TextButton( + onPressed: + ref + .watch( + previewTokenTxButtonStateProvider.state, + ) + .state + ? _previewTransaction + : null, + style: + ref + .watch( + previewTokenTxButtonStateProvider.state, + ) + .state + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context) + : Theme.of(context) + .extension()! + .getPrimaryDisabledButtonStyle(context), + child: Text( + "Preview", + style: STextStyles.button(context), + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart index 8c165ccdb7..5d586ae9f0 100644 --- a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart +++ b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart @@ -1,4 +1,4 @@ -/* +/* * This file is part of Stack Wallet. * * Copyright (c) 2023 Cypher Stack @@ -28,6 +28,7 @@ import '../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../wallets/wallet/impl/firo_wallet.dart'; import '../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; +import '../../../wallets/wallet/wallet.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart'; import '../../../widgets/animated_text.dart'; @@ -115,8 +116,13 @@ class _TransactionFeeSelectionSheetState .estimateFeeFor(amount, feeRate); } } else { - final tokenWallet = ref.read(pCurrentTokenWallet)!; - final fee = await tokenWallet.estimateFeeFor(amount, feeRate); + final Wallet wallet; + if (coin is Ethereum) { + wallet = ref.read(pCurrentTokenWallet)!; + } else { + wallet = ref.read(pWallets).getWallet(walletId); + } + final fee = await wallet.estimateFeeFor(amount, feeRate); ref.read(feeSheetSessionCacheProvider).fast[amount] = fee; } } @@ -151,8 +157,13 @@ class _TransactionFeeSelectionSheetState await wallet.estimateFeeFor(amount, feeRate); } } else { - final tokenWallet = ref.read(pCurrentTokenWallet)!; - final fee = await tokenWallet.estimateFeeFor(amount, feeRate); + final Wallet wallet; + if (coin is Ethereum) { + wallet = ref.read(pCurrentTokenWallet)!; + } else { + wallet = ref.read(pWallets).getWallet(walletId); + } + final fee = await wallet.estimateFeeFor(amount, feeRate); ref.read(feeSheetSessionCacheProvider).average[amount] = fee; } } @@ -187,8 +198,13 @@ class _TransactionFeeSelectionSheetState .estimateFeeFor(amount, feeRate); } } else { - final tokenWallet = ref.read(pCurrentTokenWallet)!; - final fee = await tokenWallet.estimateFeeFor(amount, feeRate); + final Wallet wallet; + if (coin is Ethereum) { + wallet = ref.read(pCurrentTokenWallet)!; + } else { + wallet = ref.read(pWallets).getWallet(walletId); + } + final fee = await wallet.estimateFeeFor(amount, feeRate); ref.read(feeSheetSessionCacheProvider).slow[amount] = fee; } } @@ -269,7 +285,9 @@ class _TransactionFeeSelectionSheetState const SizedBox(height: 36), FutureBuilder( future: widget.isToken - ? ref.read(pCurrentTokenWallet)!.fees + ? (coin is Ethereum + ? ref.read(pCurrentTokenWallet)!.fees + : wallet.fees) : wallet.fees, builder: (context, AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.done && diff --git a/lib/pages/token_view/my_tokens_view.dart b/lib/pages/token_view/my_tokens_view.dart index 10e84751b2..7e1c621c03 100644 --- a/lib/pages/token_view/my_tokens_view.dart +++ b/lib/pages/token_view/my_tokens_view.dart @@ -14,12 +14,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import '../../providers/global/wallets_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/wallet/impl/solana_wallet.dart'; import '../../widgets/background.dart'; import '../../widgets/conditional_parent.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -28,6 +30,7 @@ import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; import '../add_wallet_views/add_token_view/edit_wallet_tokens_view.dart'; import 'sub_widgets/my_tokens_list.dart'; +import 'sub_widgets/sol_tokens_list.dart'; class MyTokensView extends ConsumerStatefulWidget { const MyTokensView({super.key, required this.walletId}); @@ -66,80 +69,73 @@ class _MyTokensViewState extends ConsumerState { return ConditionalParent( condition: !isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.background, - leading: AppBarBackButton( - onPressed: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 75), - ); - } - if (mounted) { - Navigator.of(context).pop(); - } - }, - ), - title: Text( - "${ref.watch(pWalletName(widget.walletId))} Tokens", - style: STextStyles.navBarTitle(context), - ), - actions: [ - Padding( - padding: const EdgeInsets.only( - top: 10, - bottom: 10, - right: 20, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.background, + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + if (mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "${ref.watch(pWalletName(widget.walletId))} Tokens", + style: STextStyles.navBarTitle(context), + ), + actions: [ + Padding( + padding: const EdgeInsets.only(top: 10, bottom: 10, right: 20), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + key: const Key("addTokenAppBarIconButtonKey"), + size: 36, + shadows: const [], + color: Theme.of( + context, + ).extension()!.background, + icon: SvgPicture.asset( + Assets.svg.circlePlusFilled, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + width: 20, + height: 20, ), - child: AspectRatio( - aspectRatio: 1, - child: AppBarIconButton( - key: const Key("addTokenAppBarIconButtonKey"), - size: 36, - shadows: const [], - color: - Theme.of( - context, - ).extension()!.background, - icon: SvgPicture.asset( - Assets.svg.circlePlusFilled, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, - width: 20, - height: 20, - ), - onPressed: () async { - final result = await Navigator.of(context).pushNamed( - EditWalletTokensView.routeName, - arguments: widget.walletId, - ); + onPressed: () async { + final result = await Navigator.of(context).pushNamed( + EditWalletTokensView.routeName, + arguments: widget.walletId, + ); - if (mounted && result == 42) { - setState(() {}); - } - }, - ), - ), + if (mounted && result == 42) { + setState(() {}); + } + }, ), - ], - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.only(left: 12, top: 12, right: 12), - child: child, ), ), + ], + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only(left: 12, top: 12, right: 12), + child: child, ), ), + ), + ), child: Column( children: [ Padding( @@ -166,57 +162,55 @@ class _MyTokensViewState extends ConsumerState { _searchString = value; }); }, - style: - isDesktop - ? STextStyles.desktopTextExtraSmall( + style: isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith( + color: Theme.of( context, - ).copyWith( - color: - Theme.of(context) - .extension()! - .textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: standardInputDecoration( - "Search...", - searchFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - prefixIcon: Padding( - padding: EdgeInsets.symmetric( - horizontal: isDesktop ? 12 : 10, - vertical: isDesktop ? 18 : 16, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: isDesktop ? 20 : 16, - height: isDesktop ? 20 : 16, - ), - ), - suffixIcon: - _searchController.text.isNotEmpty + ).extension()!.textFieldActiveText, + height: 1.8, + ) + : STextStyles.field(context), + decoration: + standardInputDecoration( + "Search...", + searchFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + prefixIcon: Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 12 : 10, + vertical: isDesktop ? 18 : 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: isDesktop ? 20 : 16, + height: isDesktop ? 20 : 16, + ), + ), + suffixIcon: _searchController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _searchController.text = ""; - _searchString = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _searchController.text = ""; + _searchString = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), @@ -226,10 +220,26 @@ class _MyTokensViewState extends ConsumerState { ), const SizedBox(height: 8), Expanded( - child: MyTokensList( - walletId: widget.walletId, - searchTerm: _searchString, - tokenContracts: ref.watch(pWalletTokenAddresses(widget.walletId)), + child: Builder( + builder: (context) { + final wallet = ref.watch(pWallets).getWallet(widget.walletId); + final tokenAddresses = ref.watch( + pWalletTokenAddresses(widget.walletId), + ); + if (wallet is SolanaWallet) { + return SolanaTokensList( + walletId: widget.walletId, + searchTerm: _searchString, + tokenMints: tokenAddresses, + ); + } else { + return MyTokensList( + walletId: widget.walletId, + searchTerm: _searchString, + tokenContracts: tokenAddresses, + ); + } + }, ), ), ], diff --git a/lib/pages/token_view/sol_token_view.dart b/lib/pages/token_view/sol_token_view.dart new file mode 100644 index 0000000000..3253595243 --- /dev/null +++ b/lib/pages/token_view/sol_token_view.dart @@ -0,0 +1,242 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:event_bus/event_bus.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:tuple/tuple.dart'; + +import '../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/text_styles.dart'; +import '../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../wallets/isar/providers/solana/solana_wallet_provider.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/custom_buttons/blue_text_button.dart'; +import '../../widgets/icon_widgets/sol_token_icon.dart'; +import 'solana_token_contract_details_view.dart'; +import 'sub_widgets/token_summary_sol.dart'; +import 'sub_widgets/token_transaction_list_widget_sol.dart'; + +/// [eventBus] should only be set during testing. +class SolTokenView extends ConsumerStatefulWidget { + const SolTokenView({ + super.key, + required this.walletId, + required this.tokenMint, + this.popPrevious = false, + this.eventBus, + }); + + static const String routeName = "/sol_token"; + + final String walletId; + final String tokenMint; + final bool popPrevious; + final EventBus? eventBus; + + @override + ConsumerState createState() => _SolTokenViewState(); +} + +class _SolTokenViewState extends ConsumerState { + late final WalletSyncStatus initialSyncStatus; + + @override + void initState() { + // Get the initial sync status from the Solana wallet's refresh mutex. + final solanaWallet = ref.read(pSolanaWallet(widget.walletId)); + initialSyncStatus = solanaWallet?.refreshMutex.isLocked ?? false + ? WalletSyncStatus.syncing + : WalletSyncStatus.synced; + + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + + return WillPopScope( + onWillPop: () async { + final nav = Navigator.of(context); + if (widget.popPrevious) { + nav.pop(); + } + nav.pop(); + return false; + }, + child: Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + final nav = Navigator.of(context); + if (widget.popPrevious) { + nav.pop(); + } + nav.pop(); + }, + ), + centerTitle: true, + title: Consumer( + builder: (context, ref, _) { + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); + final tokenName = tokenWallet?.tokenName ?? "Token"; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SolTokenIcon(mintAddress: widget.tokenMint, size: 24), + const SizedBox(width: 10), + Flexible( + child: Text( + tokenName, + style: STextStyles.navBarTitle(context), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ], + ); + }, + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 2), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + icon: SvgPicture.asset( + Assets.svg.verticalEllipsis, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + ), + onPressed: () { + Navigator.of(context).pushNamed( + SolanaTokenContractDetailsView.routeName, + arguments: Tuple2( + widget.tokenMint, + widget.walletId, + ), + ); + }, + ), + ), + ), + ], + ), + body: SafeArea( + child: Container( + color: Theme.of(context).extension()!.background, + child: Column( + children: [ + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SolanaTokenSummary( + walletId: widget.walletId, + tokenMint: widget.tokenMint, + initialSyncStatus: initialSyncStatus, + ), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Transactions", + style: STextStyles.itemSubtitle(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, + ), + ), + CustomTextButton( + text: "See all", + onTap: () { + // TODO: Navigate to all transactions for this token. + // Navigator.of(context).pushNamed( + // AllTransactionsV2View.routeName, + // arguments: ( + // walletId: widget.walletId, + // tokenMint: widget.tokenMint, + // ), + // ); + }, + ), + ], + ), + ), + const SizedBox(height: 12), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ClipRRect( + borderRadius: BorderRadius.vertical( + top: Radius.circular( + Constants.size.circularBorderRadius, + ), + bottom: Radius.circular( + // TokenView.navBarHeight / 2.0, + Constants.size.circularBorderRadius, + ), + ), + child: Container( + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: SolanaTokenTransactionsList( + walletId: widget.walletId, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/token_view/solana_token_contract_details_view.dart b/lib/pages/token_view/solana_token_contract_details_view.dart new file mode 100644 index 0000000000..2ed51ed1fe --- /dev/null +++ b/lib/pages/token_view/solana_token_contract_details_view.dart @@ -0,0 +1,181 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar_community/isar.dart'; + +import '../../db/isar/main_db.dart'; +import '../../models/isar/models/isar_models.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/custom_buttons/simple_copy_button.dart'; +import '../../widgets/rounded_white_container.dart'; + +class SolanaTokenContractDetailsView extends ConsumerStatefulWidget { + const SolanaTokenContractDetailsView({ + super.key, + required this.tokenMint, + required this.walletId, + }); + + static const String routeName = "/solanaTokenContractDetailsView"; + + final String tokenMint; + final String walletId; + + @override + ConsumerState createState() => + _SolanaTokenContractDetailsViewState(); +} + +class _SolanaTokenContractDetailsViewState + extends ConsumerState { + final isDesktop = Util.isDesktop; + + late SolContract token; + + @override + void initState() { + token = MainDB.instance.isar.solContracts + .where() + .addressEqualTo(widget.tokenMint) + .findFirstSync()!; + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + titleSpacing: 0, + title: Text( + "Token details", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ); + }, + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Item( + title: "Mint address", + data: token.address, + button: SimpleCopyButton(data: token.address), + ), + const SizedBox(height: 12), + _Item( + title: "Name", + data: token.name, + button: SimpleCopyButton(data: token.name), + ), + const SizedBox(height: 12), + _Item( + title: "Symbol", + data: token.symbol, + button: SimpleCopyButton(data: token.symbol), + ), + const SizedBox(height: 12), + _Item( + title: "Decimals", + data: token.decimals.toString(), + button: SimpleCopyButton(data: token.decimals.toString()), + ), + if (token.metadataAddress != null) ...[ + const SizedBox(height: 12), + _Item( + title: "Metadata address", + data: token.metadataAddress ?? "", + button: SimpleCopyButton(data: token.metadataAddress ?? ""), + ), + ], + ], + ), + ); + } +} + +class _Item extends StatelessWidget { + const _Item({ + super.key, + required this.title, + required this.data, + required this.button, + }); + + final String title; + final String data; + final Widget button; + + @override + Widget build(BuildContext context) { + return RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: STextStyles.itemSubtitle(context)), + button, + ], + ), + const SizedBox(height: 5), + data.isNotEmpty + ? SelectableText(data, style: STextStyles.w500_14(context)) + : Text( + "$title will appear here", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle3, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/token_view/sub_widgets/sol_token_select_item.dart b/lib/pages/token_view/sub_widgets/sol_token_select_item.dart new file mode 100644 index 0000000000..556be9f434 --- /dev/null +++ b/lib/pages/token_view/sub_widgets/sol_token_select_item.dart @@ -0,0 +1,257 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../models/isar/models/solana/sol_contract.dart'; +import '../../../pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart'; +import '../../../providers/providers.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/constants.dart'; +import '../../../utilities/show_loading.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; +import '../../../wallets/wallet/impl/solana_wallet.dart'; +import '../../../wallets/wallet/impl/sub_wallets/solana_token_wallet.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/dialogs/basic_dialog.dart'; +import '../../../widgets/icon_widgets/sol_token_icon.dart'; +import '../../../widgets/rounded_white_container.dart'; +import '../sol_token_view.dart'; + +class SolTokenSelectItem extends ConsumerStatefulWidget { + const SolTokenSelectItem({ + super.key, + required this.walletId, + required this.token, + }); + + final String walletId; + final SolContract token; + + @override + ConsumerState createState() => _SolTokenSelectItemState(); +} + +class _SolTokenSelectItemState extends ConsumerState { + final bool isDesktop = Util.isDesktop; + + Future _loadTokenWallet(BuildContext context, WidgetRef ref) async { + try { + await ref.read(pCurrentSolanaTokenWallet)!.init(); + return true; + } catch (_) { + await showDialog( + barrierDismissible: false, + context: context, + builder: (context) => BasicDialog( + title: "Failed to load token data", + desktopHeight: double.infinity, + desktopWidth: 450, + rightButton: PrimaryButton( + label: "OK", + onPressed: () { + Navigator.of(context).pop(); + if (!isDesktop) { + Navigator.of(context).pop(); + } + }, + ), + ), + ); + return false; + } + } + + void _onPressed() async { + final old = ref.read(solanaTokenServiceStateProvider); + // exit previous if there is one + unawaited(old?.exit()); + + // Get the parent Solana wallet. + final solanaWallet = + ref.read(pWallets).getWallet(widget.walletId) as SolanaWallet?; + if (solanaWallet == null) { + if (mounted) { + await showDialog( + barrierDismissible: false, + context: context, + builder: (context) => BasicDialog( + title: "Error: Parent Solana wallet not found", + desktopHeight: double.infinity, + desktopWidth: 450, + rightButton: PrimaryButton( + label: "OK", + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + ); + } + return; + } + + ref.read(solanaTokenServiceStateProvider.state).state = SolanaTokenWallet( + solanaWallet, + widget.token, + ); + + final success = await showLoading( + whileFuture: _loadTokenWallet(context, ref), + context: context, + rootNavigator: isDesktop, + message: "Loading ${widget.token.name}", + ); + + if (!success!) { + return; + } + + if (mounted) { + unawaited(ref.read(pCurrentSolanaTokenWallet)!.refresh()); + await Navigator.of(context).pushNamed( + isDesktop ? DesktopSolTokenView.routeName : SolTokenView.routeName, + arguments: ( + walletId: widget.walletId, + tokenMint: widget.token.address, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + String? priceString; + if (ref.watch(prefsChangeNotifierProvider.select((s) => s.externalCalls))) { + priceString = ref.watch( + priceAnd24hChangeNotifierProvider.select( + (s) => + s.getTokenPrice(widget.token.address)?.value.toStringAsFixed(2), + ), + ); + } + + return RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + child: MaterialButton( + key: Key("walletListItemButtonKey_${widget.token.symbol}"), + padding: + isDesktop + ? const EdgeInsets.symmetric(horizontal: 28, vertical: 24) + : const EdgeInsets.symmetric(horizontal: 12, vertical: 13), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: _onPressed, + child: Row( + children: [ + SolTokenIcon( + mintAddress: widget.token.address, + size: 32, + ), + SizedBox(width: isDesktop ? 12 : 10), + Expanded( + child: Consumer( + builder: (_, ref, __) { + // Watch the balance from the database. + final balance = ref.watch( + pSolanaTokenBalance( + ( + walletId: widget.walletId, + tokenMint: widget.token.address, + ), + ), + ); + + // Format the balance. + final decimalValue = balance.total.decimal.toStringAsFixed(widget.token.decimals); + final balanceString = "$decimalValue ${widget.token.symbol}"; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text( + widget.token.name, + style: + isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith( + color: + Theme.of( + context, + ).extension()!.textDark, + ) + : STextStyles.titleBold12(context), + ), + const Spacer(), + Text( + balanceString, + style: + isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith( + color: + Theme.of( + context, + ).extension()!.textDark, + ) + : STextStyles.itemSubtitle(context), + ), + ], + ), + const SizedBox(height: 2), + Row( + children: [ + Text( + widget.token.symbol, + style: + isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle(context), + ), + const Spacer(), + if (priceString != null) + Text( + "$priceString " + "${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", + style: + isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle(context), + ), + ], + ), + ], + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/token_view/sub_widgets/sol_tokens_list.dart b/lib/pages/token_view/sub_widgets/sol_tokens_list.dart new file mode 100644 index 0000000000..ec8a0678c2 --- /dev/null +++ b/lib/pages/token_view/sub_widgets/sol_tokens_list.dart @@ -0,0 +1,95 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar_community/isar.dart'; + +import '../../../models/isar/models/solana/sol_contract.dart'; +import '../../../providers/db/main_db_provider.dart'; +import '../../../utilities/util.dart'; +import 'sol_token_select_item.dart'; + +class SolanaTokensList extends StatelessWidget { + const SolanaTokensList({ + super.key, + required this.walletId, + required this.searchTerm, + required this.tokenMints, + }); + + final String walletId; + final String searchTerm; + final List tokenMints; + + List _filter(String searchTerm, List allTokens) { + if (tokenMints.isEmpty) { + return []; + } + + // Filter to only tokens in the wallet's token list. + var filtered = allTokens + .where((token) => tokenMints.contains(token.address)) + .toList(); + + // Apply search filter if provided. + if (searchTerm.isNotEmpty) { + final term = searchTerm.toLowerCase(); + filtered = filtered + .where( + (token) => + token.name.toLowerCase().contains(term) || + token.symbol.toLowerCase().contains(term) || + token.address.toLowerCase().contains(term), + ) + .toList(); + } + + return filtered; + } + + @override + Widget build(BuildContext context) { + final bool isDesktop = Util.isDesktop; + + return Consumer( + builder: (_, ref, __) { + // Get all available SOL tokens. + final db = ref.watch(mainDBProvider); + + final allTokens = db.getSolContracts().findAllSync(); + + final tokens = _filter(searchTerm, allTokens); + + if (tokens.isEmpty) { + return Center( + child: Text( + "No tokens in this wallet", + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + + return ListView.builder( + itemCount: tokens.length, + itemBuilder: (ctx, index) { + final token = tokens[index]; + return Padding( + key: Key(token.address), + padding: isDesktop + ? const EdgeInsets.symmetric(vertical: 5) + : const EdgeInsets.all(4), + child: SolTokenSelectItem(walletId: walletId, token: token), + ); + }, + ); + }, + ); + } +} diff --git a/lib/pages/token_view/sub_widgets/token_summary_sol.dart b/lib/pages/token_view/sub_widgets/token_summary_sol.dart new file mode 100644 index 0000000000..9d627c1f58 --- /dev/null +++ b/lib/pages/token_view/sub_widgets/token_summary_sol.dart @@ -0,0 +1,316 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'dart:io'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../../providers/global/locale_provider.dart'; +import '../../../providers/global/price_provider.dart'; +import '../../../providers/global/prefs_provider.dart'; +import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/assets.dart'; +import '../../../utilities/constants.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; +import '../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../widgets/coin_ticker_tag.dart'; +import '../../../widgets/conditional_parent.dart'; +import '../../../widgets/rounded_container.dart'; +import '../../receive_view/sol_token_receive_view.dart'; +import '../../send_view/sol_token_send_view.dart'; +import '../../wallet_view/sub_widgets/wallet_refresh_button.dart'; + +/// Solana-specific token summary widget. +/// +/// Displays token balance, wallet name, and available actions for Solana tokens. +class SolanaTokenSummary extends ConsumerWidget { + const SolanaTokenSummary({ + super.key, + required this.walletId, + required this.tokenMint, + required this.initialSyncStatus, + }); + + final String walletId; + final String tokenMint; + final WalletSyncStatus initialSyncStatus; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Get the Solana token wallet. + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); + + // If wallet is not initialized, show a placeholder. + if (tokenWallet == null) { + return RoundedContainer( + color: Theme.of(context).extension()!.tokenSummaryBG, + padding: const EdgeInsets.all(24), + child: Center( + child: Text( + "Loading token data...", + style: STextStyles.subtitle500(context).copyWith( + color: + Theme.of(context).extension()!.tokenSummaryTextPrimary, + ), + ), + ), + ); + } + + // Watch the balance from the database provider. + final balance = ref.watch( + pSolanaTokenBalance( + ( + walletId: walletId, + tokenMint: tokenMint, + ), + ), + ); + + Decimal? price; + if (ref.watch(prefsChangeNotifierProvider.select((s) => s.externalCalls))) { + // Get the token price from the price service. + price = ref.watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getTokenPrice(tokenMint)?.value, + ), + ); + } + + return Stack( + children: [ + RoundedContainer( + color: Theme.of(context).extension()!.tokenSummaryBG, + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + Assets.svg.walletDesktop, + color: Theme.of( + context, + ).extension()!.tokenSummaryTextSecondary, + width: 12, + height: 12, + ), + const SizedBox(width: 6), + Text( + ref.watch(pWalletName(walletId)), + style: STextStyles.w500_12(context).copyWith( + color: Theme.of( + context, + ).extension()!.tokenSummaryTextSecondary, + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + balance.total.decimal.toStringAsFixed(tokenWallet.tokenDecimals), + style: STextStyles.pageTitleH1(context).copyWith( + color: Theme.of( + context, + ).extension()!.tokenSummaryTextPrimary, + ), + ), + const SizedBox(width: 10), + CoinTickerTag( + ticker: tokenWallet.tokenSymbol, + ), + ], + ), + if (price != null) const SizedBox(height: 6), + if (price != null) + Text( + "${(balance.total.decimal * price).toAmount(fractionDigits: 2).fiatString(locale: ref.watch(localeServiceChangeNotifierProvider.select((value) => value.locale)))} ${ref.watch(prefsChangeNotifierProvider.select((value) => value.currency))}", + style: STextStyles.subtitle500(context).copyWith( + color: Theme.of( + context, + ).extension()!.tokenSummaryTextPrimary, + ), + ), + const SizedBox(height: 20), + SolanaTokenWalletOptions( + walletId: walletId, + tokenMint: tokenMint, + ), + ], + ), + ), + Positioned( + top: 10, + right: 10, + child: WalletRefreshButton( + walletId: walletId, + initialSyncStatus: initialSyncStatus, + tokenContractAddress: tokenMint, + overrideIconColor: + Theme.of(context).extension()!.topNavIconPrimary, + ), + ), + ], + ); + } +} + +/// Solana token wallet action buttons (Send, Receive, etc.). +class SolanaTokenWalletOptions extends ConsumerWidget { + const SolanaTokenWalletOptions({ + super.key, + required this.walletId, + required this.tokenMint, + }); + + final String walletId; + final String tokenMint; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // TODO: Use prefs for enabling/disabling exchange features when implemented for Solana. + // final prefs = ref.watch(prefsChangeNotifierProvider); + // final showExchange = prefs.enableExchange; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TokenOptionsButton( + onPressed: () { + Navigator.of(context).pushNamed( + SolTokenReceiveView.routeName, + arguments: (walletId, tokenMint), + ); + }, + subLabel: "Receive", + iconAssetPathSVG: Assets.svg.arrowDownLeft, + ), + const SizedBox(width: 16), + TokenOptionsButton( + onPressed: () { + Navigator.of(context).pushNamed( + SolTokenSendView.routeName, + arguments: (walletId, tokenMint), + ); + }, + subLabel: "Send", + iconAssetPathSVG: Assets.svg.arrowUpRight, + ), + // TODO: Add swap and buy buttons when Solana token swap/buy views are implemented. + // if (AppConfig.hasFeature(AppFeature.swap) && showExchange) + // const SizedBox(width: 16), + // if (AppConfig.hasFeature(AppFeature.swap) && showExchange) + // TokenOptionsButton( + // onPressed: () => _onExchangePressed(context), + // subLabel: "Swap", + // iconAssetPathSVG: ref.watch( + // themeProvider.select((value) => value.assets.exchange), + // ), + // ), + ], + ); + } +} + +/// A button for token wallet options (Send, Receive, Swap, Buy). +class TokenOptionsButton extends StatelessWidget { + const TokenOptionsButton({ + super.key, + required this.onPressed, + required this.subLabel, + required this.iconAssetPathSVG, + }); + + final VoidCallback onPressed; + final String subLabel; + final String iconAssetPathSVG; + + @override + Widget build(BuildContext context) { + final iconSize = subLabel == "Send" || subLabel == "Receive" ? 12.0 : 24.0; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + RawMaterialButton( + fillColor: + Theme.of(context).extension()!.tokenSummaryButtonBG, + elevation: 0, + focusElevation: 0, + hoverElevation: 0, + highlightElevation: 0, + constraints: const BoxConstraints(), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: onPressed, + child: Padding( + padding: const EdgeInsets.all(10), + child: ConditionalParent( + condition: iconSize < 24, + builder: + (child) => RoundedContainer( + padding: const EdgeInsets.all(6), + color: Theme.of(context) + .extension()! + .tokenSummaryIcon + .withOpacity(0.4), + radiusMultiplier: 10, + child: Center(child: child), + ), + child: + iconAssetPathSVG.startsWith("assets/") + ? SvgPicture.asset( + iconAssetPathSVG, + color: + Theme.of( + context, + ).extension()!.tokenSummaryIcon, + width: iconSize, + height: iconSize, + ) + : SvgPicture.file( + File(iconAssetPathSVG), + color: + Theme.of( + context, + ).extension()!.tokenSummaryIcon, + width: iconSize, + height: iconSize, + ), + ), + ), + ), + const SizedBox(height: 6), + Text( + subLabel, + style: STextStyles.w500_12(context).copyWith( + color: + Theme.of( + context, + ).extension()!.tokenSummaryTextPrimary, + ), + ), + ], + ); + } +} diff --git a/lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart b/lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart new file mode 100644 index 0000000000..ffbfb2e248 --- /dev/null +++ b/lib/pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart @@ -0,0 +1,202 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar_community/isar.dart'; + +import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; +import '../../wallet_view/sub_widgets/no_transactions_found.dart'; +import '../../wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart'; +import '../../../providers/db/main_db_provider.dart'; +import '../../../providers/global/wallets_provider.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/constants.dart'; +import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../../widgets/loading_indicator.dart'; + +/// Solana-specific transaction list widget. +/// +/// Displays transactions for a Solana token using the Solana token wallet provider. +class SolanaTokenTransactionsList extends ConsumerStatefulWidget { + const SolanaTokenTransactionsList({ + super.key, + required this.walletId, + }); + + final String walletId; + + @override + ConsumerState createState() => + _SolanaTransactionsListState(); +} + +class _SolanaTransactionsListState extends ConsumerState { + late final int minConfirms; + + bool _hasLoaded = false; + List _transactions = []; + + StreamSubscription>? _subscription; + Query? _query; + + BorderRadius get _borderRadiusFirst { + return BorderRadius.only( + topLeft: Radius.circular( + Constants.size.circularBorderRadius, + ), + topRight: Radius.circular( + Constants.size.circularBorderRadius, + ), + ); + } + + BorderRadius get _borderRadiusLast { + return BorderRadius.only( + bottomLeft: Radius.circular( + Constants.size.circularBorderRadius, + ), + bottomRight: Radius.circular( + Constants.size.circularBorderRadius, + ), + ); + } + + @override + void initState() { + minConfirms = ref + .read(pWallets) + .getWallet(widget.walletId) + .cryptoCurrency + .minConfirms; + super.initState(); + } + + /// Initialize the query and subscription when the wallet becomes available. + void _initializeQuery() { + if (_query != null) { + return; // Already initialized. + } + + // Get transaction filter from Solana token wallet if available. + final solanaTokenWallet = ref.read(pCurrentSolanaTokenWallet); + FilterOperation? transactionFilter; + + if (solanaTokenWallet != null) { + transactionFilter = solanaTokenWallet.transactionFilterOperation; + } + + _query = ref.read(mainDBProvider).isar.transactionV2s.buildQuery( + whereClauses: [ + IndexWhereClause.equalTo( + indexName: 'walletId', + value: [widget.walletId], + ), + ], + filter: transactionFilter, + sortBy: [ + const SortProperty( + property: "timestamp", + sort: Sort.desc, + ), + ], + ); + + _subscription = _query!.watch().listen((event) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _transactions = event; + }); + } + }); + }); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final wallet = + ref.watch(pWallets.select((value) => value.getWallet(widget.walletId))); + + // Ensure query is initialized when wallet becomes available. + _initializeQuery(); + + // If query hasn't been initialized yet, show loading. + if (_query == null) { + return Center( + child: Container( + color: Theme.of(context).extension()!.background, + child: const LoadingIndicator( + width: 100, + height: 100, + ), + ), + ); + } + + return FutureBuilder( + future: _query!.findAll(), + builder: (fbContext, AsyncSnapshot> snapshot) { + if (snapshot.connectionState == ConnectionState.done && + snapshot.hasData) { + if (!_hasLoaded) { + _hasLoaded = true; + _transactions = snapshot.data ?? []; + } + + if (_transactions.isEmpty) { + return const NoTransActionsFound(); + } + + return CustomScrollView( + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return TxListItem( + key: Key( + "solanaTokenTransactionV2ListItemKey_${_transactions[index].txid}", + ), + tx: _transactions[index], + coin: wallet.cryptoCurrency, + radius: index == 0 + ? _borderRadiusFirst + : index == _transactions.length - 1 + ? _borderRadiusLast + : null, + ); + }, + childCount: _transactions.length, + ), + ), + ], + ); + } + + return Center( + child: Container( + color: Theme.of(context).extension()!.background, + child: const LoadingIndicator( + width: 100, + height: 100, + ), + ), + ); + }, + ); + } +} diff --git a/lib/pages/wallet_view/sub_widgets/wallet_refresh_button.dart b/lib/pages/wallet_view/sub_widgets/wallet_refresh_button.dart index 6e98f4a3fb..e75a063d58 100644 --- a/lib/pages/wallet_view/sub_widgets/wallet_refresh_button.dart +++ b/lib/pages/wallet_view/sub_widgets/wallet_refresh_button.dart @@ -21,6 +21,7 @@ import '../../../themes/stack_colors.dart'; import '../../../utilities/constants.dart'; import '../../../utilities/util.dart'; import '../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; +import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../../../widgets/animated_widgets/rotating_arrows.dart'; /// [eventBus] should only be set during testing @@ -112,13 +113,24 @@ class _RefreshButtonState extends ConsumerState { splashColor: Theme.of(context).extension()!.highlight, onPressed: () { if (widget.tokenContractAddress == null) { - final wallet = ref.read(pWallets).getWallet(widget.walletId); - final isRefreshing = wallet.refreshMutex.isLocked; - if (!isRefreshing) { - _spinController.repeat?.call(); - wallet.refresh().then((_) => _spinController.stop?.call()); + // Solana token - check if there's a current Solana token wallet. + final solanaTokenWallet = ref.read(pCurrentSolanaTokenWallet); + if (solanaTokenWallet != null) { + if (!solanaTokenWallet.refreshMutex.isLocked) { + _spinController.repeat?.call(); + solanaTokenWallet.refresh().then((_) => _spinController.stop?.call()); + } + } else { + // Fall back to refreshing the parent Solana wallet. + final wallet = ref.read(pWallets).getWallet(widget.walletId); + final isRefreshing = wallet.refreshMutex.isLocked; + if (!isRefreshing) { + _spinController.repeat?.call(); + wallet.refresh().then((_) => _spinController.stop?.call()); + } } } else { + // Ethereum token. if (!ref.read(pCurrentTokenWallet)!.refreshMutex.isLocked) { ref.read(pCurrentTokenWallet)!.refresh(); } diff --git a/lib/pages/wallets_view/sub_widgets/wallet_list_item.dart b/lib/pages/wallets_view/sub_widgets/wallet_list_item.dart index 64d9ecbc9f..42955e0068 100644 --- a/lib/pages/wallets_view/sub_widgets/wallet_list_item.dart +++ b/lib/pages/wallets_view/sub_widgets/wallet_list_item.dart @@ -24,7 +24,9 @@ import '../../../utilities/show_loading.dart'; import '../../../utilities/show_node_tor_settings_mismatch.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; +import '../../../wallets/crypto_currency/coins/solana.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../wallets/wallet/intermediate/external_wallet.dart'; import '../../../widgets/dialogs/tor_warning_dialog.dart'; import '../../../widgets/rounded_white_container.dart'; @@ -80,7 +82,23 @@ class WalletListItem extends ConsumerWidget { } } - if (walletCount == 1 && coin is! Ethereum) { + // Check if we should show the wallets overview or open wallet directly. + bool shouldShowWalletsOverview = walletCount > 1 || coin is Ethereum; + + // For Solana and other token-supporting coins, check if any wallet has tokens. + if (!shouldShowWalletsOverview && coin.hasTokenSupport) { + final wallet = ref + .read(pWallets) + .wallets + .firstWhere((e) => e.info.coin == coin); + + final tokenAddresses = ref.read(pWalletTokenAddresses(wallet.walletId)); + if (tokenAddresses.isNotEmpty) { + shouldShowWalletsOverview = true; + } + } + + if (walletCount == 1 && !shouldShowWalletsOverview) { final wallet = ref .read(pWallets) .wallets diff --git a/lib/pages/wallets_view/wallets_overview.dart b/lib/pages/wallets_view/wallets_overview.dart index d0cc2d12da..29aaa3b02a 100644 --- a/lib/pages/wallets_view/wallets_overview.dart +++ b/lib/pages/wallets_view/wallets_overview.dart @@ -15,7 +15,8 @@ import 'package:isar_community/isar.dart'; import '../../app_config.dart'; import '../../models/add_wallet_list_entity/sub_classes/coin_entity.dart'; -import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../models/isar/models/contract.dart'; +import '../../pages_desktop_specific/my_stack_view/dialogs/desktop_expanding_solana_wallet_card.dart'; import '../../pages_desktop_specific/my_stack_view/dialogs/desktop_expanding_wallet_card.dart'; import '../../providers/providers.dart'; import '../../services/event_bus/events/wallet_added_event.dart'; @@ -59,7 +60,7 @@ class WalletsOverview extends ConsumerStatefulWidget { ConsumerState createState() => _EthWalletsOverviewState(); } -typedef WalletListItemData = ({Wallet wallet, List contracts}); +typedef WalletListItemData = ({Wallet wallet, List contracts}); class _EthWalletsOverviewState extends ConsumerState { final isDesktop = Util.isDesktop; @@ -99,15 +100,13 @@ class _EthWalletsOverviewState extends ConsumerState { term, ); - final List contracts = []; + final List contracts = []; for (final contract in entry.value.contracts) { if (_elementContains(contract.name, term)) { contracts.add(contract); } else if (_elementContains(contract.symbol, term)) { contracts.add(contract); - } else if (_elementContains(contract.type.name, term)) { - contracts.add(contract); } else if (_elementContains(contract.address, term)) { contracts.add(contract); } @@ -133,7 +132,7 @@ class _EthWalletsOverviewState extends ConsumerState { if (widget.coin is Ethereum) { for (final data in walletsData) { - final List contracts = []; + final List contracts = []; final contractAddresses = ref.read( pWalletTokenAddresses(data.walletId), ); @@ -150,6 +149,31 @@ class _EthWalletsOverviewState extends ConsumerState { } } + // add tuple to list + wallets[data.walletId] = ( + wallet: ref.read(pWallets).getWallet(data.walletId), + contracts: contracts, + ); + } + } else if (widget.coin is Solana) { + for (final data in walletsData) { + final List contracts = []; + final tokenMintAddresses = ref.read( + pWalletTokenAddresses(data.walletId), + ); + + // fetch each token + for (final tokenAddress in tokenMintAddresses) { + final token = ref + .read(mainDBProvider) + .getSolContractSync(tokenAddress); + + // add it to list if it exists in DB + if (token != null) { + contracts.add(token); + } + } + // add tuple to list wallets[data.walletId] = ( wallet: ref.read(pWallets).getWallet(data.walletId), @@ -319,13 +343,23 @@ class _EthWalletsOverviewState extends ConsumerState { if (wallet.cryptoCurrency.hasTokenSupport) { if (isDesktop) { - return DesktopExpandingWalletCard( - key: Key( - "${wallet.walletId}_${entry.contracts.map((e) => e.address).join()}", - ), - data: entry, - navigatorState: widget.navigatorState!, - ); + if (wallet.cryptoCurrency is Solana) { + return DesktopExpandingSolanaWalletCard( + key: Key( + "${wallet.walletId}_${entry.contracts.map((e) => e.address).join()}", + ), + data: entry, + navigatorState: widget.navigatorState!, + ); + } else { + return DesktopExpandingWalletCard( + key: Key( + "${wallet.walletId}_${entry.contracts.map((e) => e.address).join()}", + ), + data: entry, + navigatorState: widget.navigatorState!, + ); + } } else { return MasterWalletCard( key: Key(wallet.walletId), diff --git a/lib/pages_desktop_specific/my_stack_view/dialogs/desktop_expanding_solana_wallet_card.dart b/lib/pages_desktop_specific/my_stack_view/dialogs/desktop_expanding_solana_wallet_card.dart new file mode 100644 index 0000000000..db0afd695c --- /dev/null +++ b/lib/pages_desktop_specific/my_stack_view/dialogs/desktop_expanding_solana_wallet_card.dart @@ -0,0 +1,201 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2025-11-20 + * + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../../pages/wallets_view/wallets_overview.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/assets.dart'; +import '../../../utilities/constants.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../widgets/animated_widgets/rotate_icon.dart'; +import '../../../widgets/expandable.dart'; +import '../../../widgets/rounded_white_container.dart'; +import '../../../widgets/wallet_card.dart'; +import '../../../widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart'; +import '../../../widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart'; + +class DesktopExpandingSolanaWalletCard extends StatefulWidget { + const DesktopExpandingSolanaWalletCard({ + super.key, + required this.data, + required this.navigatorState, + }); + + final WalletListItemData data; + final NavigatorState navigatorState; + + @override + State createState() => + _DesktopExpandingSolanaWalletCardState(); +} + +class _DesktopExpandingSolanaWalletCardState + extends State { + final expandableController = ExpandableController(); + final rotateIconController = RotateIconController(); + final List tokenMintAddresses = []; + + @override + void initState() { + if (widget.data.wallet.cryptoCurrency.hasTokenSupport) { + tokenMintAddresses.addAll( + widget.data.contracts.map((e) => e.address), + ); + } + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return RoundedWhiteContainer( + padding: EdgeInsets.zero, + borderColor: Theme.of(context).extension()!.backgroundAppBar, + child: Expandable( + initialState: widget.data.wallet.cryptoCurrency.hasTokenSupport + ? ExpandableState.expanded + : ExpandableState.collapsed, + controller: expandableController, + onExpandWillChange: (toState) { + if (toState == ExpandableState.expanded) { + rotateIconController.forward?.call(); + } else { + rotateIconController.reverse?.call(); + } + }, + header: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 14, + ), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + Expanded( + flex: 2, + child: Row( + children: [ + WalletInfoCoinIcon( + coin: widget.data.wallet.info.coin, + ), + const SizedBox( + width: 12, + ), + Text( + widget.data.wallet.info.name, + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ), + ], + ), + ), + Expanded( + flex: 4, + child: WalletInfoRowBalance( + walletId: widget.data.wallet.walletId, + ), + ), + ], + ), + ), + MaterialButton( + padding: const EdgeInsets.all(5), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + minWidth: 32, + height: 32, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, + elevation: 0, + hoverElevation: 0, + disabledElevation: 0, + highlightElevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: () { + if (expandableController.state == ExpandableState.collapsed) { + rotateIconController.forward?.call(); + } else { + rotateIconController.reverse?.call(); + } + expandableController.toggle?.call(); + }, + child: RotateIcon( + controller: rotateIconController, + icon: RotatedBox( + quarterTurns: 2, + child: SvgPicture.asset( + Assets.svg.chevronDown, + width: 14, + ), + ), + curve: Curves.easeInOut, + ), + ), + ], + ), + ), + body: ListView( + shrinkWrap: true, + primary: false, + children: [ + Container( + width: double.infinity, + height: 1, + color: + Theme.of(context).extension()!.backgroundAppBar, + ), + Padding( + padding: const EdgeInsets.only( + left: 32, + right: 14, + top: 14, + bottom: 14, + ), + child: SimpleWalletCard( + walletId: widget.data.wallet.walletId, + popPrevious: true, + desktopNavigatorState: widget.navigatorState, + ), + ), + ...tokenMintAddresses.map( + (e) => Padding( + padding: const EdgeInsets.only( + left: 32, + right: 14, + top: 14, + bottom: 14, + ), + child: SimpleWalletCard( + walletId: widget.data.wallet.walletId, + contractAddress: e, + popPrevious: true, + desktopNavigatorState: widget.navigatorState, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart new file mode 100644 index 0000000000..12dab0b07a --- /dev/null +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart @@ -0,0 +1,237 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:event_bus/event_bus.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:tuple/tuple.dart'; + +import '../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; +import '../../../pages/token_view/solana_token_contract_details_view.dart'; +import '../../../pages/token_view/sub_widgets/token_transaction_list_widget_sol.dart'; +import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/assets.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../../wallets/isar/providers/solana/solana_wallet_provider.dart'; +import '../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../widgets/coin_ticker_tag.dart'; +import '../../../widgets/custom_buttons/blue_text_button.dart'; +import '../../../widgets/desktop/desktop_app_bar.dart'; +import '../../../widgets/desktop/desktop_scaffold.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/icon_widgets/sol_token_icon.dart'; +import '../../../widgets/rounded_white_container.dart'; +import 'sub_widgets/desktop_wallet_features.dart'; +import 'sub_widgets/desktop_wallet_summary.dart'; +import 'sub_widgets/my_wallet.dart'; + +/// [eventBus] should only be set during testing. +class DesktopSolTokenView extends ConsumerStatefulWidget { + const DesktopSolTokenView({ + super.key, + required this.walletId, + required this.tokenMint, + this.eventBus, + }); + + static const String routeName = "/desktopSolTokenView"; + + final String walletId; + final String tokenMint; + final EventBus? eventBus; + + @override + ConsumerState createState() => _DesktopTokenViewState(); +} + +class _DesktopTokenViewState extends ConsumerState { + static const double sendReceiveColumnWidth = 460; + + late final WalletSyncStatus initialSyncStatus; + + @override + void initState() { + // Get the initial sync status from the Solana wallet's refresh mutex. + final solanaWallet = ref.read(pSolanaWallet(widget.walletId)); + initialSyncStatus = solanaWallet?.refreshMutex.isLocked ?? false + ? WalletSyncStatus.syncing + : WalletSyncStatus.synced; + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + + return DesktopScaffold( + appBar: DesktopAppBar( + background: Theme.of(context).extension()!.popupBG, + leading: Expanded( + flex: 3, + child: Row( + children: [ + const SizedBox(width: 32), + SecondaryButton( + padding: const EdgeInsets.only(left: 12, right: 18), + buttonHeight: ButtonHeight.s, + label: ref.watch(pWalletName(widget.walletId)), + icon: SvgPicture.asset( + Assets.svg.arrowLeft, + width: 18, + height: 18, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + ), + onPressed: () { + ref.refresh(feeSheetSessionCacheProvider); + Navigator.of(context).pop(); + }, + ), + const SizedBox(width: 15), + ], + ), + ), + center: Expanded( + flex: 4, + child: Consumer( + builder: (context, ref, _) { + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); + final tokenName = tokenWallet?.tokenName ?? "Token"; + final tokenSymbol = tokenWallet?.tokenSymbol ?? "SOL"; + return GestureDetector( + onTap: () { + Navigator.of(context).pushNamed( + SolanaTokenContractDetailsView.routeName, + arguments: Tuple2( + widget.tokenMint, + widget.walletId, + ), + ); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Row( + children: [ + SolTokenIcon(mintAddress: widget.tokenMint, size: 32), + const SizedBox(width: 12), + Text(tokenName, style: STextStyles.desktopH3(context)), + const SizedBox(width: 12), + CoinTickerTag(ticker: tokenSymbol), + ], + ), + ), + ); + }, + ), + ), + useSpacers: false, + isCompactHeight: true, + ), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + RoundedWhiteContainer( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + SolTokenIcon(mintAddress: widget.tokenMint, size: 40), + const SizedBox(width: 10), + DesktopWalletSummary( + walletId: widget.walletId, + isToken: true, + initialSyncStatus: initialSyncStatus, + ), + const Spacer(), + DesktopWalletFeatures(walletId: widget.walletId), + ], + ), + ), + const SizedBox(height: 24), + Row( + children: [ + SizedBox( + width: sendReceiveColumnWidth, + child: Text( + "My wallet", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconLeft, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Recent transactions", + style: STextStyles.desktopTextExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconLeft, + ), + ), + CustomTextButton( + text: "See all", + onTap: () { + // TODO: Navigate to all transactions for this token + // Navigator.of(context).pushNamed( + // AllTransactionsV2View.routeName, + // arguments: ( + // walletId: widget.walletId, + // tokenMint: "TODO_TOKEN_MINT", + // ), + // ); + }, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: sendReceiveColumnWidth, + child: MyWallet( + walletId: widget.walletId, + contractAddress: widget.tokenMint, + ), + ), + const SizedBox(width: 16), + Expanded( + child: SolanaTokenTransactionsList( + walletId: widget.walletId, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart index 577d4e322a..c1597a4e87 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart @@ -12,9 +12,11 @@ import 'package:event_bus/event_bus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:tuple/tuple.dart'; import '../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart'; import '../../../pages/token_view/sub_widgets/token_transaction_list_widget.dart'; +import '../../../pages/token_view/token_contract_details_view.dart'; import '../../../pages/wallet_view/transaction_views/tx_v2/all_transactions_v2_view.dart'; import '../../../providers/providers.dart'; import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; @@ -102,32 +104,48 @@ class _DesktopTokenViewState extends ConsumerState { ), center: Expanded( flex: 4, - child: Row( - children: [ - EthTokenIcon( - contractAddress: ref.watch( - pCurrentTokenWallet.select( - (value) => value!.tokenContract.address, - ), + child: GestureDetector( + onTap: () { + final contractAddress = ref.watch( + pCurrentTokenWallet.select( + (value) => value!.tokenContract.address, ), - size: 32, - ), - const SizedBox(width: 12), - Text( - ref.watch( - pCurrentTokenWallet.select( - (value) => value!.tokenContract.name, + ); + Navigator.of(context).pushNamed( + TokenContractDetailsView.routeName, + arguments: Tuple2(contractAddress, widget.walletId), + ); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Row( + children: [ + EthTokenIcon( + contractAddress: ref.watch( + pCurrentTokenWallet.select( + (value) => value!.tokenContract.address, + ), + ), + size: 32, ), - ), - style: STextStyles.desktopH3(context), - ), - const SizedBox(width: 12), - CoinTickerTag( - ticker: ref.watch( - pWalletCoin(widget.walletId).select((s) => s.ticker), - ), + const SizedBox(width: 12), + Text( + ref.watch( + pCurrentTokenWallet.select( + (value) => value!.tokenContract.name, + ), + ), + style: STextStyles.desktopH3(context), + ), + const SizedBox(width: 12), + CoinTickerTag( + ticker: ref.watch( + pWalletCoin(widget.walletId).select((s) => s.ticker), + ), + ), + ], ), - ], + ), ), ), useSpacers: false, diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart index ce8940c80d..3cbb94493a 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart @@ -622,7 +622,9 @@ class _DesktopReceiveState extends ConsumerState { Row( children: [ Text( - "Your ${widget.contractAddress == null ? coin.ticker : ref.watch(pCurrentTokenWallet.select((value) => value!.tokenContract.symbol))} address", + // "Your ${widget.contractAddress == null ? coin.ticker : ref.watch(pCurrentTokenWallet.select((value) => value!.tokenContract.symbol))} address", + // TODO [prio=high]: Make the above work for Sol tokens instead of the placeholder below. + "Your ${widget.contractAddress == null ? coin.ticker : "token"} address", style: STextStyles.itemSubtitle(context), ), const Spacer(), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart index e0dfd7fc11..0b9377599d 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart @@ -77,6 +77,7 @@ class _DesktopSendFeeFormState extends ConsumerState { Widget build(BuildContext context) { final canEditFees = isEth || + cryptoCurrency is Solana || (cryptoCurrency is ElectrumXCurrencyInterface && !(((cryptoCurrency is Firo) && (ref.watch(publicPrivateBalanceStateProvider.state).state == @@ -211,15 +212,21 @@ class _DesktopSendFeeFormState extends ConsumerState { .estimateFeeFor(amount, feeRate); } } else { - final tokenWallet = ref.read( - pCurrentTokenWallet, - )!; - final fee = await tokenWallet - .estimateFeeFor(amount, feeRate); - ref - .read(tokenFeeSessionCacheProvider) - .average[amount] = - fee; + // Token fee estimation (works for ERC20 and SOL tokens). + try { + final tokenWallet = ref.read( + pCurrentTokenWallet, + )!; + final fee = await tokenWallet + .estimateFeeFor(amount, feeRate); + ref + .read(tokenFeeSessionCacheProvider) + .average[amount] = + fee; + } catch (_) { + // Token wallet not available. + debugPrint("Token fee estimation not available"); + } } } return ref diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart new file mode 100644 index 0000000000..cc20e75f0c --- /dev/null +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart @@ -0,0 +1,1065 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'dart:async'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../models/isar/models/contact_entry.dart'; +import '../../../../models/paynym/paynym_account_lite.dart'; +import '../../../../models/send_view_auto_fill_data.dart'; +import '../../../../pages/send_view/confirm_transaction_view.dart'; +import '../../../../pages/send_view/sub_widgets/building_transaction_dialog.dart'; +import '../../../../providers/providers.dart'; +import '../../../../providers/ui/preview_tx_button_state_provider.dart'; +import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/address_utils.dart'; +import '../../../../utilities/amount/amount.dart'; +import '../../../../utilities/amount/amount_formatter.dart'; +import '../../../../utilities/amount/amount_input_formatter.dart'; +import '../../../../utilities/clipboard_interface.dart'; +import '../../../../utilities/constants.dart'; +import '../../../../utilities/logger.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../utilities/util.dart'; +import '../../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; +import '../../../../wallets/models/tx_data.dart'; +import '../../../../widgets/custom_buttons/blue_text_button.dart'; +import '../../../../widgets/desktop/desktop_dialog.dart'; +import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../../widgets/desktop/primary_button.dart'; +import '../../../../widgets/desktop/qr_code_scanner_dialog.dart'; +import '../../../../widgets/desktop/secondary_button.dart'; +import '../../../../widgets/icon_widgets/addressbook_icon.dart'; +import '../../../../widgets/icon_widgets/clipboard_icon.dart'; +import '../../../../widgets/icon_widgets/x_icon.dart'; +import '../../../../widgets/stack_text_field.dart'; +import '../../../../widgets/textfield_icon_button.dart'; +import '../../../desktop_home_view.dart'; +import 'address_book_address_chooser/address_book_address_chooser.dart'; + +class DesktopSolTokenSend extends ConsumerStatefulWidget { + const DesktopSolTokenSend({ + super.key, + required this.walletId, + this.autoFillData, + this.clipboard = const ClipboardWrapper(), + + this.accountLite, + }); + + final String walletId; + final SendViewAutoFillData? autoFillData; + final ClipboardInterface clipboard; + final PaynymAccountLite? accountLite; + + @override + ConsumerState createState() => _DesktopSolTokenSendState(); +} + +class _DesktopSolTokenSendState extends ConsumerState { + late final String walletId; + late final CryptoCurrency coin; + late final ClipboardInterface clipboard; + + late TextEditingController sendToController; + late TextEditingController cryptoAmountController; + late TextEditingController baseAmountController; + late TextEditingController nonceController; + + late final SendViewAutoFillData? _data; + + final _addressFocusNode = FocusNode(); + final _cryptoFocus = FocusNode(); + final _baseFocus = FocusNode(); + // Solana doesn't use nonces like Ethereum. + // final _nonceFocusNode = FocusNode(); + + String? _note; + + Amount? _amountToSend; + Amount? _cachedAmountToSend; + String? _address; + + bool _addressToggleFlag = false; + + bool _cryptoAmountChangeLock = false; + late VoidCallback onCryptoAmountChanged; + + Future previewSend() async { + final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!; + + final Amount amount = _amountToSend!; + + // Get the current balance from the database. + final balance = ref.read( + pSolanaTokenBalance(( + walletId: walletId, + tokenMint: tokenWallet.tokenMint, + )), + ); + + final availableBalance = balance.spendable; + + // confirm send all + if (amount == availableBalance) { + final bool? shouldSendAll = await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return DesktopDialog( + maxWidth: 450, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.only(left: 32, bottom: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Confirm send all", + style: STextStyles.desktopH3(context), + ), + const DesktopDialogCloseButton(), + ], + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.only(right: 32), + child: Text( + "You are about to send your entire balance. Would you like to continue?", + textAlign: TextAlign.left, + style: STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith(fontSize: 18), + ), + ), + const SizedBox(height: 40), + Padding( + padding: const EdgeInsets.only(right: 32), + child: Row( + children: [ + Expanded( + child: SecondaryButton( + buttonHeight: ButtonHeight.l, + label: "Cancel", + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + buttonHeight: ButtonHeight.l, + label: "Yes", + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + + if (shouldSendAll == null || shouldSendAll == false) { + // cancel preview + return; + } + } + + try { + bool wasCancelled = false; + + if (mounted) { + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return DesktopDialog( + maxWidth: 400, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.all(32), + child: BuildingTransactionDialog( + coin: tokenWallet.cryptoCurrency, + isSpark: false, + onCancel: () { + wasCancelled = true; + + Navigator.of(context).pop(); + }, + ), + ), + ); + }, + ), + ); + } + + final time = Future.delayed(const Duration(milliseconds: 2500)); + + TxData txData; + Future txDataFuture; + + final tokenSymbol = tokenWallet.tokenSymbol; + final tokenMint = tokenWallet.tokenMint; + final tokenDecimals = tokenWallet.tokenDecimals; + + txDataFuture = tokenWallet.prepareSend( + txData: TxData( + recipients: [ + TxRecipient( + address: _address!, + amount: amount, + isChange: false, + addressType: + tokenWallet.cryptoCurrency.getAddressType(_address!)!, + ), + ], + tokenSymbol: tokenSymbol, + tokenMint: tokenMint, + tokenDecimals: tokenDecimals, + ), + ); + + final results = await Future.wait([txDataFuture, time]); + + txData = results.first as TxData; + + if (!wasCancelled && mounted) { + txData = txData.copyWith( + note: _note ?? "", + tokenSymbol: tokenSymbol, + tokenMint: tokenMint, + tokenDecimals: tokenDecimals, + ); + + // pop building dialog + Navigator.of(context, rootNavigator: true).pop(); + + unawaited( + showDialog( + context: context, + builder: + (context) => DesktopDialog( + maxHeight: MediaQuery.of(context).size.height - 64, + maxWidth: 580, + child: ConfirmTransactionView( + txData: txData, + walletId: walletId, + onSuccess: clearSendForm, + isTokenTx: true, + routeOnSuccessName: DesktopHomeView.routeName, + ), + ), + ), + ); + } + } catch (e) { + if (mounted) { + // pop building dialog + Navigator.of(context, rootNavigator: true).pop(); + + unawaited( + showDialog( + context: context, + builder: (context) { + return DesktopDialog( + maxWidth: 450, + maxHeight: double.infinity, + child: Padding( + padding: const EdgeInsets.only(left: 32, bottom: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Transaction failed", + style: STextStyles.desktopH3(context), + ), + const DesktopDialogCloseButton(), + ], + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.only(right: 32), + child: SelectableText( + e.toString(), + textAlign: TextAlign.left, + style: STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith(fontSize: 18), + ), + ), + const SizedBox(height: 40), + Row( + children: [ + Expanded( + child: SecondaryButton( + buttonHeight: ButtonHeight.l, + label: "Ok", + onPressed: () { + Navigator.of( + context, + rootNavigator: true, + ).pop(); + }, + ), + ), + const SizedBox(width: 32), + ], + ), + ], + ), + ), + ); + }, + ), + ); + } + } + } + + void clearSendForm() { + sendToController.text = ""; + cryptoAmountController.text = ""; + baseAmountController.text = ""; + // Note: Solana doesn't use nonces like Ethereum. + _address = ""; + _addressToggleFlag = false; + if (mounted) { + setState(() {}); + } + } + + void _cryptoAmountChanged() async { + if (!_cryptoAmountChangeLock) { + // Get the token's decimal places for proper amount parsing + final tokenDecimals = + ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals; + + if (cryptoAmountController.text.isNotEmpty && + cryptoAmountController.text != "." && + cryptoAmountController.text != ",") { + try { + // Parse the amount using the token's decimal places, not the coin's + final inputDecimal = Decimal.parse( + cryptoAmountController.text.replaceFirst(",", "."), + ); + final cryptoAmount = Amount.fromDecimal( + inputDecimal, + fractionDigits: tokenDecimals, + ); + + // Only proceed if the parsed amount is valid + if (cryptoAmount.raw > BigInt.zero) { + _amountToSend = cryptoAmount; + if (_cachedAmountToSend != null && + _cachedAmountToSend == _amountToSend) { + return; + } + _cachedAmountToSend = _amountToSend; + + final price = ref + .read(priceAnd24hChangeNotifierProvider) + .getTokenPrice( + ref.read(pCurrentSolanaTokenWallet)!.tokenMint, + ) + ?.value; + + if (price != null && price > Decimal.zero) { + final String fiatAmountString = Amount.fromDecimal( + _amountToSend!.decimal * price, + fractionDigits: 2, + ).fiatString( + locale: ref.read(localeServiceChangeNotifierProvider).locale, + ); + + baseAmountController.text = fiatAmountString; + } + } + } catch (e) { + // Probably an invalid decimal input. + _amountToSend = null; + _cachedAmountToSend = null; + baseAmountController.text = ""; + } + } else { + _amountToSend = null; + _cachedAmountToSend = null; + baseAmountController.text = ""; + } + + _updatePreviewButtonState(_address, _amountToSend); + } + } + + String? _updateInvalidAddressText(String address) { + if (_data != null && _data!.contactLabel == address) { + return null; + } + if (address.isNotEmpty && + !ref + .read(pWallets) + .getWallet(walletId) + .cryptoCurrency + .validateAddress(address)) { + return "Invalid address"; + } + return null; + } + + void _updatePreviewButtonState(String? address, Amount? amount) { + final wallet = ref.read(pWallets).getWallet(walletId); + + final isValidAddress = wallet.cryptoCurrency.validateAddress(address ?? ""); + ref.read(previewTokenTxButtonStateProvider.state).state = + (isValidAddress && amount != null && amount > Amount.zero); + } + + Future scanQr() async { + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 75)); + } + + final qrResult = await showDialog( + context: context, + builder: (context) => const QrCodeScannerDialog(), + ); + + if (qrResult == null) { + Logging.instance.w("Qr scanning cancelled"); + return; + } + + Logging.instance.d("qrResult content: $qrResult"); + + final paymentData = AddressUtils.parsePaymentUri( + qrResult, + logging: Logging.instance, + ); + + Logging.instance.d("qrResult parsed: $paymentData"); + + if (paymentData != null && + paymentData.coin?.uriScheme == coin.uriScheme) { + // auto fill address + _address = paymentData.address.trim(); + sendToController.text = _address!; + + // autofill notes field + if (paymentData.message != null) { + _note = paymentData.message!; + } else if (paymentData.label != null) { + _note = paymentData.label!; + } + + // autofill amount field + if (paymentData.amount != null) { + final Amount amount = Decimal.parse(paymentData.amount!).toAmount( + fractionDigits: + ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals, + ); + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .format(amount, withUnitName: false); + + _amountToSend = amount; + } + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + + // now check for non standard encoded basic address + } else { + _address = qrResult.split("\n").first.trim(); + sendToController.text = _address ?? ""; + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + } on PlatformException catch (e, s) { + // here we ignore the exception caused by not giving permission + // to use the camera to scan a qr code + Logging.instance.w( + "Failed to get camera permissions while trying to scan qr code in SendView: ", + error: e, + stackTrace: s, + ); + } + } + + Future pasteAddress() async { + final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null && data!.text!.isNotEmpty) { + String content = data.text!.trim(); + if (content.contains("\n")) { + content = content.substring(0, content.indexOf("\n")); + } + + sendToController.text = content; + _address = content; + + _updatePreviewButtonState(_address, _amountToSend); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + } + + void fiatTextFieldOnChanged(String baseAmountString) { + final int tokenDecimals = + ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals; + + if (baseAmountString.isNotEmpty && + baseAmountString != "." && + baseAmountString != ",") { + final baseAmount = + baseAmountString.contains(",") + ? Decimal.parse( + baseAmountString.replaceFirst(",", "."), + ).toAmount(fractionDigits: 2) + : Decimal.parse(baseAmountString).toAmount(fractionDigits: 2); + + final Decimal? _price = + ref + .read(priceAnd24hChangeNotifierProvider) + .getTokenPrice( + ref.read(pCurrentSolanaTokenWallet)!.tokenMint, + ) + ?.value; + + if (_price == null || _price == Decimal.zero) { + _amountToSend = Decimal.zero.toAmount(fractionDigits: tokenDecimals); + } else { + _amountToSend = + baseAmount <= Amount.zero + ? Decimal.zero.toAmount(fractionDigits: tokenDecimals) + : (baseAmount.decimal / _price) + .toDecimal(scaleOnInfinitePrecision: tokenDecimals) + .toAmount(fractionDigits: tokenDecimals); + } + if (_cachedAmountToSend != null && _cachedAmountToSend == _amountToSend) { + return; + } + _cachedAmountToSend = _amountToSend; + + final amountString = ref + .read(pAmountFormatter(coin)) + .format( + _amountToSend!, + withUnitName: false, + ); + + _cryptoAmountChangeLock = true; + cryptoAmountController.text = amountString; + _cryptoAmountChangeLock = false; + } else { + _amountToSend = Decimal.zero.toAmount(fractionDigits: tokenDecimals); + _cryptoAmountChangeLock = true; + cryptoAmountController.text = ""; + _cryptoAmountChangeLock = false; + } + + _updatePreviewButtonState(_address, _amountToSend); + } + + Future sendAllTapped() async { + final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!; + final balance = ref.read( + pSolanaTokenBalance(( + walletId: walletId, + tokenMint: tokenWallet.tokenMint, + )), + ); + + cryptoAmountController.text = balance + .spendable + .decimal + .toStringAsFixed(tokenWallet.tokenDecimals); + } + + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) { + // ref.refresh(tokenFeeSessionCacheProvider); // Ethereum-specific + ref.read(previewTokenTxButtonStateProvider.state).state = false; + }); + + // _calculateFeesFuture = calculateFees(0); + _data = widget.autoFillData; + walletId = widget.walletId; + final wallet = ref.read(pWallets).getWallet(walletId); + coin = wallet.info.coin; + clipboard = widget.clipboard; + + sendToController = TextEditingController(); + cryptoAmountController = TextEditingController(); + baseAmountController = TextEditingController(); + // Solana doesn't use nonces like Ethereum. + // nonceController = TextEditingController(); + // feeController = TextEditingController(); + + onCryptoAmountChanged = _cryptoAmountChanged; + cryptoAmountController.addListener(onCryptoAmountChanged); + + if (_data != null) { + if (_data!.amount != null) { + cryptoAmountController.text = _data!.amount!.toString(); + } + sendToController.text = _data!.contactLabel; + _address = _data!.address; + _addressToggleFlag = true; + } + + super.initState(); + } + + @override + void dispose() { + cryptoAmountController.removeListener(onCryptoAmountChanged); + + sendToController.dispose(); + cryptoAmountController.dispose(); + baseAmountController.dispose(); + // nonceController.dispose(); // Solana doesn't use nonces. + // feeController.dispose(); + + _addressFocusNode.dispose(); + _cryptoFocus.dispose(); + _baseFocus.dispose(); + // _nonceFocusNode.dispose(); // Solana doesn't use nonces. + super.dispose(); + } + + @override + Widget build(BuildContext context) { + debugPrint("BUILD: $runtimeType"); + + final tokenWallet = ref.watch(pCurrentSolanaTokenWallet); + + // If wallet is not initialized, show a placeholder. + if (tokenWallet == null) { + return Center( + child: Text( + "Loading token data...", + style: STextStyles.subtitle500(context), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + if (coin is Firo) + Text( + "Send from", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: + Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + textAlign: TextAlign.left, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Amount", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: + Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + textAlign: TextAlign.left, + ), + CustomTextButton( + text: "Send all ${tokenWallet.tokenSymbol}", + onTap: sendAllTapped, + ), + ], + ), + const SizedBox(height: 10), + TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop ? false : true, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of(context).extension()!.textDark, + ), + key: const Key("amountInputFieldCryptoTextFieldKey"), + controller: cryptoAmountController, + focusNode: _cryptoFocus, + keyboardType: + Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + textAlign: TextAlign.right, + inputFormatters: [ + AmountInputFormatter( + decimals: tokenWallet.tokenDecimals, + unit: ref.watch(pAmountUnit(coin)), + locale: ref.watch( + localeServiceChangeNotifierProvider.select( + (value) => value.locale, + ), + ), + ), + // regex to validate a crypto amount with 8 decimal places + // TextInputFormatter.withFunction((oldValue, newValue) => RegExp( + // _kCryptoAmountRegex.replaceAll( + // "0,8", + // "0,${tokenContract.decimals}", + // ), + // ).hasMatch(newValue.text) + // ? newValue + // : oldValue), + ], + onChanged: (newValue) {}, + decoration: InputDecoration( + contentPadding: const EdgeInsets.only( + top: 22, + right: 12, + bottom: 22, + ), + hintText: "0", + hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith( + color: + Theme.of( + context, + ).extension()!.textFieldDefaultText, + ), + prefixIcon: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + tokenWallet.tokenSymbol, + style: STextStyles.smallMed14(context).copyWith( + color: + Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ), + ), + ), + if (ref.watch( + prefsChangeNotifierProvider.select((s) => s.externalCalls), + )) + const SizedBox(height: 10), + if (ref.watch( + prefsChangeNotifierProvider.select((s) => s.externalCalls), + )) + TextField( + autocorrect: Util.isDesktop ? false : true, + enableSuggestions: Util.isDesktop ? false : true, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of(context).extension()!.textDark, + ), + key: const Key("amountInputFieldFiatTextFieldKey"), + controller: baseAmountController, + focusNode: _baseFocus, + keyboardType: + Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), + textAlign: TextAlign.right, + inputFormatters: [ + AmountInputFormatter( + decimals: 2, + locale: ref.watch( + localeServiceChangeNotifierProvider.select( + (value) => value.locale, + ), + ), + ), + // // regex to validate a fiat amount with 2 decimal places + // TextInputFormatter.withFunction((oldValue, newValue) => + // RegExp(r'^([0-9]*[,.]?[0-9]{0,2}|[,.][0-9]{0,2})$') + // .hasMatch(newValue.text) + // ? newValue + // : oldValue), + ], + onChanged: fiatTextFieldOnChanged, + decoration: InputDecoration( + contentPadding: const EdgeInsets.only( + top: 22, + right: 12, + bottom: 22, + ), + hintText: "0", + hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith( + color: + Theme.of( + context, + ).extension()!.textFieldDefaultText, + ), + prefixIcon: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + ref.watch( + prefsChangeNotifierProvider.select( + (value) => value.currency, + ), + ), + style: STextStyles.smallMed14(context).copyWith( + color: + Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 20), + Text( + "Send to", + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: + Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + textAlign: TextAlign.left, + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + minLines: 1, + maxLines: 5, + key: const Key("sendViewAddressFieldKey"), + controller: sendToController, + readOnly: false, + autocorrect: false, + enableSuggestions: false, + // inputFormatters: [ + // FilteringTextInputFormatter.allow( + // RegExp("[a-zA-Z0-9]{34}")), + // ], + toolbarOptions: const ToolbarOptions( + copy: false, + cut: false, + paste: true, + selectAll: false, + ), + onChanged: (newValue) { + _address = newValue; + _updatePreviewButtonState(_address, _amountToSend); + + setState(() { + _addressToggleFlag = newValue.isNotEmpty; + }); + }, + focusNode: _addressFocusNode, + style: STextStyles.desktopTextExtraSmall(context).copyWith( + color: + Theme.of( + context, + ).extension()!.textFieldActiveText, + height: 1.8, + ), + decoration: standardInputDecoration( + "Enter Solana address", + _addressFocusNode, + context, + desktopMed: true, + ).copyWith( + contentPadding: const EdgeInsets.only( + left: 16, + top: 11, + bottom: 12, + right: 5, + ), + suffixIcon: Padding( + padding: + sendToController.text.isEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _addressToggleFlag + ? TextFieldIconButton( + key: const Key( + "sendTokenViewClearAddressFieldButtonKey", + ), + onTap: () { + sendToController.text = ""; + _address = ""; + _updatePreviewButtonState( + _address, + _amountToSend, + ); + setState(() { + _addressToggleFlag = false; + }); + }, + child: const XIcon(), + ) + : TextFieldIconButton( + key: const Key( + "sendTokenViewPasteAddressFieldButtonKey", + ), + onTap: pasteAddress, + child: + sendToController.text.isEmpty + ? const ClipboardIcon() + : const XIcon(), + ), + if (sendToController.text.isEmpty) + TextFieldIconButton( + key: const Key("sendTokenViewAddressBookButtonKey"), + onTap: () async { + final entry = await showDialog< + ContactAddressEntry? + >( + context: context, + builder: + (context) => DesktopDialog( + maxWidth: 696, + maxHeight: 600, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only( + left: 32, + ), + child: Text( + "Address book", + style: STextStyles.desktopH3( + context, + ), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: AddressBookAddressChooser( + coin: coin, + ), + ), + ], + ), + ), + ); + + if (entry != null) { + sendToController.text = + entry.other ?? entry.label; + + _address = entry.address; + + _updatePreviewButtonState( + _address, + _amountToSend, + ); + + setState(() { + _addressToggleFlag = true; + }); + } + }, + child: const AddressBookIcon(), + ), + ], + ), + ), + ), + ), + ), + ), + Builder( + builder: (_) { + final error = _updateInvalidAddressText(_address ?? ""); + + if (error == null || error.isEmpty) { + return Container(); + } else { + return Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(left: 12.0, top: 4.0), + child: Text( + error, + textAlign: TextAlign.left, + style: STextStyles.label(context).copyWith( + color: + Theme.of(context).extension()!.textError, + ), + ), + ), + ); + } + }, + ), + const SizedBox(height: 36), + PrimaryButton( + buttonHeight: ButtonHeight.l, + label: "Preview send", + enabled: ref.watch(previewTokenTxButtonStateProvider.state).state, + onPressed: + ref.watch(previewTokenTxButtonStateProvider.state).state + ? previewSend + : null, + ), + ], + ); + } +} diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart index c6ad2694d3..c89a57dfb3 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_summary.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../models/balance.dart'; +import '../../../../models/isar/models/ethereum/eth_contract.dart'; import '../../../../pages/wallet_view/sub_widgets/wallet_refresh_button.dart'; import '../../../../providers/providers.dart'; import '../../../../providers/wallet/public_private_balance_state_provider.dart'; @@ -22,12 +23,17 @@ import '../../../../utilities/amount/amount.dart'; import '../../../../utilities/amount/amount_formatter.dart'; import '../../../../utilities/enums/wallet_balance_toggle_state.dart'; import '../../../../utilities/text_styles.dart'; +import '../../../../wallets/crypto_currency/coins/ethereum.dart'; import '../../../../wallets/crypto_currency/coins/firo.dart'; +import '../../../../wallets/crypto_currency/coins/solana.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart' show CryptoCurrency; import '../../../../wallets/isar/providers/eth/current_token_wallet_provider.dart'; import '../../../../wallets/isar/providers/eth/token_balance_provider.dart'; +import '../../../../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; +import '../../../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../../wallets/wallet/impl/sub_wallets/solana_token_wallet.dart'; import 'desktop_balance_toggle_button.dart'; class DesktopWalletSummary extends ConsumerStatefulWidget { @@ -77,25 +83,51 @@ class _WDesktopWalletSummaryState extends ConsumerState { prefsChangeNotifierProvider.select((value) => value.currency), ); - final tokenContract = - widget.isToken - ? ref.watch( - pCurrentTokenWallet.select((value) => value!.tokenContract), - ) - : null; - - final price = - widget.isToken - ? ref.watch( - priceAnd24hChangeNotifierProvider.select( - (value) => value.getTokenPrice(tokenContract!.address), - ), - ) - : ref.watch( - priceAnd24hChangeNotifierProvider.select( - (value) => value.getPrice(coin), - ), - ); + // For Ethereum tokens, get the token contract; for Solana tokens, get the token wallet. + final EthContract? tokenContract; + final SolanaTokenWallet? solanaTokenWallet; + if (widget.isToken) { + switch (ref.watch(pWalletCoin(walletId))) { + case Ethereum(): + tokenContract = ref.watch( + pCurrentTokenWallet.select((value) => value!.tokenContract), + ); + solanaTokenWallet = null; + break; + + case Solana(): + tokenContract = null; + // this cannot be null if coin is sol and isToken is true. + // if it is null, then there is a bug somewhere else. + solanaTokenWallet = ref.watch(pCurrentSolanaTokenWallet); + break; + + default: + tokenContract = null; + solanaTokenWallet = null; + } + } else { + tokenContract = null; + solanaTokenWallet = null; + } + + final price = widget.isToken && tokenContract != null + ? ref.watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getTokenPrice(tokenContract!.address), + ), + ) + : widget.isToken && solanaTokenWallet != null + ? ref.watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getTokenPrice(solanaTokenWallet!.tokenMint), + ), + ) + : ref.watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getPrice(coin), + ), + ); final _showAvailable = ref.watch(walletBalanceToggleStateProvider.state).state == @@ -115,15 +147,27 @@ class _WDesktopWalletSummaryState extends ConsumerState { break; } } else { - final Balance balance = - widget.isToken - ? ref.watch( - pTokenBalance(( - walletId: walletId, - contractAddress: tokenContract!.address, - )), - ) - : ref.watch(pWalletBalance(walletId)); + final Balance balance; + if (widget.isToken && tokenContract != null) { + // Ethereum token balance + balance = ref.watch( + pTokenBalance(( + walletId: walletId, + contractAddress: tokenContract.address, + )), + ); + } else if (widget.isToken && solanaTokenWallet != null) { + // Watch Solana token balance from db. + balance = ref.watch( + pSolanaTokenBalance(( + walletId: walletId, + tokenMint: solanaTokenWallet.tokenMint, + )), + ); + } else { + // Regular wallet balance. + balance = ref.watch(pWalletBalance(walletId)); + } balanceToShow = _showAvailable ? balance.spendable : balance.total; } @@ -141,7 +185,11 @@ class _WDesktopWalletSummaryState extends ConsumerState { child: SelectableText( ref .watch(pAmountFormatter(coin)) - .format(balanceToShow, ethContract: tokenContract), + .format( + balanceToShow, + ethContract: tokenContract, + solContract: solanaTokenWallet?.solContract, + ), style: STextStyles.desktopH3(context), ), ), @@ -149,10 +197,9 @@ class _WDesktopWalletSummaryState extends ConsumerState { SelectableText( "${Amount.fromDecimal(price.value * balanceToShow.decimal, fractionDigits: 2).fiatString(locale: locale)} $baseCurrency", style: STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), ), // if (coin is Firo) @@ -173,10 +220,9 @@ class _WDesktopWalletSummaryState extends ConsumerState { WalletRefreshButton( walletId: walletId, initialSyncStatus: widget.initialSyncStatus, - tokenContractAddress: - widget.isToken - ? ref.watch(pCurrentTokenWallet)!.tokenContract.address - : null, + tokenContractAddress: widget.isToken && tokenContract != null + ? tokenContract.address + : null, ), const SizedBox(width: 8), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart index 9a8d94da6f..1c490bea2c 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/my_wallet.dart @@ -16,8 +16,10 @@ import '../../../../pages/finalize_view/finalize_view.dart'; import '../../../../pages/send_view/frost_ms/frost_send_view.dart'; import '../../../../pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart'; import '../../../../providers/global/wallets_provider.dart'; +import '../../../../utilities/clipboard_interface.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import '../../../../wallets/wallet/impl/solana_wallet.dart' show SolanaWallet; import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../../widgets/custom_tab_view.dart'; import '../../../../widgets/desktop/secondary_button.dart'; @@ -26,6 +28,7 @@ import '../../../../widgets/rounded_white_container.dart'; import '../../my_stack_view.dart'; import 'desktop_receive.dart'; import 'desktop_send.dart'; +import 'desktop_sol_token_send.dart'; import 'desktop_token_send.dart'; class MyWallet extends ConsumerStatefulWidget { @@ -42,6 +45,7 @@ class _MyWalletState extends ConsumerState { final titles = ["Send", "Receive"]; late final bool isEth; + late final bool isSolana; late final CryptoCurrency coin; late final bool isFrost; late final bool isMimblewimblecoin; @@ -53,6 +57,7 @@ class _MyWalletState extends ConsumerState { coin = wallet.info.coin; isFrost = wallet is BitcoinFrostWallet; isEth = coin is Ethereum; + isSolana = wallet is SolanaWallet; isMimblewimblecoin = coin is Mimblewimblecoin; if (isMimblewimblecoin) { @@ -101,58 +106,74 @@ class _MyWalletState extends ConsumerState { children: [ widget.contractAddress == null ? isFrost - ? Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, + ? Column( children: [ - Padding( - padding: const EdgeInsets.fromLTRB(0, 20, 0, 0), - child: SecondaryButton( - width: 200, - buttonHeight: ButtonHeight.l, - label: "Import sign config", - onPressed: () async { - final wallet = + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + 0, + 20, + 0, + 0, + ), + child: SecondaryButton( + width: 200, + buttonHeight: ButtonHeight.l, + label: "Import sign config", + onPressed: () async { + final wallet = + ref + .read(pWallets) + .getWallet(widget.walletId) + as BitcoinFrostWallet; ref - .read(pWallets) - .getWallet(widget.walletId) - as BitcoinFrostWallet; - ref.read(pFrostScaffoldArgs.state).state = ( - info: ( - walletName: wallet.info.name, - frostCurrency: wallet.cryptoCurrency, - ), - walletId: widget.walletId, - stepRoutes: - FrostRouteGenerator + .read(pFrostScaffoldArgs.state) + .state = ( + info: ( + walletName: wallet.info.name, + frostCurrency: + wallet.cryptoCurrency, + ), + walletId: widget.walletId, + stepRoutes: FrostRouteGenerator .signFrostTxStepRoutes, - parentNav: Navigator.of(context), - frostInterruptionDialogType: - FrostInterruptionDialogType - .transactionCreation, - callerRouteName: MyStackView.routeName, - ); - - await Navigator.of( - context, - ).pushNamed(FrostStepScaffold.routeName); - }, - ), + parentNav: Navigator.of(context), + frostInterruptionDialogType: + FrostInterruptionDialogType + .transactionCreation, + callerRouteName: + MyStackView.routeName, + ); + + await Navigator.of(context).pushNamed( + FrostStepScaffold.routeName, + ); + }, + ), + ), + ], + ), + FrostSendView( + walletId: widget.walletId, + coin: coin, ), ], - ), - FrostSendView(walletId: widget.walletId, coin: coin), - ], - ) - : Padding( - padding: const EdgeInsets.all(20), - child: DesktopSend(walletId: widget.walletId), - ) + ) + : Padding( + padding: const EdgeInsets.all(20), + child: DesktopSend(walletId: widget.walletId), + ) : Padding( - padding: const EdgeInsets.all(20), - child: DesktopTokenSend(walletId: widget.walletId), - ), + padding: const EdgeInsets.all(20), + child: isSolana + ? DesktopSolTokenSend( + walletId: widget.walletId, + clipboard: const ClipboardWrapper(), + ) + : DesktopTokenSend(walletId: widget.walletId), + ), Padding( padding: const EdgeInsets.all(20), child: DesktopReceive( diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 1fe6313e38..35bf76bbb4 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -17,6 +17,7 @@ import 'app_config.dart'; import 'db/drift/database.dart'; import 'models/add_wallet_list_entity/add_wallet_list_entity.dart'; import 'models/add_wallet_list_entity/sub_classes/eth_token_entity.dart'; +import 'models/add_wallet_list_entity/sub_classes/sol_token_entity.dart'; import 'models/buy/response_objects/quote.dart'; import 'models/exchange/incomplete_exchange.dart'; import 'models/exchange/response_objects/trade.dart'; @@ -29,6 +30,7 @@ import 'models/keys/view_only_wallet_data.dart'; import 'models/paynym/paynym_account_lite.dart'; import 'models/send_view_auto_fill_data.dart'; import 'pages/add_wallet_views/add_token_view/add_custom_token_view.dart'; +import 'pages/add_wallet_views/add_token_view/add_custom_solana_token_view.dart'; import 'pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart'; import 'pages/add_wallet_views/add_wallet_view/add_wallet_view.dart'; import 'pages/add_wallet_views/create_or_restore_wallet_view/create_or_restore_wallet_view.dart'; @@ -43,6 +45,7 @@ import 'pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_ import 'pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart'; import 'pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart'; import 'pages/add_wallet_views/select_wallet_for_token_view.dart'; +import 'pages/add_wallet_views/select_wallet_for_sol_token_view.dart'; import 'pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart'; import 'pages/address_book_views/address_book_view.dart'; import 'pages/address_book_views/subviews/add_address_book_entry_view.dart'; @@ -93,10 +96,12 @@ import 'pages/receive_view/addresses/edit_address_label_view.dart'; import 'pages/receive_view/addresses/wallet_addresses_view.dart'; import 'pages/receive_view/generate_receiving_uri_qr_code_view.dart'; import 'pages/receive_view/receive_view.dart'; +import 'pages/receive_view/sol_token_receive_view.dart'; import 'pages/salvium_stake/salvium_create_stake_view.dart'; import 'pages/send_view/confirm_transaction_view.dart'; import 'pages/send_view/frost_ms/frost_send_view.dart'; import 'pages/send_view/send_view.dart'; +import 'pages/send_view/sol_token_send_view.dart'; import 'pages/send_view/token_send_view.dart'; import 'pages/settings_views/global_settings_view/about_view.dart'; import 'pages/settings_views/global_settings_view/advanced_views/advanced_settings_view.dart'; @@ -161,6 +166,8 @@ import 'pages/spark_names/sub_widgets/spark_name_details.dart'; import 'pages/special/firo_rescan_recovery_error_dialog.dart'; import 'pages/stack_privacy_calls.dart'; import 'pages/token_view/my_tokens_view.dart'; +import 'pages/token_view/sol_token_view.dart'; +import 'pages/token_view/solana_token_contract_details_view.dart'; import 'pages/token_view/token_contract_details_view.dart'; import 'pages/token_view/token_view.dart'; import 'pages/wallet_view/transaction_views/all_transactions_view.dart'; @@ -187,6 +194,7 @@ import 'pages_desktop_specific/desktop_exchange/desktop_exchange_view.dart'; import 'pages_desktop_specific/desktop_home_view.dart'; import 'pages_desktop_specific/mweb_utxos_view.dart'; import 'pages_desktop_specific/my_stack_view/my_stack_view.dart'; +import 'pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart'; import 'pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart'; import 'pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart'; import 'pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart'; @@ -367,6 +375,19 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case DesktopSolTokenView.routeName: + if (args is ({String walletId, String tokenMint})) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => DesktopSolTokenView( + walletId: args.walletId, + tokenMint: args.tokenMint, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SelectWalletForTokenView.routeName: if (args is EthTokenEntity) { return getRoute( @@ -377,6 +398,16 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SelectWalletForSolTokenView.routeName: + if (args is SolTokenEntity) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SelectWalletForSolTokenView(entity: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case AddCustomTokenView.routeName: return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, @@ -384,6 +415,14 @@ class RouteGenerator { settings: RouteSettings(name: settings.name), ); + case AddCustomSolanaTokenView.routeName: + final walletId = args is String ? args : null; + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => AddCustomSolanaTokenView(walletId: walletId), + settings: RouteSettings(name: settings.name), + ); + case WalletsOverview.routeName: if (args is CryptoCurrency) { return getRoute( @@ -407,6 +446,19 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SolanaTokenContractDetailsView.routeName: + if (args is Tuple2) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SolanaTokenContractDetailsView( + tokenMint: args.item1, + walletId: args.item2, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SingleFieldEditView.routeName: if (args is Tuple2) { return getRoute( @@ -1790,6 +1842,32 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SolTokenSendView.routeName: + if (args is (String, String)) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SolTokenSendView( + walletId: args.$1, + tokenMint: args.$2, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case SolTokenReceiveView.routeName: + if (args is (String, String)) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SolTokenReceiveView( + walletId: args.$1, + tokenMint: args.$2, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case ConfirmTransactionView.routeName: if (args is (TxData, String, VoidCallback)) { return getRoute( @@ -2529,6 +2607,29 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SolTokenView.routeName: + if (args is ({String walletId, String tokenMint})) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SolTokenView( + walletId: args.walletId, + tokenMint: args.tokenMint, + ), + settings: RouteSettings(name: settings.name), + ); + } else if (args is ({String walletId, String tokenMint, bool popPrevious})) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SolTokenView( + walletId: args.walletId, + tokenMint: args.tokenMint, + popPrevious: args.popPrevious, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + // == End of desktop specific routes ===================================== case SparkViewKeyView.routeName: diff --git a/lib/services/price.dart b/lib/services/price.dart index e20d96a2a3..abf47795ca 100644 --- a/lib/services/price.dart +++ b/lib/services/price.dart @@ -300,4 +300,96 @@ class PriceAPI { return tokenPrices; } } + + /// Get prices and 24h change for Solana SOL tokens. + /// + /// Uses CoinGecko API to fetch prices for tokens by their Solana mint addresses. + /// Format: GET /api/v3/simple/token_price/solana?vs_currencies=usd&contract_addresses=mint1,mint2&include_24hr_change=true + Future> + getPricesAnd24hChangeForSolTokens({ + required Set contractAddresses, + required String baseCurrency, + }) async { + final Map tokenPrices = {}; + + if (AppConfig.coins.whereType().isEmpty || + contractAddresses.isEmpty) { + return tokenPrices; + } + + final externalCalls = Prefs.instance.externalCalls; + if ((!Util.isTestEnv && !externalCalls) || + !(await Prefs.instance.isExternalCallsSet())) { + Logging.instance.i("User does not want to use external calls"); + return tokenPrices; + } + + try { + // Build comma-separated list of mint addresses. + final mintsParam = contractAddresses.join(','); + final uri = Uri.parse( + "https://api.coingecko.com/api/v3/simple/token_price/solana" + "?vs_currencies=${baseCurrency.toLowerCase()}" + "&contract_addresses=$mintsParam" + "&include_24hr_change=true", + ); + + final coinGeckoResponse = await client.get( + url: uri, + headers: {'Content-Type': 'application/json'}, + proxyInfo: Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ); + + if (coinGeckoResponse.code == 200) { + try { + final coinGeckoData = jsonDecode(coinGeckoResponse.body) as Map; + + for (final mint in contractAddresses) { + final map = coinGeckoData[mint.toLowerCase()] as Map?; + if (map != null) { + try { + final price = Decimal.parse( + map[baseCurrency.toLowerCase()].toString(), + ); + final change24h = double.parse( + map["${baseCurrency.toLowerCase()}_24h_change"].toString(), + ); + + tokenPrices[mint.toLowerCase()] = ( + value: price, + change24h: change24h, + ); + } catch (e) { + // only log the error as we don't want to interrupt the rest of the loop + Logging.instance.w( + "getPricesAnd24hChangeForSolTokens($baseCurrency,$mint): Failed to parse price data: $e", + ); + } + } + } + } catch (e, s) { + // only log the error as we don't want to interrupt the rest of the loop + Logging.instance.w( + "getPricesAnd24hChangeForSolTokens($baseCurrency): Error parsing response: $e\n$s\nRESPONSE: ${coinGeckoResponse.body}", + ); + } + } else { + Logging.instance.w( + "getPricesAnd24hChangeForSolTokens($baseCurrency): HTTP ${coinGeckoResponse.code}", + ); + } + + return tokenPrices; + } catch (e, s) { + Logging.instance.e( + "getPricesAnd24hChangeForSolTokens($baseCurrency,$contractAddresses): ", + error: e, + stackTrace: s, + ); + // return previous cached values + return tokenPrices; + } + } } diff --git a/lib/services/price_service.dart b/lib/services/price_service.dart index 51fa4fab43..4cbf27d864 100644 --- a/lib/services/price_service.dart +++ b/lib/services/price_service.dart @@ -25,6 +25,9 @@ class PriceService extends ChangeNotifier { Future> get tokenContractAddressesToCheck async => (await MainDB.instance.getEthContracts().addressProperty().findAll()) .toSet(); + Future> get solTokenContractAddressesToCheck async => + (await MainDB.instance.getSolContracts().addressProperty().findAll()) + .toSet(); final Duration updateInterval = const Duration(seconds: 60); Timer? _timer; @@ -73,6 +76,22 @@ class PriceService extends ChangeNotifier { } } + final _solTokenContractAddressesToCheck = await solTokenContractAddressesToCheck; + + if (_solTokenContractAddressesToCheck.isNotEmpty) { + final solTokenPriceMap = await _priceAPI.getPricesAnd24hChangeForSolTokens( + contractAddresses: _solTokenContractAddressesToCheck, + baseCurrency: baseTicker, + ); + + for (final map in solTokenPriceMap.entries) { + if (_cachedTokenPrices[map.key] != map.value) { + _cachedTokenPrices[map.key] = map.value; + shouldNotify = true; + } + } + } + if (shouldNotify) { notifyListeners(); } diff --git a/lib/services/solana/solana_token_api.dart b/lib/services/solana/solana_token_api.dart new file mode 100644 index 0000000000..3798e45c2a --- /dev/null +++ b/lib/services/solana/solana_token_api.dart @@ -0,0 +1,428 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:solana/dto.dart'; +import 'package:solana/solana.dart'; + +/// Exception for Solana token API errors. +class SolanaTokenApiException implements Exception { + final String message; + final Exception? originalException; + + SolanaTokenApiException(this.message, {this.originalException}); + + @override + String toString() => 'SolanaTokenApiException: $message'; +} + +/// Result wrapper for Solana token API calls. +class SolanaTokenApiResponse { + final T? value; + final Exception? exception; + + SolanaTokenApiResponse({this.value, this.exception}); + + bool get isSuccess => exception == null && value != null; + bool get isError => exception != null; + + @override + String toString() => isSuccess ? 'Success($value)' : 'Error($exception)'; +} + +/// Data class for token account information. +class TokenAccountInfo { + final String address; + final String owner; + final String mint; + final BigInt balance; + final int decimals; + final bool isNative; + + TokenAccountInfo({ + required this.address, + required this.owner, + required this.mint, + required this.balance, + required this.decimals, + required this.isNative, + }); + + factory TokenAccountInfo.fromJson(String address, Map json) { + Map? parsed; + Map? infoMap; + + try { + final data = json['data']; + if (data is Map) { + final dataMap = Map.from(data); + final parsedVal = dataMap['parsed']; + if (parsedVal is Map) { + parsed = Map.from(parsedVal); + } + } + if (parsed != null) { + final infoVal = parsed['info']; + if (infoVal is Map) { + infoMap = Map.from(infoVal); + } + } + } catch (e) { + // Silently ignore parsing errors, use empty map + } + + final info = infoMap ?? {}; + + final owner = info['owner']; + final mint = info['mint']; + final tokenAmount = info['tokenAmount']; + final amountStr = (tokenAmount is Map) + ? (tokenAmount as Map)['amount'] + : null; + final decimalsVal = (tokenAmount is Map) + ? (tokenAmount as Map)['decimals'] + : null; + + final isNative = (parsed is Map) + ? ((parsed as Map)['type'] == 'account' && + (parsed as Map)['program'] == 'spl-token') + : false; + + return TokenAccountInfo( + address: address, + owner: owner is String ? owner : (owner?.toString() ?? ''), + mint: mint is String ? mint : (mint?.toString() ?? ''), + balance: BigInt.parse((amountStr?.toString() ?? '0')), + decimals: decimalsVal is int + ? decimalsVal + : (int.tryParse(decimalsVal?.toString() ?? '0') ?? 0), + isNative: isNative, + ); + } + + @override + String toString() => + 'TokenAccountInfo(address=$address, owner=$owner, mint=$mint, balance=$balance, decimals=$decimals)'; +} + +/// Solana SPL Token API service. +/// +/// Provides methods to interact with Solana token accounts and metadata +/// using RPC calls. Uses the solana package's RpcClient under the hood. +class SolanaTokenAPI { + static final SolanaTokenAPI _instance = SolanaTokenAPI._internal(); + + factory SolanaTokenAPI() { + return _instance; + } + + SolanaTokenAPI._internal(); + + RpcClient? _rpcClient; + + void initializeRpcClient(RpcClient rpcClient) { + _rpcClient = rpcClient; + } + + void _checkClient() { + if (_rpcClient == null) { + throw SolanaTokenApiException( + 'RPC client not initialized. Call initializeRpcClient() first.', + ); + } + } + + Future>> getTokenAccountsByOwner( + String ownerAddress, { + String? mint, + }) async { + try { + _checkClient(); + + const splTokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + + final result = await _rpcClient!.getTokenAccountsByOwner( + ownerAddress, + mint != null + ? TokenAccountsFilter.byMint(mint) + : TokenAccountsFilter.byProgramId(splTokenProgramId), + encoding: Encoding.jsonParsed, + ); + + final accountAddresses = result.value + .map((account) => account.pubkey) + .toList(); + + return SolanaTokenApiResponse>(value: accountAddresses); + } on Exception catch (e) { + return SolanaTokenApiResponse>( + exception: SolanaTokenApiException( + 'Failed to get token accounts: ${e.toString()}', + originalException: e, + ), + ); + } + } + + Future> getTokenAccountBalance( + String tokenAccountAddress, + ) async { + try { + _checkClient(); + + final response = await _rpcClient!.getAccountInfo( + tokenAccountAddress, + encoding: Encoding.jsonParsed, + ); + + if (response.value == null) { + return SolanaTokenApiResponse(value: BigInt.zero); + } + + final accountData = response.value!; + + try { + final parsedData = accountData.data; + + if (parsedData is ParsedAccountData) { + try { + final extractedBalance = parsedData.when( + splToken: (spl) { + return spl.when( + account: (info, type, accountType) { + try { + final tokenAmount = info.tokenAmount; + return BigInt.parse(tokenAmount.amount); + } catch (e) { + return null; + } + }, + mint: (info, type, accountType) => null, + unknown: (type) => null, + ); + }, + stake: (_) => null, + token2022: (token2022Data) { + return token2022Data.when( + account: (info, type, accountType) { + try { + final tokenAmount = info.tokenAmount; + return BigInt.parse(tokenAmount.amount); + } catch (e) { + return null; + } + }, + mint: (info, type, accountType) => null, + unknown: (type) => null, + ); + }, + unsupported: (_) => null, + ); + + if (extractedBalance != null && extractedBalance is BigInt) { + return SolanaTokenApiResponse( + value: extractedBalance as BigInt, + ); + } + } catch (e) { + // Ignore parsing errors. + } + } + + return SolanaTokenApiResponse(value: BigInt.zero); + } catch (e) { + return SolanaTokenApiResponse(value: BigInt.zero); + } + } on Exception catch (e) { + return SolanaTokenApiResponse( + exception: SolanaTokenApiException( + 'Failed to get token balance: ${e.toString()}', + originalException: e, + ), + ); + } + } + + // TODO: Implement full RPC call when API is ready. + Future> getTokenSupply(String mint) async { + try { + _checkClient(); + // TODO: Get the mint account info when RPC APIs are stable. + return SolanaTokenApiResponse( + value: BigInt.parse('1000000000000000000'), + ); + } on Exception catch (e) { + return SolanaTokenApiResponse( + exception: SolanaTokenApiException( + 'Failed to get token supply: ${e.toString()}', + originalException: e, + ), + ); + } + } + + // TODO: Implement full RPC call when API is ready. + Future> getTokenAccountInfo( + String tokenAccountAddress, + ) async { + try { + _checkClient(); + + // Return placeholder data. + // + // TODO: Implement actual RPC call using proper client methods. + return SolanaTokenApiResponse( + value: TokenAccountInfo( + address: tokenAccountAddress, + owner: 'placeholder_owner', + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + balance: BigInt.from(1000000000), + decimals: 6, + isNative: false, + ), + ); + } on Exception catch (e) { + return SolanaTokenApiResponse( + exception: SolanaTokenApiException( + 'Failed to get token account info: ${e.toString()}', + originalException: e, + ), + ); + } + } + + String findAssociatedTokenAddress(String ownerAddress, String mint) { + // Return a placeholder. + // + // TODO: Implement ATA derivation using Solana package. + return ''; + } + + Future> ownsToken( + String ownerAddress, + String mint, + ) async { + try { + _checkClient(); + + // Get token accounts for this owner and mint. + final accounts = await getTokenAccountsByOwner(ownerAddress, mint: mint); + + if (accounts.isError) { + return SolanaTokenApiResponse(exception: accounts.exception); + } + + // If we got token accounts, the user owns this token. + final hasTokenAccount = + accounts.value != null && (accounts.value as List).isNotEmpty; + return SolanaTokenApiResponse(value: hasTokenAccount); + } on Exception catch (e) { + return SolanaTokenApiResponse( + exception: SolanaTokenApiException( + 'Failed to check token ownership: ${e.toString()}', + originalException: e, + ), + ); + } + } + + Future?>> + fetchTokenMetadataByMint( + String mintAddress, + ) async { + try { + _checkClient(); + + // TODO: Implement proper metadata PDA derivation when solana package + // exposes findProgramAddress() utilities. + // + // The Solana Token Metadata program (metaqbxxUerdq28cj1RbAqWwTRiWLs6nshmbbuP3xqb) + // stores token metadata at a PDA derived from the mint address using: + // findProgramAddress( + // ["metadata", metadataProgram, mintPubkey], + // metadataProgram + // ) + // + // Until then, return null to allow users to enter custom token details. + + // Metadata PDA derivation not yet implemented + return SolanaTokenApiResponse?>( + value: null, + ); + } on Exception { + // On error, return null to allow user to manually enter token details + return SolanaTokenApiResponse?>( + value: null, + ); + } + } + + /// Validate if a string is a valid Solana mint address. + /// + /// A valid Solana address must: + /// - Be base58 encoded + /// - Be between 40-50 characters long + /// - Represent a valid Ed25519 public key + /// + /// Returns: true if valid, false otherwise. + bool isValidSolanaMintAddress(String address) { + try { + // Check length (Solana addresses are ~44 chars in base58). + if (address.length < 40 || address.length > 50) return false; + + // Try to parse as Ed25519 public key from base58. + Ed25519HDPublicKey.fromBase58(address); + + // Valid if parsing succeeds. + return true; + } catch (e) { + return false; + } + } + + /// Detect which token program owns a mint address. + /// + /// Queries the RPC to get the mint account info and checks which program owns it. + /// This is needed to determine whether to use standard SPL Token instructions + /// or Token-2022 (Token Extensions) instructions for transfers. + /// + /// Returns: "spl" for standard SPL Token, "token2022" for Token Extensions, or null if detection fails. + Future getTokenProgramType(String mintAddress) async { + try { + _checkClient(); + + // Query the mint account to check its owner program. + final response = await _rpcClient!.getAccountInfo( + mintAddress, + encoding: Encoding.jsonParsed, + ); + + if (response.value == null) { + return null; + } + + final owner = response.value!.owner; + + // Rough check which program owns this mint. + // + // For now all we need to know ius if it's SPL or newer. + // TODO [prio=low]: Fix via program metadata parsing or similar. + if (owner == 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA') { + return 'spl'; + } else { + if (owner.startsWith('Token')) { + return 'token2022'; + } + } + + return null; + } catch (e) { + return null; + } + } +} diff --git a/lib/themes/coin_icon_provider.dart b/lib/themes/coin_icon_provider.dart index 1deb22b4e1..a732c69c14 100644 --- a/lib/themes/coin_icon_provider.dart +++ b/lib/themes/coin_icon_provider.dart @@ -9,9 +9,10 @@ */ import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../models/isar/stack_theme.dart'; -import 'theme_providers.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; +import 'theme_providers.dart'; final coinIconProvider = Provider.family((ref, coin) { final assets = ref.watch(themeAssetsProvider); @@ -28,8 +29,6 @@ final coinIconProvider = Provider.family((ref, coin) { return assets.dogecoin; case const (Epiccash): return assets.epicCash; - case const (Mimblewimblecoin): - return assets.mimblewimblecoin; case const (Firo): return assets.firo; case const (Monero): diff --git a/lib/utilities/amount/amount.dart b/lib/utilities/amount/amount.dart index a1a68576f2..b31d87d7a0 100644 --- a/lib/utilities/amount/amount.dart +++ b/lib/utilities/amount/amount.dart @@ -15,31 +15,24 @@ import 'package:decimal/decimal.dart'; import '../util.dart'; class Amount { - Amount({ - required BigInt rawValue, - required this.fractionDigits, - }) : assert(fractionDigits >= 0), - _value = rawValue; + const Amount({required BigInt rawValue, required this.fractionDigits}) + : assert(fractionDigits >= 0), + _value = rawValue; /// special zero case with [fractionDigits] set to 0 - static Amount get zero => Amount( - rawValue: BigInt.zero, - fractionDigits: 0, - ); + static Amount get zero => .zeroWith(fractionDigits: 0); - Amount.zeroWith({required this.fractionDigits}) - : assert(fractionDigits >= 0), - _value = BigInt.zero; + Amount.zeroWith({required int fractionDigits}) + : this(rawValue: BigInt.from(0), fractionDigits: fractionDigits); /// truncate decimal value to [fractionDigits] places - Amount.fromDecimal(Decimal amount, {required this.fractionDigits}) - : assert(fractionDigits >= 0), - _value = amount.shift(fractionDigits).toBigInt(); - - static Amount? tryParseFiatString( - String value, { - required String locale, - }) { + Amount.fromDecimal(Decimal amount, {required int fractionDigits}) + : this( + rawValue: amount.shift(fractionDigits).toBigInt(), + fractionDigits: fractionDigits, + ); + + static Amount? tryParseFiatString(String value, {required String locale}) { final parts = value.split(" "); if (parts.first.isEmpty) { @@ -98,9 +91,7 @@ class Amount { return jsonEncode(toMap()); } - String fiatString({ - required String locale, - }) { + String fiatString({required String locale}) { final wholeNumber = decimal.truncate(); // get number symbols for decimal place and group separator @@ -172,10 +163,7 @@ class Amount { "fractionDigits do not match: this=$this, other=$other", ); } - return Amount( - rawValue: raw + other.raw, - fractionDigits: fractionDigits, - ); + return Amount(rawValue: raw + other.raw, fractionDigits: fractionDigits); } Amount operator -(Amount other) { @@ -184,10 +172,7 @@ class Amount { "fractionDigits do not match: this=$this, other=$other", ); } - return Amount( - rawValue: raw - other.raw, - fractionDigits: fractionDigits, - ); + return Amount(rawValue: raw - other.raw, fractionDigits: fractionDigits); } Amount operator *(Amount other) { @@ -196,10 +181,7 @@ class Amount { "fractionDigits do not match: this=$this, other=$other", ); } - return Amount( - rawValue: raw * other.raw, - fractionDigits: fractionDigits, - ); + return Amount(rawValue: raw * other.raw, fractionDigits: fractionDigits); } // =========================================================================== @@ -226,18 +208,12 @@ class Amount { extension DecimalAmountExt on Decimal { Amount toAmount({required int fractionDigits}) { - return Amount.fromDecimal( - this, - fractionDigits: fractionDigits, - ); + return Amount.fromDecimal(this, fractionDigits: fractionDigits); } } extension IntAmountExtension on int { Amount toAmountAsRaw({required int fractionDigits}) { - return Amount( - rawValue: BigInt.from(this), - fractionDigits: fractionDigits, - ); + return Amount(rawValue: BigInt.from(this), fractionDigits: fractionDigits); } } diff --git a/lib/utilities/amount/amount_formatter.dart b/lib/utilities/amount/amount_formatter.dart index 44746b8cdb..f5e047f1ee 100644 --- a/lib/utilities/amount/amount_formatter.dart +++ b/lib/utilities/amount/amount_formatter.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../models/isar/models/solana/sol_contract.dart'; import '../../providers/global/locale_provider.dart'; import '../../providers/global/prefs_provider.dart'; import 'amount.dart'; @@ -52,6 +53,7 @@ class AmountFormatter { Amount amount, { String? overrideUnit, EthContract? ethContract, + SolContract? solContract, bool withUnitName = true, bool indicatePrecisionLoss = true, }) { @@ -64,6 +66,7 @@ class AmountFormatter { indicatePrecisionLoss: indicatePrecisionLoss, overrideUnit: overrideUnit, tokenContract: ethContract, + splToken: solContract, ); } diff --git a/lib/utilities/amount/amount_unit.dart b/lib/utilities/amount/amount_unit.dart index 79e45232b3..7763a3e6f8 100644 --- a/lib/utilities/amount/amount_unit.dart +++ b/lib/utilities/amount/amount_unit.dart @@ -12,6 +12,7 @@ import 'dart:math' as math; import 'package:decimal/decimal.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../models/isar/models/solana/sol_contract.dart'; import 'amount.dart'; import '../util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; @@ -175,6 +176,27 @@ extension AmountUnitExt on AmountUnit { } } + String unitForSplToken(SolContract token) { + switch (this) { + case AmountUnit.normal: + return token.symbol; + case AmountUnit.milli: + return "m${token.symbol}"; + case AmountUnit.micro: + return "µ${token.symbol}"; + case AmountUnit.nano: + case AmountUnit.pico: + case AmountUnit.femto: + case AmountUnit.atto: + case AmountUnit.zepto: + case AmountUnit.yocto: + case AmountUnit.ronto: + case AmountUnit.quecto: + // For SOL tokens, just use the symbol with the prefix if applicable. + return token.symbol; + } + } + Amount? tryParse( String value, { required String locale, @@ -231,6 +253,7 @@ extension AmountUnitExt on AmountUnit { bool indicatePrecisionLoss = true, String? overrideUnit, EthContract? tokenContract, + SolContract? splToken, }) { assert(maxDecimalPlaces >= 0); @@ -274,6 +297,10 @@ extension AmountUnitExt on AmountUnit { updatedMax = maxDecimalPlaces > tokenContract.decimals ? tokenContract.decimals : maxDecimalPlaces; + } else if (splToken != null) { + updatedMax = maxDecimalPlaces > splToken.decimals + ? splToken.decimals + : maxDecimalPlaces; } else { updatedMax = maxDecimalPlaces > coin.fractionDigits ? coin.fractionDigits @@ -329,6 +356,8 @@ extension AmountUnitExt on AmountUnit { // return the value with the proper unit symbol if (tokenContract != null) { overrideUnit = unitForContract(tokenContract); + } else if (splToken != null) { + overrideUnit = unitForSplToken(splToken); } return "$returnValue ${overrideUnit ?? unitForCoin(coin)}"; diff --git a/lib/utilities/assets.dart b/lib/utilities/assets.dart index e75bf8eb4e..d912567d3d 100644 --- a/lib/utilities/assets.dart +++ b/lib/utilities/assets.dart @@ -233,29 +233,6 @@ class _SVG { String get trocadorRatingC => "assets/svg/trocador_rating_c.svg"; String get trocadorRatingD => "assets/svg/trocador_rating_d.svg"; - // TODO provide proper assets - String get bitcoinTestnet => "assets/svg/coin_icons/Bitcoin.svg"; - String get bitcoincashTestnet => "assets/svg/coin_icons/Bitcoincash.svg"; - String get firoTestnet => "assets/svg/coin_icons/Firo.svg"; - String get dogecoinTestnet => "assets/svg/coin_icons/Dogecoin.svg"; - String get particlTestnet => "assets/svg/coin_icons/Particl.svg"; - - // small icons - String get bitcoin => "assets/svg/coin_icons/Bitcoin.svg"; - String get litecoin => "assets/svg/coin_icons/Litecoin.svg"; - String get bitcoincash => "assets/svg/coin_icons/Bitcoincash.svg"; - String get dogecoin => "assets/svg/coin_icons/Dogecoin.svg"; - String get epicCash => "assets/svg/coin_icons/EpicCash.svg"; - String get mimblewimblecoin => "assets/svg/coin_icons/Mimblewimblecoin.svg"; - String get ethereum => "assets/svg/coin_icons/Ethereum.svg"; - String get firo => "assets/svg/coin_icons/Firo.svg"; - String get monero => "assets/svg/coin_icons/Monero.svg"; - String get wownero => "assets/svg/coin_icons/Wownero.svg"; - String get namecoin => "assets/svg/coin_icons/Namecoin.svg"; - String get particl => "assets/svg/coin_icons/Particl.svg"; - - String get bnbIcon => "assets/svg/coin_icons/bnb_icon.svg"; - String get spark => "assets/svg/spark.svg"; } diff --git a/lib/utilities/default_sol_tokens.dart b/lib/utilities/default_sol_tokens.dart new file mode 100644 index 0000000000..fbc69a7ac7 --- /dev/null +++ b/lib/utilities/default_sol_tokens.dart @@ -0,0 +1,55 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import '../models/isar/models/solana/sol_contract.dart'; + +abstract class DefaultSolTokens { + static List list = [ + SolContract( + address: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + name: "USD Coin", + symbol: "USDC", + decimals: 6, + logoUri: + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png", + ), + SolContract( + address: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenEst", + name: "Tether", + symbol: "USDT", + decimals: 6, + logoUri: + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenEst/logo.svg", + ), + SolContract( + address: "MangoCzJ36AjZyKwVj3VnYU4GTonjfVEnJmvvWaxLac", + name: "Mango", + symbol: "MNGO", + decimals: 6, + logoUri: + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/MangoCzJ36AjZyKwVj3VnYU4GTonjfVEnJmvvWaxLac/logo.png", + ), + SolContract( + address: "SRMuApVgqbCmmp3uVrwpad5p4stLBUq3nSoSnqQQXmk", + name: "Serum", + symbol: "SRM", + decimals: 6, + logoUri: + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/SRMuApVgqbCmmp3uVrwpad5p4stLBUq3nSoSnqQQXmk/logo.png", + ), + SolContract( + address: "orca8TvxvggsCKvVPXSHXDvKgJ3bNroWusDawg461mpD", + name: "Orca", + symbol: "ORCA", + decimals: 6, + logoUri: + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/orcaEKTdK7LKz57chYcSKdBI6qrE5dS1zG4FqHWGcKc/logo.svg", + ), + ]; +} diff --git a/lib/wallets/crypto_currency/coins/solana.dart b/lib/wallets/crypto_currency/coins/solana.dart index 03331a922d..b4f40a86e7 100644 --- a/lib/wallets/crypto_currency/coins/solana.dart +++ b/lib/wallets/crypto_currency/coins/solana.dart @@ -41,6 +41,9 @@ class Solana extends Bip39Currency { @override String get ticker => _ticker; + @override + bool get hasTokenSupport => true; + @override NodeModel defaultNode({required bool isPrimary}) { switch (network) { diff --git a/lib/wallets/isar/models/token_wallet_info.dart b/lib/wallets/isar/models/token_wallet_info.dart index 842d04bf0d..767c5a2f9a 100644 --- a/lib/wallets/isar/models/token_wallet_info.dart +++ b/lib/wallets/isar/models/token_wallet_info.dart @@ -38,11 +38,15 @@ class TokenWalletInfo implements IsarId { // token balance cache Balance getCachedBalance() { if (cachedBalanceJsonString == null) { + final amount = Amount( + rawValue: BigInt.zero, + fractionDigits: tokenFractionDigits, + ); return Balance( - total: Amount.zeroWith(fractionDigits: tokenFractionDigits), - spendable: Amount.zeroWith(fractionDigits: tokenFractionDigits), - blockedTotal: Amount.zeroWith(fractionDigits: tokenFractionDigits), - pendingSpendable: Amount.zeroWith(fractionDigits: tokenFractionDigits), + total: amount, + spendable: amount, + blockedTotal: amount, + pendingSpendable: amount, ); } return Balance.fromJson(cachedBalanceJsonString!, tokenFractionDigits); diff --git a/lib/wallets/isar/models/wallet_info.dart b/lib/wallets/isar/models/wallet_info.dart index d65ceeeb4e..db0a65c701 100644 --- a/lib/wallets/isar/models/wallet_info.dart +++ b/lib/wallets/isar/models/wallet_info.dart @@ -75,6 +75,28 @@ class WalletInfo implements IsarId { } } + @ignore + List get solanaTokenMintAddresses { + if (otherData[WalletInfoKeys.solanaTokenMintAddresses] is List) { + return List.from( + otherData[WalletInfoKeys.solanaTokenMintAddresses] as List, + ); + } else { + return []; + } + } + + @ignore + List get solanaCustomTokenMintAddresses { + if (otherData[WalletInfoKeys.solanaCustomTokenMintAddresses] is List) { + return List.from( + otherData[WalletInfoKeys.solanaCustomTokenMintAddresses] as List, + ); + } else { + return []; + } + } + /// Special case for coins such as firo lelantus @ignore Balance get cachedBalanceSecondary { @@ -400,6 +422,32 @@ class WalletInfo implements IsarId { ); } + /// Update Solana token mint addresses and update the db. + Future updateSolanaTokenMintAddresses({ + required Set newMintAddresses, + required Isar isar, + }) async { + await updateOtherData( + newEntries: { + WalletInfoKeys.solanaTokenMintAddresses: newMintAddresses.toList(), + }, + isar: isar, + ); + } + + /// Update custom Solana token mint addresses and update the db. + Future updateSolanaCustomTokenMintAddresses({ + required List newMintAddresses, + required Isar isar, + }) async { + await updateOtherData( + newEntries: { + WalletInfoKeys.solanaCustomTokenMintAddresses: newMintAddresses.toList(), + }, + isar: isar, + ); + } + Future setMwebEnabled({ required bool newValue, required Isar isar, @@ -530,4 +578,7 @@ abstract class WalletInfoKeys { static const String firoSparkUsedTagsCacheResetVersion = "firoSparkUsedTagsCacheResetVersionKey"; static const String enableLegacyAddresses = "enableLegacyAddressesKey"; + static const String solanaTokenMintAddresses = "solanaTokenMintAddressesKey"; + static const String solanaCustomTokenMintAddresses = + "solanaCustomTokenMintAddressesKey"; } diff --git a/lib/wallets/isar/models/wallet_solana_token_info.dart b/lib/wallets/isar/models/wallet_solana_token_info.dart new file mode 100644 index 0000000000..473db74119 --- /dev/null +++ b/lib/wallets/isar/models/wallet_solana_token_info.dart @@ -0,0 +1,92 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:isar_community/isar.dart'; + +import '../../../models/balance.dart'; +import '../../../models/isar/models/isar_models.dart'; +import '../../../utilities/amount/amount.dart'; +import '../isar_id_interface.dart'; + +part 'wallet_solana_token_info.g.dart'; + +@Collection(accessor: "walletSolanaTokenInfo", inheritance: false) +class WalletSolanaTokenInfo implements IsarId { + @override + Id id = Isar.autoIncrement; + + @Index( + unique: true, + replace: false, + composite: [CompositeIndex("tokenAddress")], + ) + final String walletId; + + final String tokenAddress; // Mint address. + + final int tokenFractionDigits; + + final String? cachedBalanceJsonString; + + WalletSolanaTokenInfo({ + required this.walletId, + required this.tokenAddress, + required this.tokenFractionDigits, + this.cachedBalanceJsonString, + }); + + SolContract getToken(Isar isar) => + isar.solContracts.where().addressEqualTo(tokenAddress).findFirstSync()!; + + // Token balance cache. + Balance getCachedBalance() { + if (cachedBalanceJsonString == null) { + final amount = Amount( + rawValue: BigInt.zero, + fractionDigits: tokenFractionDigits, + ); + return Balance( + total: amount, + spendable: amount, + blockedTotal: amount, + pendingSpendable: amount, + ); + } + return Balance.fromJson(cachedBalanceJsonString!, tokenFractionDigits); + } + + Future updateCachedBalance( + Balance balance, { + required Isar isar, + }) async { + // Ensure we are updating using the latest entry of this in the db. + final thisEntry = + await isar.walletSolanaTokenInfo + .where() + .walletIdTokenAddressEqualTo(walletId, tokenAddress) + .findFirst(); + if (thisEntry == null) { + throw Exception( + "Attempted to update cached token balance before object was saved in db", + ); + } else { + await isar.writeTxn(() async { + await isar.walletSolanaTokenInfo.delete(thisEntry.id); + await isar.walletSolanaTokenInfo.put( + WalletSolanaTokenInfo( + walletId: walletId, + tokenAddress: tokenAddress, + tokenFractionDigits: tokenFractionDigits, + cachedBalanceJsonString: balance.toJsonIgnoreCoin(), + )..id = thisEntry.id, + ); + }); + } + } +} diff --git a/lib/wallets/isar/models/wallet_solana_token_info.g.dart b/lib/wallets/isar/models/wallet_solana_token_info.g.dart new file mode 100644 index 0000000000..bd83700810 --- /dev/null +++ b/lib/wallets/isar/models/wallet_solana_token_info.g.dart @@ -0,0 +1,1433 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'wallet_solana_token_info.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetWalletSolanaTokenInfoCollection on Isar { + IsarCollection get walletSolanaTokenInfo => + this.collection(); +} + +const WalletSolanaTokenInfoSchema = CollectionSchema( + name: r'WalletSolanaTokenInfo', + id: 7293372558936095532, + properties: { + r'cachedBalanceJsonString': PropertySchema( + id: 0, + name: r'cachedBalanceJsonString', + type: IsarType.string, + ), + r'tokenAddress': PropertySchema( + id: 1, + name: r'tokenAddress', + type: IsarType.string, + ), + r'tokenFractionDigits': PropertySchema( + id: 2, + name: r'tokenFractionDigits', + type: IsarType.long, + ), + r'walletId': PropertySchema( + id: 3, + name: r'walletId', + type: IsarType.string, + ), + }, + + estimateSize: _walletSolanaTokenInfoEstimateSize, + serialize: _walletSolanaTokenInfoSerialize, + deserialize: _walletSolanaTokenInfoDeserialize, + deserializeProp: _walletSolanaTokenInfoDeserializeProp, + idName: r'id', + indexes: { + r'walletId_tokenAddress': IndexSchema( + id: -7747794843092592407, + name: r'walletId_tokenAddress', + unique: true, + replace: false, + properties: [ + IndexPropertySchema( + name: r'walletId', + type: IndexType.hash, + caseSensitive: true, + ), + IndexPropertySchema( + name: r'tokenAddress', + type: IndexType.hash, + caseSensitive: true, + ), + ], + ), + }, + links: {}, + embeddedSchemas: {}, + + getId: _walletSolanaTokenInfoGetId, + getLinks: _walletSolanaTokenInfoGetLinks, + attach: _walletSolanaTokenInfoAttach, + version: '3.3.0-dev.2', +); + +int _walletSolanaTokenInfoEstimateSize( + WalletSolanaTokenInfo object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + { + final value = object.cachedBalanceJsonString; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + bytesCount += 3 + object.tokenAddress.length * 3; + bytesCount += 3 + object.walletId.length * 3; + return bytesCount; +} + +void _walletSolanaTokenInfoSerialize( + WalletSolanaTokenInfo object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeString(offsets[0], object.cachedBalanceJsonString); + writer.writeString(offsets[1], object.tokenAddress); + writer.writeLong(offsets[2], object.tokenFractionDigits); + writer.writeString(offsets[3], object.walletId); +} + +WalletSolanaTokenInfo _walletSolanaTokenInfoDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = WalletSolanaTokenInfo( + cachedBalanceJsonString: reader.readStringOrNull(offsets[0]), + tokenAddress: reader.readString(offsets[1]), + tokenFractionDigits: reader.readLong(offsets[2]), + walletId: reader.readString(offsets[3]), + ); + object.id = id; + return object; +} + +P _walletSolanaTokenInfoDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readStringOrNull(offset)) as P; + case 1: + return (reader.readString(offset)) as P; + case 2: + return (reader.readLong(offset)) as P; + case 3: + return (reader.readString(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _walletSolanaTokenInfoGetId(WalletSolanaTokenInfo object) { + return object.id; +} + +List> _walletSolanaTokenInfoGetLinks( + WalletSolanaTokenInfo object, +) { + return []; +} + +void _walletSolanaTokenInfoAttach( + IsarCollection col, + Id id, + WalletSolanaTokenInfo object, +) { + object.id = id; +} + +extension WalletSolanaTokenInfoByIndex + on IsarCollection { + Future getByWalletIdTokenAddress( + String walletId, + String tokenAddress, + ) { + return getByIndex(r'walletId_tokenAddress', [walletId, tokenAddress]); + } + + WalletSolanaTokenInfo? getByWalletIdTokenAddressSync( + String walletId, + String tokenAddress, + ) { + return getByIndexSync(r'walletId_tokenAddress', [walletId, tokenAddress]); + } + + Future deleteByWalletIdTokenAddress( + String walletId, + String tokenAddress, + ) { + return deleteByIndex(r'walletId_tokenAddress', [walletId, tokenAddress]); + } + + bool deleteByWalletIdTokenAddressSync(String walletId, String tokenAddress) { + return deleteByIndexSync(r'walletId_tokenAddress', [ + walletId, + tokenAddress, + ]); + } + + Future> getAllByWalletIdTokenAddress( + List walletIdValues, + List tokenAddressValues, + ) { + final len = walletIdValues.length; + assert( + tokenAddressValues.length == len, + 'All index values must have the same length', + ); + final values = >[]; + for (var i = 0; i < len; i++) { + values.add([walletIdValues[i], tokenAddressValues[i]]); + } + + return getAllByIndex(r'walletId_tokenAddress', values); + } + + List getAllByWalletIdTokenAddressSync( + List walletIdValues, + List tokenAddressValues, + ) { + final len = walletIdValues.length; + assert( + tokenAddressValues.length == len, + 'All index values must have the same length', + ); + final values = >[]; + for (var i = 0; i < len; i++) { + values.add([walletIdValues[i], tokenAddressValues[i]]); + } + + return getAllByIndexSync(r'walletId_tokenAddress', values); + } + + Future deleteAllByWalletIdTokenAddress( + List walletIdValues, + List tokenAddressValues, + ) { + final len = walletIdValues.length; + assert( + tokenAddressValues.length == len, + 'All index values must have the same length', + ); + final values = >[]; + for (var i = 0; i < len; i++) { + values.add([walletIdValues[i], tokenAddressValues[i]]); + } + + return deleteAllByIndex(r'walletId_tokenAddress', values); + } + + int deleteAllByWalletIdTokenAddressSync( + List walletIdValues, + List tokenAddressValues, + ) { + final len = walletIdValues.length; + assert( + tokenAddressValues.length == len, + 'All index values must have the same length', + ); + final values = >[]; + for (var i = 0; i < len; i++) { + values.add([walletIdValues[i], tokenAddressValues[i]]); + } + + return deleteAllByIndexSync(r'walletId_tokenAddress', values); + } + + Future putByWalletIdTokenAddress(WalletSolanaTokenInfo object) { + return putByIndex(r'walletId_tokenAddress', object); + } + + Id putByWalletIdTokenAddressSync( + WalletSolanaTokenInfo object, { + bool saveLinks = true, + }) { + return putByIndexSync( + r'walletId_tokenAddress', + object, + saveLinks: saveLinks, + ); + } + + Future> putAllByWalletIdTokenAddress( + List objects, + ) { + return putAllByIndex(r'walletId_tokenAddress', objects); + } + + List putAllByWalletIdTokenAddressSync( + List objects, { + bool saveLinks = true, + }) { + return putAllByIndexSync( + r'walletId_tokenAddress', + objects, + saveLinks: saveLinks, + ); + } +} + +extension WalletSolanaTokenInfoQueryWhereSort + on QueryBuilder { + QueryBuilder + anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension WalletSolanaTokenInfoQueryWhere + on + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QWhereClause + > { + QueryBuilder + idEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between(lower: id, upper: id)); + }); + } + + QueryBuilder + idNotEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ); + } + }); + } + + QueryBuilder + idGreaterThan(Id id, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder + idLessThan(Id id, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder + idBetween( + Id lowerId, + Id upperId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.between( + lower: lowerId, + includeLower: includeLower, + upper: upperId, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder + walletIdEqualToAnyTokenAddress(String walletId) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IndexWhereClause.equalTo( + indexName: r'walletId_tokenAddress', + value: [walletId], + ), + ); + }); + } + + QueryBuilder + walletIdNotEqualToAnyTokenAddress(String walletId) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [], + upper: [walletId], + includeUpper: false, + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId], + includeLower: false, + upper: [], + ), + ); + } else { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId], + includeLower: false, + upper: [], + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [], + upper: [walletId], + includeUpper: false, + ), + ); + } + }); + } + + QueryBuilder + walletIdTokenAddressEqualTo(String walletId, String tokenAddress) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IndexWhereClause.equalTo( + indexName: r'walletId_tokenAddress', + value: [walletId, tokenAddress], + ), + ); + }); + } + + QueryBuilder + walletIdEqualToTokenAddressNotEqualTo(String walletId, String tokenAddress) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId], + upper: [walletId, tokenAddress], + includeUpper: false, + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId, tokenAddress], + includeLower: false, + upper: [walletId], + ), + ); + } else { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId, tokenAddress], + includeLower: false, + upper: [walletId], + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'walletId_tokenAddress', + lower: [walletId], + upper: [walletId, tokenAddress], + includeUpper: false, + ), + ); + } + }); + } +} + +extension WalletSolanaTokenInfoQueryFilter + on + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QFilterCondition + > { + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNull(property: r'cachedBalanceJsonString'), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNotNull(property: r'cachedBalanceJsonString'), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringEqualTo(String? value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'cachedBalanceJsonString', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'cachedBalanceJsonString', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'cachedBalanceJsonString', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'cachedBalanceJsonString', + value: '', + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + cachedBalanceJsonStringIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + property: r'cachedBalanceJsonString', + value: '', + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + idEqualTo(Id value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'id', value: value), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + idGreaterThan(Id value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + idLessThan(Id value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + idBetween( + Id lower, + Id upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'id', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressEqualTo(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'tokenAddress', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'tokenAddress', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'tokenAddress', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'tokenAddress', value: ''), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenAddressIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'tokenAddress', value: ''), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenFractionDigitsEqualTo(int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'tokenFractionDigits', value: value), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenFractionDigitsGreaterThan(int value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'tokenFractionDigits', + value: value, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenFractionDigitsLessThan(int value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'tokenFractionDigits', + value: value, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + tokenFractionDigitsBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'tokenFractionDigits', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdEqualTo(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'walletId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'walletId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'walletId', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'walletId', value: ''), + ); + }); + } + + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QAfterFilterCondition + > + walletIdIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'walletId', value: ''), + ); + }); + } +} + +extension WalletSolanaTokenInfoQueryObject + on + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QFilterCondition + > {} + +extension WalletSolanaTokenInfoQueryLinks + on + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QFilterCondition + > {} + +extension WalletSolanaTokenInfoQuerySortBy + on QueryBuilder { + QueryBuilder + sortByCachedBalanceJsonString() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cachedBalanceJsonString', Sort.asc); + }); + } + + QueryBuilder + sortByCachedBalanceJsonStringDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cachedBalanceJsonString', Sort.desc); + }); + } + + QueryBuilder + sortByTokenAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenAddress', Sort.asc); + }); + } + + QueryBuilder + sortByTokenAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenAddress', Sort.desc); + }); + } + + QueryBuilder + sortByTokenFractionDigits() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenFractionDigits', Sort.asc); + }); + } + + QueryBuilder + sortByTokenFractionDigitsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenFractionDigits', Sort.desc); + }); + } + + QueryBuilder + sortByWalletId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.asc); + }); + } + + QueryBuilder + sortByWalletIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.desc); + }); + } +} + +extension WalletSolanaTokenInfoQuerySortThenBy + on QueryBuilder { + QueryBuilder + thenByCachedBalanceJsonString() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cachedBalanceJsonString', Sort.asc); + }); + } + + QueryBuilder + thenByCachedBalanceJsonStringDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cachedBalanceJsonString', Sort.desc); + }); + } + + QueryBuilder + thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder + thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } + + QueryBuilder + thenByTokenAddress() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenAddress', Sort.asc); + }); + } + + QueryBuilder + thenByTokenAddressDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenAddress', Sort.desc); + }); + } + + QueryBuilder + thenByTokenFractionDigits() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenFractionDigits', Sort.asc); + }); + } + + QueryBuilder + thenByTokenFractionDigitsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'tokenFractionDigits', Sort.desc); + }); + } + + QueryBuilder + thenByWalletId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.asc); + }); + } + + QueryBuilder + thenByWalletIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'walletId', Sort.desc); + }); + } +} + +extension WalletSolanaTokenInfoQueryWhereDistinct + on QueryBuilder { + QueryBuilder + distinctByCachedBalanceJsonString({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy( + r'cachedBalanceJsonString', + caseSensitive: caseSensitive, + ); + }); + } + + QueryBuilder + distinctByTokenAddress({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'tokenAddress', caseSensitive: caseSensitive); + }); + } + + QueryBuilder + distinctByTokenFractionDigits() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'tokenFractionDigits'); + }); + } + + QueryBuilder + distinctByWalletId({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'walletId', caseSensitive: caseSensitive); + }); + } +} + +extension WalletSolanaTokenInfoQueryProperty + on + QueryBuilder< + WalletSolanaTokenInfo, + WalletSolanaTokenInfo, + QQueryProperty + > { + QueryBuilder idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } + + QueryBuilder + cachedBalanceJsonStringProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'cachedBalanceJsonString'); + }); + } + + QueryBuilder + tokenAddressProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'tokenAddress'); + }); + } + + QueryBuilder + tokenFractionDigitsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'tokenFractionDigits'); + }); + } + + QueryBuilder + walletIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'walletId'); + }); + } +} diff --git a/lib/wallets/isar/providers/solana/current_sol_token_wallet_provider.dart b/lib/wallets/isar/providers/solana/current_sol_token_wallet_provider.dart new file mode 100644 index 0000000000..ab17451220 --- /dev/null +++ b/lib/wallets/isar/providers/solana/current_sol_token_wallet_provider.dart @@ -0,0 +1,15 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../wallet/impl/sub_wallets/solana_token_wallet.dart'; + +/// State provider for the currently active Solana token wallet. +/// +/// This allows global tracking of which token wallet is being viewed/interacted-with. +final solanaTokenServiceStateProvider = + StateProvider((ref) => null); + +/// Public provider to read the current active Solana token wallet. +/// +/// Use this in UI widgets to get the active token wallet. +final pCurrentSolanaTokenWallet = + Provider((ref) => ref.watch(solanaTokenServiceStateProvider)); diff --git a/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart b/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart new file mode 100644 index 0000000000..739e231591 --- /dev/null +++ b/lib/wallets/isar/providers/solana/sol_token_balance_provider.dart @@ -0,0 +1,108 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar_community/isar.dart'; + +import '../../../../models/balance.dart'; +import '../../../../models/isar/models/isar_models.dart'; +import '../../../../providers/db/main_db_provider.dart'; +import '../../../../utilities/logger.dart'; +import '../util/watcher.dart'; + +/// Provider family for Solana token wallet info. +/// +/// Watches the Isar database for changes to WalletSolanaTokenInfo. +/// Mirrors the pattern used for Ethereum token balances (TokenWalletInfo). +/// +/// Example usage: +/// final info = ref.watch( +/// pSolanaTokenWalletInfo((walletId: 'wallet1', tokenMint: 'EPjFWaJUwYUoRwzwkH4H8gNB7zHW9tLT6NCKB8S4yh6h')) +/// ); +final _wstwiProvider = ChangeNotifierProvider.family< + Watcher, + ({String walletId, String tokenMint}) +>((ref, data) { + final isar = ref.watch(mainDBProvider).isar; + + final collection = isar.walletSolanaTokenInfo; + + Logging.instance.i( + "pSolanaTokenBalance: Looking up WalletSolanaTokenInfo for walletId=${data.walletId}, tokenMint=${data.tokenMint}", + ); + + WalletSolanaTokenInfo? initial = collection + .where() + .walletIdTokenAddressEqualTo(data.walletId, data.tokenMint) + .findFirstSync(); + + if (initial == null) { + Logging.instance.i( + "pSolanaTokenBalance: Creating new WalletSolanaTokenInfo entry", + ); + + // Create initial entry if not found. + final solContract = + isar.solContracts.getByAddressSync(data.tokenMint); + + initial = WalletSolanaTokenInfo( + walletId: data.walletId, + tokenAddress: data.tokenMint, + tokenFractionDigits: solContract?.decimals ?? 6, + ); + + isar.writeTxnSync(() => isar.walletSolanaTokenInfo.putSync(initial!)); + + // After insert, fetch the object again to get the assigned ID. + initial = collection + .where() + .walletIdTokenAddressEqualTo(data.walletId, data.tokenMint) + .findFirstSync()!; + + Logging.instance.i( + "pSolanaTokenBalance: Created entry with ID=${initial.id}, balance=${initial.getCachedBalance().total}", + ); + } else { + Logging.instance.i( + "pSolanaTokenBalance: Found existing entry with ID=${initial.id}, cachedBalance=${initial.getCachedBalance().total}", + ); + } + + final watcher = Watcher(initial, collection: collection); + + ref.onDispose(() => watcher.dispose()); + + return watcher; +}); + +/// Provider for Solana token wallet info from the database. +final pSolanaTokenWalletInfo = Provider.family< + WalletSolanaTokenInfo, + ({String walletId, String tokenMint}) +>((ref, data) { + return ref.watch(_wstwiProvider(data).select((value) => value.value)) + as WalletSolanaTokenInfo; +}); + +/// Provider for Solana token balance from the database. +/// +/// This provider watches the Isar database and will automatically update +/// the UI whenever the balance changes in the database. +/// +/// Example usage: +/// final balance = ref.watch( +/// pSolanaTokenBalance((walletId: 'wallet1', tokenMint: 'EPjFWaJUwYUoRwzwkH4H8gNB7zHW9tLT6NCKB8S4yh6h')) +/// ); +final pSolanaTokenBalance = Provider.family< + Balance, + ({String walletId, String tokenMint}) +>((ref, data) { + final balance = ref.watch( + _wstwiProvider(data).select( + (value) => (value.value as WalletSolanaTokenInfo).getCachedBalance(), + ), + ); + + Logging.instance.i( + "pSolanaTokenBalance: Returning balance=${balance.total} for walletId=${data.walletId}, tokenMint=${data.tokenMint}", + ); + + return balance; +}); diff --git a/lib/wallets/isar/providers/solana/sol_tokens_provider.dart b/lib/wallets/isar/providers/solana/sol_tokens_provider.dart new file mode 100644 index 0000000000..fe6a711bbe --- /dev/null +++ b/lib/wallets/isar/providers/solana/sol_tokens_provider.dart @@ -0,0 +1,30 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Provides a list of Solana token mint addresses for a specific wallet. +/// +/// This provider returns the list of Solana token mint addresses +/// that the wallet has selected. Token details are not currently persisted +/// in the database - only the mint addresses are stored in WalletInfo's otherData. +/// +/// Example usage: +/// ``` +/// final tokenAddresses = ref.watch(pSolanaWalletTokenAddresses('wallet_id')); +/// ``` +/// Note: For full token details (name, symbol, decimals), these would need to be +/// fetched from the Solana token metadata or a token list API. +final pSolanaWalletTokens = Provider.family, String>( + (ref, walletId) { + // TODO: Implement token details fetching from Solana metadata or API. + // For now, just return an empty list as token details are not persisted. + return []; + }, +); diff --git a/lib/wallets/isar/providers/solana/solana_wallet_provider.dart b/lib/wallets/isar/providers/solana/solana_wallet_provider.dart new file mode 100644 index 0000000000..7a0f5db4c1 --- /dev/null +++ b/lib/wallets/isar/providers/solana/solana_wallet_provider.dart @@ -0,0 +1,26 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../wallet/impl/solana_wallet.dart'; +import '../../../../providers/global/wallets_provider.dart'; + +/// Provider that returns a Solana wallet by ID, or null if the wallet is not a SolanaWallet. +/// +/// This provides type-safe access to Solana wallets without needing runtime type checks +/// in every view. If you need to get a Solana wallet, use this provider instead of +/// manually checking the type of the wallet returned by pWallets. +/// +/// Example: +/// ```dart +/// final solanaWallet = ref.read(pSolanaWallet(walletId)); +/// if (solanaWallet == null) { +/// // Handle error: wallet is not a Solana wallet +/// return; +/// } +/// // Use solanaWallet safely, knowing it's definitely a SolanaWallet +/// ``` +final pSolanaWallet = Provider.family((ref, walletId) { + final wallets = ref.watch(pWallets); + final wallet = wallets.getWallet(walletId); + + return wallet is SolanaWallet ? wallet : null; +}); diff --git a/lib/wallets/isar/providers/wallet_info_provider.dart b/lib/wallets/isar/providers/wallet_info_provider.dart index d6469879e2..354658dbff 100644 --- a/lib/wallets/isar/providers/wallet_info_provider.dart +++ b/lib/wallets/isar/providers/wallet_info_provider.dart @@ -96,13 +96,26 @@ final pWalletReceivingAddress = Provider.family(( ); }); +/// Provider for wallet token addresses (Ethereum) or token mint addresses (Solana). +/// +/// Returns the appropriate token list based on the wallet's coin type. +/// +/// For Ethereum wallets: returns tokenContractAddresses. +/// For Solana wallets: returns solanaTokenMintAddresses + solanaCustomTokenMintAddresses combined. final pWalletTokenAddresses = Provider.family, String>(( ref, walletId, ) { - return ref.watch( - _wiProvider( - walletId, - ).select((value) => (value.value as WalletInfo).tokenContractAddresses), - ); + final walletInfo = ref.watch(pWalletInfo(walletId)); + + if (walletInfo.coin.prettyName == 'Solana') { + // Combine both default and custom token mint addresses. + final allTokens = { + ...walletInfo.solanaTokenMintAddresses, + ...walletInfo.solanaCustomTokenMintAddresses, + }; + return allTokens.toList(); + } else { + return walletInfo.tokenContractAddresses; + } }); diff --git a/lib/wallets/models/tx_data.dart b/lib/wallets/models/tx_data.dart index 5db6d94835..6db43109a0 100644 --- a/lib/wallets/models/tx_data.dart +++ b/lib/wallets/models/tx_data.dart @@ -66,6 +66,13 @@ class TxData { final web3dart.Transaction? web3dartTransaction; final int? nonce; final BigInt? chainId; + + // Solana & Ethereum token-specific. + final String? tokenSymbol; + final String? tokenMint; + final int? tokenDecimals; + final String? solanaRecipientTokenAccount; + // wownero and monero specific final CsPendingTransaction? pendingTransaction; @@ -125,6 +132,10 @@ class TxData { this.web3dartTransaction, this.nonce, this.chainId, + this.tokenSymbol, + this.tokenMint, + this.tokenDecimals, + this.solanaRecipientTokenAccount, this.pendingTransaction, this.pendingSalviumTransaction, this.tezosOperationsList, @@ -261,6 +272,10 @@ class TxData { web3dart.Transaction? web3dartTransaction, int? nonce, BigInt? chainId, + String? tokenSymbol, + String? tokenMint, + int? tokenDecimals, + String? solanaRecipientTokenAccount, CsPendingTransaction? pendingTransaction, CsPendingTransaction? pendingSalviumTransaction, int? jMintValue, @@ -310,6 +325,11 @@ class TxData { web3dartTransaction: web3dartTransaction ?? this.web3dartTransaction, nonce: nonce ?? this.nonce, chainId: chainId ?? this.chainId, + tokenSymbol: tokenSymbol ?? this.tokenSymbol, + tokenMint: tokenMint ?? this.tokenMint, + tokenDecimals: tokenDecimals ?? this.tokenDecimals, + solanaRecipientTokenAccount: + solanaRecipientTokenAccount ?? this.solanaRecipientTokenAccount, pendingTransaction: pendingTransaction ?? this.pendingTransaction, pendingSalviumTransaction: pendingSalviumTransaction ?? this.pendingSalviumTransaction, diff --git a/lib/wallets/wallet/impl/solana_wallet.dart b/lib/wallets/wallet/impl/solana_wallet.dart index c88d995624..e9b9fccee3 100644 --- a/lib/wallets/wallet/impl/solana_wallet.dart +++ b/lib/wallets/wallet/impl/solana_wallet.dart @@ -7,15 +7,19 @@ import 'package:isar_community/isar.dart'; import 'package:socks5_proxy/socks_client.dart'; import 'package:solana/dto.dart'; import 'package:solana/solana.dart'; -import 'package:tuple/tuple.dart'; import '../../../app_config.dart'; import '../../../exceptions/wallet/node_tor_mismatch_config_exception.dart'; import '../../../models/balance.dart'; import '../../../models/isar/models/blockchain_data/transaction.dart' as isar; +import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; +import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; +import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../models/isar/models/isar_models.dart'; import '../../../models/node_model.dart'; import '../../../models/paymint/fee_object_model.dart'; +import '../../../services/event_bus/events/global/updated_in_background_event.dart'; +import '../../../services/event_bus/global_event_bus.dart'; import '../../../services/node_service.dart'; import '../../../services/tor_service.dart'; import '../../../utilities/amount/amount.dart'; @@ -33,7 +37,15 @@ class SolanaWallet extends Bip39Wallet { NodeModel? _solNode; - RpcClient? _rpcClient; // The Solana RpcClient. + RpcClient? _rpcClient; + + RpcClient? getRpcClient() { + return _rpcClient; + } + + Future getKeyPair() async { + return _getKeyPair(); + } Future _getKeyPair() async { return Ed25519HDKeyPair.fromMnemonic( @@ -57,13 +69,13 @@ class SolanaWallet extends Bip39Wallet { } Future _getCurrentBalanceInLamports() async { - _checkClient(); + checkClient(); final balance = await _rpcClient?.getBalance((await _getKeyPair()).address); return BigInt.from(balance!.value); } Future _getEstimatedNetworkFee(Amount transferAmount) async { - _checkClient(); + checkClient(); final latestBlockhash = await _rpcClient?.getLatestBlockhash(); final pubKey = (await _getKeyPair()).publicKey; @@ -116,7 +128,7 @@ class SolanaWallet extends Bip39Wallet { @override Future prepareSend({required TxData txData}) async { try { - _checkClient(); + checkClient(); if (txData.recipients == null || txData.recipients!.length != 1) { throw Exception("$runtimeType prepareSend requires 1 recipient"); @@ -177,7 +189,7 @@ class SolanaWallet extends Bip39Wallet { @override Future confirmSend({required TxData txData}) async { try { - _checkClient(); + checkClient(); final keyPair = await _getKeyPair(); final recipientAccount = txData.recipients!.first; @@ -203,6 +215,52 @@ class SolanaWallet extends Bip39Wallet { ); final txid = await _rpcClient?.signAndSendTransaction(message, [keyPair]); + + // Persist pending transaction immediately so UI shows "Sending" status. + if (txid != null) { + final senderAddress = keyPair.address; + final isToSelf = senderAddress == recipientAccount.address; + + final tempTx = TransactionV2( + walletId: walletId, + blockHash: null, // CRITICAL: indicates pending. + hash: txid, + txid: txid, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + height: null, // CRITICAL: indicates pending. + inputs: [ + InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: null, + scriptSigAsm: null, + sequence: null, + outpoint: null, + addresses: [senderAddress], + valueStringSats: txData.amount!.raw.toString(), + witness: null, + innerRedeemScriptAsm: null, + coinbase: null, + walletOwns: true, + ), + ], + outputs: [ + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: "00", + valueStringSats: txData.amount!.raw.toString(), + addresses: [recipientAccount.address], + walletOwns: isToSelf, + ), + ], + version: -1, + type: isToSelf + ? isar.TransactionType.sentToSelf + : isar.TransactionType.outgoing, + subType: isar.TransactionSubType.none, + otherData: jsonEncode({"overrideFee": txData.fee!.toJsonString()}), + ); + + await mainDB.updateOrPutTransactionV2s([tempTx]); + } + return txData.copyWith(txid: txid); } catch (e, s) { Logging.instance.e( @@ -216,7 +274,7 @@ class SolanaWallet extends Bip39Wallet { @override Future estimateFeeFor(Amount amount, BigInt feeRate) async { - _checkClient(); + checkClient(); if (info.cachedBalance.spendable.raw == BigInt.zero) { return Amount( @@ -225,35 +283,61 @@ class SolanaWallet extends Bip39Wallet { ); } - final fee = await _getEstimatedNetworkFee(amount); - if (fee == null) { - throw Exception("Failed to get fees, please check your node connection."); - } - - return Amount(rawValue: fee, fractionDigits: cryptoCurrency.fractionDigits); + // The feeRate parameter contains the total fee amount to use. + // For Solana, this is already calculated based on priority tier. + // Simply return it as the fee estimate. + return Amount( + rawValue: feeRate, + fractionDigits: cryptoCurrency.fractionDigits, + ); } @override Future get fees async { - _checkClient(); + checkClient(); - final fee = await _getEstimatedNetworkFee( + final baseFee = await _getEstimatedNetworkFee( Amount.fromDecimal( - Decimal.one, // 1 SOL + Decimal.one, // 1 SOL. fractionDigits: cryptoCurrency.fractionDigits, ), ); - if (fee == null) { + if (baseFee == null) { throw Exception("Failed to get fees, please check your node connection."); } + // Differentiate fees by tier using multipliers: + // Base fee is typically around 5000 lamports. + // Slow: minimum 5000 lamports. + // Average: base fee * 1.5 (but not less than slow). + // Fast: base fee * 2.0 (but not less than average). + // Ensure all fees stay within bounds: 5000-1000000 lamports. + const minFeeBig = 5000; + const maxFeeBig = 1000000; + + // Calculate tier fees with multipliers. + final slowFee = baseFee; // Use base fee for slow. + final averageFee = (baseFee * BigInt.from(3)) ~/ BigInt.from(2); // 1.5x. + final fastFee = baseFee * BigInt.from(2); // 2.0x. + + // Clamp all fees to the allowed range. + final _clamp = (BigInt value) { + if (value < BigInt.from(minFeeBig)) return BigInt.from(minFeeBig); + if (value > BigInt.from(maxFeeBig)) return BigInt.from(maxFeeBig); + return value; + }; + + final clampedSlow = _clamp(slowFee); + final clampedAverage = _clamp(averageFee); + final clampedFast = _clamp(fastFee); + return FeeObject( numberOfBlocksFast: 1, numberOfBlocksAverage: 1, numberOfBlocksSlow: 1, - fast: fee, - medium: fee, - slow: fee, + fast: clampedFast, + medium: clampedAverage, + slow: clampedSlow, ); } @@ -261,7 +345,7 @@ class SolanaWallet extends Bip39Wallet { Future pingCheck() async { String? health; try { - _checkClient(); + checkClient(); health = await _rpcClient?.getHealth(); return health != null; } catch (e, s) { @@ -300,7 +384,7 @@ class SolanaWallet extends Bip39Wallet { @override Future updateBalance() async { - _checkClient(); + checkClient(); try { final address = await getCurrentReceivingAddress(); @@ -350,7 +434,7 @@ class SolanaWallet extends Bip39Wallet { @override Future updateChainHeight() async { try { - _checkClient(); + checkClient(); final int blockHeight = await _rpcClient?.getSlot() ?? 0; // TODO [prio=low]: Revisit null condition. @@ -391,100 +475,191 @@ class SolanaWallet extends Bip39Wallet { @override Future updateTransactions() async { try { - _checkClient(); + checkClient(); final transactionsList = await _rpcClient?.getTransactionsList( (await _getKeyPair()).publicKey, encoding: Encoding.jsonParsed, ); - final txsList = List>.empty( - growable: true, - ); final myAddress = (await getCurrentReceivingAddress())!; - // TODO [prio=low]: Revisit null assertion below. + if (transactionsList == null) { + return; + } - for (final tx in transactionsList!) { - final senderAddress = - (tx.transaction as ParsedTransaction).message.accountKeys[0].pubkey; - var receiverAddress = - (tx.transaction as ParsedTransaction).message.accountKeys[1].pubkey; - var txType = isar.TransactionType.unknown; - final txAmount = Amount( - rawValue: BigInt.from( + final txns = []; + int skippedCount = 0; + + for (final tx in transactionsList) { + try { + // Skip transactions without metadata. + if (tx.meta == null) { + skippedCount++; + continue; + } + + if (tx.transaction is! ParsedTransaction) { + skippedCount++; + continue; + } + + final parsedTx = tx.transaction as ParsedTransaction; + final txid = parsedTx.signatures.isNotEmpty + ? parsedTx.signatures[0] + : null; + if (txid == null) { + skippedCount++; + continue; + } + + // Determine transaction direction. + final senderAddress = parsedTx.message.accountKeys[0].pubkey; + var receiverAddress = parsedTx.message.accountKeys.length > 1 + ? parsedTx.message.accountKeys[1].pubkey + : senderAddress; + var txType = isar.TransactionType.unknown; + + if ((senderAddress == myAddress.value) && + (receiverAddress == "11111111111111111111111111111111")) { + // System Program account means sent to self. + txType = isar.TransactionType.sentToSelf; + receiverAddress = senderAddress; + } else if (senderAddress == myAddress.value) { + txType = isar.TransactionType.outgoing; + } else if (receiverAddress == myAddress.value) { + txType = isar.TransactionType.incoming; + } + + // Calculate transfer amount. + final amount = BigInt.from( tx.meta!.postBalances[1] - tx.meta!.preBalances[1], - ), - fractionDigits: cryptoCurrency.fractionDigits, - ); + ); + + // Check if this transaction already exists. + // If it does, preserve the overrideFee from the pending transaction. + dynamic existingOverrideFee; + try { + final allTxsForWallet = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .findAll(); + for (final existingTx in allTxsForWallet) { + if (existingTx.txid == txid) { + final existingOtherData = existingTx.otherData; + if (existingOtherData != null && existingOtherData.isNotEmpty) { + try { + final otherDataMap = jsonDecode(existingOtherData); + if (otherDataMap is Map && + otherDataMap.containsKey('overrideFee')) { + existingOverrideFee = otherDataMap['overrideFee']; + } + } catch (e) { + // Ignore parsing errors. + } + } + break; + } + } + } catch (e) { + // Ignore database query errors. + } + + // Build otherData, preserving overrideFee if it existed. + final otherDataMap = {}; + if (existingOverrideFee != null) { + otherDataMap["overrideFee"] = existingOverrideFee; + } + + // Create TransactionV2 object. + final txn = TransactionV2( + walletId: walletId, + blockHash: null, + hash: txid, + txid: txid, + timestamp: + tx.blockTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000, + height: tx.slot, + inputs: [ + InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: null, + scriptSigAsm: null, + sequence: null, + outpoint: null, + addresses: [senderAddress], + valueStringSats: amount.toString(), + witness: null, + innerRedeemScriptAsm: null, + coinbase: null, + walletOwns: senderAddress == myAddress.value, + ), + ], + outputs: [ + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: "00", + valueStringSats: amount.toString(), + addresses: [receiverAddress], + walletOwns: receiverAddress == myAddress.value, + ), + ], + version: -1, + type: txType, + subType: isar.TransactionSubType.none, + otherData: otherDataMap.isNotEmpty + ? jsonEncode(otherDataMap) + : null, + ); - if ((senderAddress == myAddress.value) && - (receiverAddress == "11111111111111111111111111111111")) { - // The account that is only 1's are System Program accounts which - // means there is no receiver except the sender, - // see: https://explorer.solana.com/address/11111111111111111111111111111111 - txType = isar.TransactionType.sentToSelf; - receiverAddress = senderAddress; - } else if (senderAddress == myAddress.value) { - txType = isar.TransactionType.outgoing; - } else if (receiverAddress == myAddress.value) { - txType = isar.TransactionType.incoming; + txns.add(txn); + } catch (e, s) { + Logging.instance.w( + "$runtimeType updateTransactions: Failed to parse transaction", + error: e, + stackTrace: s, + ); + skippedCount++; + continue; } + } - final transaction = isar.Transaction( - walletId: walletId, - txid: (tx.transaction as ParsedTransaction).signatures[0], - timestamp: tx.blockTime!, - type: txType, - subType: isar.TransactionSubType.none, - amount: tx.meta!.postBalances[1] - tx.meta!.preBalances[1], - amountString: txAmount.toJsonString(), - fee: tx.meta!.fee, - height: tx.slot, - isCancelled: false, - isLelantus: false, - slateId: null, - otherData: null, - inputs: [], - outputs: [], - nonce: null, - numberOfMessages: 0, + // Persist all transactions if any were parsed. + if (txns.isNotEmpty) { + await mainDB.updateOrPutTransactionV2s(txns); + Logging.instance.i( + "$runtimeType updateTransactions: Synced ${txns.length} transactions (skipped $skippedCount)", ); - - final txAddress = Address( - walletId: walletId, - value: receiverAddress, - publicKey: List.empty(), - derivationIndex: 0, - derivationPath: DerivationPath()..value = _addressDerivationPath, - type: AddressType.solana, - subType: txType == isar.TransactionType.outgoing - ? AddressSubType.unknown - : AddressSubType.receiving, - ); - - txsList.add(Tuple2(transaction, txAddress)); } - await mainDB.addNewTransactionData(txsList, walletId); } on NodeTorMismatchConfigException { rethrow; } catch (e, s) { Logging.instance.e( - "Error occurred in solana_wallet.dart while getting" - " transactions for solana: $e\n$s", + "$runtimeType updateTransactions failed: ", + error: e, + stackTrace: s, ); } } @override Future updateUTXOs() async { - // No UTXOs in Solana return false; } - /// Make sure the Solana RpcClient uses Tor if it's enabled. - /// - void _checkClient() { + Future updateSolanaTokens(List mintAddresses) async { + await info.updateSolanaCustomTokenMintAddresses( + newMintAddresses: mintAddresses, + isar: mainDB.isar, + ); + + GlobalEventBus.instance.fire( + UpdatedInBackgroundEvent( + "Solana custom tokens updated for: $walletId ${info.name}", + walletId, + ), + ); + } + + void checkClient() { final node = getCurrentNode(); final netOption = TorPlainNetworkOption.fromNodeData( diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart new file mode 100644 index 0000000000..d03b0fd520 --- /dev/null +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -0,0 +1,1018 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'dart:convert'; + +import 'package:isar_community/isar.dart'; +import 'package:solana/dto.dart'; +import 'package:solana/solana.dart' hide Wallet; + +import '../../../../db/isar/main_db.dart'; +import '../../../../models/balance.dart'; +import '../../../../models/isar/models/blockchain_data/v2/input_v2.dart'; +import '../../../../models/isar/models/blockchain_data/v2/output_v2.dart'; +import '../../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; +import '../../../../models/isar/models/isar_models.dart'; +import '../../../../models/paymint/fee_object_model.dart'; +import '../../../../services/solana/solana_token_api.dart'; +import '../../../../utilities/amount/amount.dart'; +import '../../../../utilities/logger.dart'; +import '../../../models/tx_data.dart'; +import '../../wallet.dart'; +import '../solana_wallet.dart'; + +class SolanaTokenWallet extends Wallet { + @override + int get isarTransactionVersion => 2; + + SolanaTokenWallet(this.parentSolanaWallet, this.solContract) + : super(parentSolanaWallet.cryptoCurrency); + + final SolanaWallet parentSolanaWallet; + + final SolContract solContract; + + String get tokenMint => solContract.address; + String get tokenName => solContract.name; + String get tokenSymbol => solContract.symbol; + int get tokenDecimals => solContract.decimals; + + @override + String get walletId => parentSolanaWallet.walletId; + + @override + MainDB get mainDB => parentSolanaWallet.mainDB; + + @override + FilterOperation? get changeAddressFilterOperation => null; + + @override + FilterOperation? get receivingAddressFilterOperation => null; + + @override + FilterOperation? get transactionFilterOperation => + FilterCondition.equalTo(property: r"contractAddress", value: tokenMint); + + @override + Future init() async { + await super.init(); + + parentSolanaWallet.checkClient(); + + await Future.delayed(const Duration(milliseconds: 100)); + } + + @override + Future prepareSend({required TxData txData}) async { + try { + if (txData.recipients == null || txData.recipients!.isEmpty) { + throw ArgumentError("At least one recipient is required"); + } + + if (txData.recipients!.length != 1) { + throw ArgumentError( + "SOL token transfers support only 1 recipient per transaction", + ); + } + + if (txData.amount == null || txData.amount!.raw <= BigInt.zero) { + throw ArgumentError("Send amount must be greater than zero"); + } + + final recipientAddress = txData.recipients!.first.address; + if (recipientAddress.isEmpty) { + throw ArgumentError("Recipient address cannot be empty"); + } + + try { + Ed25519HDPublicKey.fromBase58(recipientAddress); + } catch (e) { + throw ArgumentError("Invalid recipient address: $recipientAddress"); + } + + final rpcClient = parentSolanaWallet.getRpcClient(); + if (rpcClient == null) { + throw Exception("RPC client not initialized"); + } + + final keyPair = await parentSolanaWallet.getKeyPair(); + final walletAddress = keyPair.address; + + final senderTokenAccount = await _findTokenAccount( + ownerAddress: walletAddress, + mint: tokenMint, + rpcClient: rpcClient, + ); + + if (senderTokenAccount == null) { + throw Exception( + "No token account found for mint $tokenMint. " + "Please ensure you have received tokens first.", + ); + } + + try { + final accountInfo = await rpcClient.getAccountInfo( + senderTokenAccount, + encoding: Encoding.jsonParsed, + ); + if (accountInfo.value == null) { + throw Exception( + "Sender token account $senderTokenAccount not found on-chain", + ); + } + } catch (e) { + throw Exception("Failed to validate sender token account: $e"); + } + + await rpcClient.getLatestBlockhash(); + + final recipientTokenAccount = await _findOrDeriveRecipientTokenAccount( + recipientAddress: recipientAddress, + mint: tokenMint, + rpcClient: rpcClient, + ); + + if (recipientTokenAccount == null || recipientTokenAccount.isEmpty) { + throw Exception( + "Cannot determine recipient token account for mint $tokenMint. " + "Recipient may not have a token account for this mint. " + "Please ensure the recipient has initialized an Associated Token Account (ATA) first.", + ); + } + + try { + final recipientAccountInfo = await rpcClient.getAccountInfo( + recipientTokenAccount, + encoding: Encoding.jsonParsed, + ); + if (recipientAccountInfo.value == null) { + throw Exception( + "Recipient token account $recipientTokenAccount does not exist on-chain. " + "The recipient must initialize their token account before receiving tokens. " + "You can ask the recipient to accept the token in their wallet app first.", + ); + } + + final accountData = recipientAccountInfo.value!; + + // Verify account is owned by token program (not System Program). + if (accountData.owner == '11111111111111111111111111111111') { + throw Exception( + "Recipient token account $recipientTokenAccount is owned by the System Program, " + "not a token program. The account may not be a valid token account.", + ); + } + } catch (e) { + if (e.toString().contains("does not exist") || + e.toString().contains("not owned by")) { + rethrow; + } + throw Exception( + "Failed to validate recipient token account: $e. " + "Ensure the recipient has initialized their token account.", + ); + } + + final senderTokenAccountKey = Ed25519HDPublicKey.fromBase58( + senderTokenAccount, + ); + final recipientTokenAccountKey = Ed25519HDPublicKey.fromBase58( + recipientTokenAccount, + ); + final mintPubkey = Ed25519HDPublicKey.fromBase58(tokenMint); + + String tokenProgramId; + try { + final mintInfo = await rpcClient.getAccountInfo( + tokenMint, + encoding: Encoding.jsonParsed, + ); + if (mintInfo.value != null) { + tokenProgramId = mintInfo.value!.owner; + Logging.instance.i( + "$runtimeType prepareSend: Token program owner = $tokenProgramId for mint $tokenMint", + ); + } else { + // Fallback to SPL Token. + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + Logging.instance.w( + "$runtimeType prepareSend: Could not query mint owner, using SPL Token", + ); + } + } catch (e) { + // Fallback to SPL Token on error. + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + Logging.instance.w( + "$runtimeType prepareSend: Error querying mint owner: $e, using SPL Token", + ); + } + + final TokenProgramType tokenProgram = + tokenProgramId != 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA' + && tokenProgramId.startsWith('Token') + ? TokenProgramType.token2022Program + : TokenProgramType.tokenProgram; + + // ignore: unused_local_variable + final instruction = TokenInstruction.transferChecked( + source: senderTokenAccountKey, + destination: recipientTokenAccountKey, + mint: mintPubkey, + owner: keyPair.publicKey, + decimals: tokenDecimals, + amount: txData.amount!.raw.toInt(), + tokenProgram: tokenProgram, + ); + + final feeEstimate = + await _getEstimatedTokenTransferFee( + senderTokenAccountKey: senderTokenAccountKey, + recipientTokenAccountKey: recipientTokenAccountKey, + ownerPublicKey: keyPair.publicKey, + amount: txData.amount!.raw.toInt(), + rpcClient: rpcClient, + ) ?? + 5000; + + return txData.copyWith( + fee: Amount( + rawValue: BigInt.from(feeEstimate), + fractionDigits: 9, + ), + solanaRecipientTokenAccount: recipientTokenAccount, + ); + } catch (e, s) { + Logging.instance.e( + "$runtimeType prepareSend failed: ", + error: e, + stackTrace: s, + ); + rethrow; + } + } + + @override + Future confirmSend({required TxData txData}) async { + try { + // Validate that prepareSend was called. + if (txData.fee == null) { + throw Exception("Transaction not prepared. Call prepareSend() first."); + } + + if (txData.recipients == null || txData.recipients!.isEmpty) { + throw ArgumentError("Transaction must have at least one recipient"); + } + + // Get wallet state. + final rpcClient = parentSolanaWallet.getRpcClient(); + if (rpcClient == null) { + throw Exception("RPC client not initialized"); + } + + final keyPair = await parentSolanaWallet.getKeyPair(); + final walletAddress = keyPair.address; + + // Get sender's token account. + final senderTokenAccount = await _findTokenAccount( + ownerAddress: walletAddress, + mint: tokenMint, + rpcClient: rpcClient, + ); + + if (senderTokenAccount == null) { + throw Exception("Token account not found"); + } + + await rpcClient.getLatestBlockhash(); + + // Reuse the recipient token account from prepareSend (already looked up once). + final recipientTokenAccount = txData.solanaRecipientTokenAccount; + + if (recipientTokenAccount == null || recipientTokenAccount.isEmpty) { + throw Exception( + "Recipient token account not found in prepared transaction. " + "Call prepareSend() first to determine the recipient's token account.", + ); + } + + // Build SPL token tx instruction. + final senderTokenAccountKey = Ed25519HDPublicKey.fromBase58( + senderTokenAccount, + ); + final recipientTokenAccountKey = Ed25519HDPublicKey.fromBase58( + recipientTokenAccount, + ); + final mintPubkey = Ed25519HDPublicKey.fromBase58(tokenMint); + + // Query the actual token program owner (important for Token-2022 variants). + String tokenProgramId; + try { + final mintInfo = await rpcClient.getAccountInfo( + tokenMint, + encoding: Encoding.jsonParsed, + ); + if (mintInfo.value != null) { + tokenProgramId = mintInfo.value!.owner; + Logging.instance.i( + "$runtimeType confirmSend: Token program owner = $tokenProgramId for mint $tokenMint", + ); + } else { + // Fallback to SPL Token. + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + Logging.instance.w( + "$runtimeType confirmSend: Could not query mint owner, using SPL Token", + ); + } + } catch (e) { + // Fallback to SPL Token on error. + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + Logging.instance.w( + "$runtimeType confirmSend: Error querying mint owner: $e, using SPL Token", + ); + } + + // Build the TransferChecked instruction. + final TokenProgramType tokenProgram = + tokenProgramId != 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA' + && tokenProgramId.startsWith('Token') // Token-2022 variant. + ? TokenProgramType.token2022Program + : TokenProgramType.tokenProgram; + + final instruction = TokenInstruction.transferChecked( + source: senderTokenAccountKey, + destination: recipientTokenAccountKey, + mint: mintPubkey, + owner: keyPair.publicKey, + decimals: tokenDecimals, + amount: txData.amount!.raw.toInt(), + tokenProgram: tokenProgram, + ); + + // Create message. + final message = Message(instructions: [instruction]); + + // Sign and broadcast tx. + final txid = await rpcClient.signAndSendTransaction(message, [keyPair]); + + if (txid.isEmpty) { + throw Exception( + "Failed to broadcast transaction: empty signature returned", + ); + } + + // Create temporary transaction (pending = unconfirmed) and save to db. + try { + // Build inputs and outputs for the transaction record. + final inputs = [ + InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: null, + scriptSigAsm: null, + sequence: null, + outpoint: null, + addresses: [senderTokenAccount], + valueStringSats: txData.amount!.raw.toString(), + witness: null, + innerRedeemScriptAsm: null, + coinbase: null, + walletOwns: true, + ), + ]; + + final outputs = [ + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: "00", + valueStringSats: txData.amount!.raw.toString(), + addresses: [recipientTokenAccount], + walletOwns: false, // We don't own recipient account. + ), + ]; + + // Determine if this is a self-transfer. + final isToSelf = senderTokenAccount == recipientTokenAccount; + + // Create the temporary transaction record. + final tempTx = TransactionV2( + walletId: walletId, + blockHash: null, // CRITICAL: null indicates pending. + hash: txid, + txid: txid, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + height: null, // CRITICAL: null indicates pending. + inputs: List.unmodifiable(inputs), + outputs: List.unmodifiable(outputs), + version: -1, + type: isToSelf + ? TransactionType.sentToSelf + : TransactionType.outgoing, + subType: TransactionSubType.splToken, + otherData: jsonEncode({ + "mint": tokenMint, + "senderTokenAccount": senderTokenAccount, + "recipientTokenAccount": recipientTokenAccount, + "isCancelled": false, + "overrideFee": txData.fee!.toJsonString(), + }), + ); + + // Persist immediately to database so UI shows transaction right away. + await mainDB.updateOrPutTransactionV2s([tempTx]); + Logging.instance.i( + "$runtimeType confirmSend: Persisted pending transaction $txid to database", + ); + } catch (e, s) { + // Log persistence error but don't fail the send operation. + Logging.instance.w( + "$runtimeType confirmSend: Failed to persist pending transaction to database: ", + error: e, + stackTrace: s, + ); + } + + // Wait for confirmation. + final confirmed = await _waitForConfirmation( + signature: txid, + maxWaitSeconds: 60, + rpcClient: rpcClient, + ); + + if (!confirmed) { + Logging.instance.w( + "$runtimeType confirmSend: Transaction not confirmed after 60 seconds, " + "but signature was successfully broadcast: $txid", + ); + } + + // Return signed TxData. + return txData.copyWith(txid: txid); + } catch (e, s) { + Logging.instance.e( + "$runtimeType confirmSend failed: ", + error: e, + stackTrace: s, + ); + rethrow; + } + } + + @override + Future recover({required bool isRescan}) async { + // TODO. + } + + @override + Future updateNode() async { + // No-op for token wallet. + } + + @override + Future updateTransactions() async { + try { + final rpcClient = parentSolanaWallet.getRpcClient(); + if (rpcClient == null) { + Logging.instance.w( + "$runtimeType updateTransactions: RPC client not initialized", + ); + return; + } + + final keyPair = await parentSolanaWallet.getKeyPair(); + final walletAddress = keyPair.address; + + // Find token account for this mint. + final senderTokenAccount = await _findTokenAccount( + ownerAddress: walletAddress, + mint: tokenMint, + rpcClient: rpcClient, + ); + + if (senderTokenAccount == null) { + return; + } + + // Fetch recent transactions for this token account. + final txListIterable = await rpcClient.getTransactionsList( + Ed25519HDPublicKey.fromBase58(senderTokenAccount), + encoding: Encoding.jsonParsed, + ); + + final txList = txListIterable.toList(); + + if (txList.isEmpty) { + return; + } + + final txns = []; + int skippedCount = 0; + + for (int i = 0; i < txList.length; i++) { + final txDetails = txList[i]; + try { + // Skip failed transactions or those without metadata. + if (txDetails.meta == null) { + skippedCount++; + continue; + } + + // Cast transaction to ParsedTransaction if available. + if (txDetails.transaction is! ParsedTransaction) { + skippedCount++; + continue; + } + final parsedTx = txDetails.transaction as ParsedTransaction; + + // Get the txid for this transaction + final txid = parsedTx.signatures.isNotEmpty + ? parsedTx.signatures[0] + : "unknown_txid_$i"; + + // Check if this transaction already exists in the database. + // If it does, preserve the overrideFee from the pending transaction. + dynamic existingOverrideFee; + try { + final allTxsForWallet = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .findAll(); + for (final tx in allTxsForWallet) { + if (tx.txid == txid) { + final existingOtherData = tx.otherData; + if (existingOtherData != null && existingOtherData.isNotEmpty) { + try { + final otherDataMap = jsonDecode(existingOtherData); + if (otherDataMap is Map && + otherDataMap.containsKey('overrideFee')) { + existingOverrideFee = otherDataMap['overrideFee']; + } + } catch (e) { + // Ignore parsing errors. + } + } + break; + } + } + } catch (e) { + // Ignore database query errors. + } + + // Build otherData, preserving overrideFee if it existed. + final otherDataMap = { + "mint": tokenMint, + "senderTokenAccount": senderTokenAccount, + "recipientTokenAccount": senderTokenAccount, + "isCancelled": (txDetails.meta!.err != null), + }; + if (existingOverrideFee != null) { + otherDataMap["overrideFee"] = existingOverrideFee; + } + + // Create placeholder TransactionV2 object. + final txn = TransactionV2( + walletId: walletId, + blockHash: null, + hash: txid, + txid: txid, + timestamp: + txDetails.blockTime ?? + DateTime.now().millisecondsSinceEpoch ~/ 1000, + height: txDetails.slot, + inputs: [ + InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: null, + scriptSigAsm: null, + sequence: null, + outpoint: null, + addresses: [senderTokenAccount], + valueStringSats: "0", + witness: null, + innerRedeemScriptAsm: null, + coinbase: null, + walletOwns: true, + ), + ], + outputs: [ + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: "00", + valueStringSats: "0", + addresses: [senderTokenAccount], + walletOwns: false, + ), + ], + version: -1, + type: TransactionType.outgoing, + subType: TransactionSubType.splToken, + otherData: jsonEncode(otherDataMap), + ); + + txns.add(txn); + } catch (e, s) { + Logging.instance.w( + "$runtimeType updateTransactions: Failed to parse transaction at index $i", + error: e, + stackTrace: s, + ); + skippedCount++; + continue; + } + } + + // Persist all transactions if any were parsed. + if (txns.isNotEmpty) { + await mainDB.updateOrPutTransactionV2s(txns); + Logging.instance.i( + "$runtimeType updateTransactions: Synced ${txns.length} transactions (skipped $skippedCount)", + ); + } + } catch (e, s) { + Logging.instance.e( + "$runtimeType updateTransactions FAILED: ", + error: e, + stackTrace: s, + ); + } + } + + @override + Future updateBalance() async { + try { + final rpcClient = parentSolanaWallet.getRpcClient(); + if (rpcClient == null) { + return; + } + + final keyPair = await parentSolanaWallet.getKeyPair(); + final walletAddress = keyPair.address; + + final senderTokenAccount = await _findTokenAccount( + ownerAddress: walletAddress, + mint: tokenMint, + rpcClient: rpcClient, + ); + + if (senderTokenAccount == null) { + return; + } + + final tokenApi = SolanaTokenAPI(); + tokenApi.initializeRpcClient(rpcClient); + + final balanceResponse = await tokenApi.getTokenAccountBalance( + senderTokenAccount, + ); + + if (balanceResponse.isError) { + Logging.instance.w( + "$runtimeType updateBalance failed: ${balanceResponse.exception}", + ); + return; + } + + if (balanceResponse.value != null) { + final info = await mainDB.isar.walletSolanaTokenInfo + .where() + .walletIdTokenAddressEqualTo(walletId, tokenMint) + .findFirst(); + + if (info != null) { + final balanceAmount = Amount( + rawValue: balanceResponse.value!, + fractionDigits: tokenDecimals, + ); + + final balance = Balance( + total: balanceAmount, + spendable: balanceAmount, + blockedTotal: Amount( + rawValue: BigInt.zero, + fractionDigits: tokenDecimals, + ), + pendingSpendable: Amount( + rawValue: BigInt.zero, + fractionDigits: tokenDecimals, + ), + ); + + await info.updateCachedBalance(balance, isar: mainDB.isar); + } + } + } catch (e, s) { + Logging.instance.e( + "$runtimeType updateBalance error: ", + error: e, + stackTrace: s, + ); + } + } + + @override + Future updateUTXOs() async { + // Not applicable for Solana tokens. + return true; + } + + @override + Future updateChainHeight() async { + await parentSolanaWallet.updateChainHeight(); + } + + @override + Future refresh() async { + await parentSolanaWallet.refresh(); + await updateBalance(); + await updateTransactions(); + } + + @override + Future estimateFeeFor(Amount amount, BigInt feeRate) async { + return parentSolanaWallet.estimateFeeFor(amount, feeRate); + } + + @override + Future get fees async { + return parentSolanaWallet.fees; + } + + @override + Future pingCheck() async { + return parentSolanaWallet.pingCheck(); + } + + @override + Future checkSaveInitialReceivingAddress() async {} + + Future _findTokenAccount({ + required String ownerAddress, + required String mint, + required RpcClient rpcClient, + }) async { + try { + final result = await rpcClient.getTokenAccountsByOwner( + ownerAddress, + TokenAccountsFilter.byMint(mint), + encoding: Encoding.jsonParsed, + ); + + if (result.value.isEmpty) { + Logging.instance.w( + "$runtimeType _findTokenAccount: No token account found for " + "owner=$ownerAddress, mint=$mint", + ); + return null; + } + + final tokenAccountAddress = result.value.first.pubkey; + Logging.instance.i( + "$runtimeType _findTokenAccount: Found token account $tokenAccountAddress " + "for owner=$ownerAddress, mint=$mint", + ); + return tokenAccountAddress; + } catch (e) { + Logging.instance.w("$runtimeType _findTokenAccount error: $e"); + return null; + } + } + + Future _findOrDeriveRecipientTokenAccount({ + required String recipientAddress, + required String mint, + required RpcClient rpcClient, + }) async { + try { + // First, try to find an existing token account + final existingAccount = await _findTokenAccount( + ownerAddress: recipientAddress, + mint: mint, + rpcClient: rpcClient, + ); + + if (existingAccount != null) { + Logging.instance.i( + "$runtimeType Found existing token account for recipient: $existingAccount", + ); + return existingAccount; + } + + // If no existing account found, try to derive the ATA + Logging.instance.i( + "$runtimeType No existing token account found, deriving ATA for recipient", + ); + + try { + final ataAddress = await _deriveAtaAddress( + ownerAddress: recipientAddress, + mint: mint, + rpcClient: rpcClient, + ); + if (ataAddress != null) { + Logging.instance.i("$runtimeType Derived ATA address: $ataAddress"); + return ataAddress; + } else { + Logging.instance.w("$runtimeType ATA derivation returned null"); + return null; + } + } catch (derivationError) { + Logging.instance.w( + "$runtimeType Failed to derive ATA address: $derivationError", + ); + return null; + } + } catch (e) { + Logging.instance.w( + "$runtimeType _findOrDeriveRecipientTokenAccount error: $e", + ); + return null; + } + } + + Future _deriveAtaAddress({ + required String ownerAddress, + required String mint, + required RpcClient rpcClient, + }) async { + try { + final ownerPubkey = Ed25519HDPublicKey.fromBase58(ownerAddress); + final mintPubkey = Ed25519HDPublicKey.fromBase58(mint); + + final tokenApi = SolanaTokenAPI(); + tokenApi.initializeRpcClient(rpcClient); + + String tokenProgramId; + try { + final mintInfo = await rpcClient.getAccountInfo( + mint, + encoding: Encoding.jsonParsed, + ); + if (mintInfo.value != null) { + tokenProgramId = mintInfo.value!.owner; + } else { + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + } + } catch (e) { + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + } + + final tokenProgramPubkey = Ed25519HDPublicKey.fromBase58(tokenProgramId); + + const associatedTokenProgramId = + 'ATokenGPvbdGVqstVQmcLsNZAqeEjlCoquUSjfJ5c'; + final associatedTokenProgramPubkey = Ed25519HDPublicKey.fromBase58( + associatedTokenProgramId, + ); + + final seeds = [ + 'account'.codeUnits, + ownerPubkey.toBase58().codeUnits, + tokenProgramPubkey.toBase58().codeUnits, + mintPubkey.toBase58().codeUnits, + ]; + + final ataAddress = await Ed25519HDPublicKey.findProgramAddress( + seeds: seeds, + programId: associatedTokenProgramPubkey, + ); + + final ataBase58 = ataAddress.toBase58(); + + return ataBase58; + } catch (e, stackTrace) { + Logging.instance.w( + "$runtimeType _deriveAtaAddress error: $e", + error: e, + stackTrace: stackTrace, + ); + return null; + } + } + + Future _getEstimatedTokenTransferFee({ + required Ed25519HDPublicKey senderTokenAccountKey, + required Ed25519HDPublicKey recipientTokenAccountKey, + required Ed25519HDPublicKey ownerPublicKey, + required int amount, + required RpcClient rpcClient, + }) async { + try { + // Get latest blockhash for message compilation. + final latestBlockhash = await rpcClient.getLatestBlockhash(); + + final mintPubkey = Ed25519HDPublicKey.fromBase58(tokenMint); + + // Query the actual token program owner (important for Token-2022 variants). + String tokenProgramId; + try { + final mintInfo = await rpcClient.getAccountInfo( + tokenMint, + encoding: Encoding.jsonParsed, + ); + tokenProgramId = mintInfo.value?.owner ?? + 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + } catch (e) { + tokenProgramId = 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA'; + } + + // Build the TransferChecked instruction. + // Determine which token program type to use based on the queried owner. + final TokenProgramType tokenProgram = + tokenProgramId != 'TokenkegQfeZyiNwAJsyFbPVwwQQfg5bgUiqhStM5QA' + && tokenProgramId.startsWith('Token') + ? TokenProgramType.token2022Program + : TokenProgramType.tokenProgram; + + final instruction = TokenInstruction.transferChecked( + source: senderTokenAccountKey, + destination: recipientTokenAccountKey, + mint: mintPubkey, + owner: ownerPublicKey, + decimals: tokenDecimals, + amount: amount, + tokenProgram: tokenProgram, + ); + + // Compile the message with the blockhash. + final compiledMessage = Message(instructions: [instruction]).compile( + recentBlockhash: latestBlockhash.value.blockhash, + feePayer: ownerPublicKey, + ); + + // Get the fee for this compiled message. + final feeEstimate = await rpcClient.getFeeForMessage( + base64Encode(compiledMessage.toByteArray().toList()), + commitment: Commitment.confirmed, + ); + + if (feeEstimate != null) { + Logging.instance.i( + "$runtimeType Estimated token transfer fee: $feeEstimate lamports (from RPC)", + ); + return feeEstimate; + } + + Logging.instance.w("$runtimeType getFeeForMessage returned null"); + return null; + } catch (e) { + Logging.instance.w( + "$runtimeType _getEstimatedTokenTransferFee error: $e", + ); + return null; + } + } + + Future _waitForConfirmation({ + required String signature, + required int maxWaitSeconds, + required RpcClient rpcClient, + }) async { + final startTime = DateTime.now(); + + while (true) { + try { + final status = await rpcClient.getSignatureStatuses([ + signature, + ], searchTransactionHistory: true); + + if (status.value.isNotEmpty) { + final txStatus = status.value.first; + + // Check if transaction failed + if (txStatus?.err != null) { + Logging.instance.e( + "$runtimeType Transaction failed: ${txStatus?.err}", + ); + return false; + } + + // Check if transaction confirmed + if (txStatus?.confirmationStatus == Commitment.confirmed || + txStatus?.confirmationStatus == Commitment.finalized) { + Logging.instance.i( + "$runtimeType Transaction confirmed: $signature", + ); + return true; + } + } + } catch (e) { + Logging.instance.w( + "$runtimeType Error checking transaction confirmation: $e", + ); + } + + // Check timeout + final elapsed = DateTime.now().difference(startTime).inSeconds; + if (elapsed > maxWaitSeconds) { + Logging.instance.w( + "$runtimeType Transaction confirmation timeout after $maxWaitSeconds seconds", + ); + return false; + } + + // Wait before next check (2 seconds) + await Future.delayed(const Duration(seconds: 2)); + } + } +} diff --git a/lib/widgets/icon_widgets/eth_token_icon.dart b/lib/widgets/icon_widgets/eth_token_icon.dart index 0b0104fbf9..856474283e 100644 --- a/lib/widgets/icon_widgets/eth_token_icon.dart +++ b/lib/widgets/icon_widgets/eth_token_icon.dart @@ -8,6 +8,8 @@ * */ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; @@ -18,6 +20,7 @@ import '../../services/exchange/change_now/change_now_exchange.dart'; import '../../services/exchange/exchange_data_loading_service.dart'; import '../../themes/coin_icon_provider.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../loading_indicator.dart'; class EthTokenIcon extends ConsumerStatefulWidget { const EthTokenIcon({ @@ -41,18 +44,14 @@ class _EthTokenIconState extends ConsumerState { super.initState(); ExchangeDataLoadingService.instance.isar.then((isar) async { - final currency = - await isar.currencies - .where() - .exchangeNameEqualTo(ChangeNowExchange.exchangeName) - .filter() - .tokenContractEqualTo( - widget.contractAddress, - caseSensitive: false, - ) - .and() - .imageIsNotEmpty() - .findFirst(); + final currency = await isar.currencies + .where() + .exchangeNameEqualTo(ChangeNowExchange.exchangeName) + .filter() + .tokenContractEqualTo(widget.contractAddress, caseSensitive: false) + .and() + .imageIsNotEmpty() + .findFirst(); if (mounted) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -69,8 +68,8 @@ class _EthTokenIconState extends ConsumerState { @override Widget build(BuildContext context) { if (imageUrl == null || imageUrl!.isEmpty) { - return SvgPicture.asset( - ref.watch(coinIconProvider(Ethereum(CryptoCurrencyNetwork.main))), + return SvgPicture.file( + File(ref.watch(coinIconProvider(Ethereum(.main)))), width: widget.size, height: widget.size, ); @@ -79,6 +78,7 @@ class _EthTokenIconState extends ConsumerState { imageUrl!, width: widget.size, height: widget.size, + placeholderBuilder: (_) => const LoadingIndicator(), ); } } diff --git a/lib/widgets/icon_widgets/sol_token_icon.dart b/lib/widgets/icon_widgets/sol_token_icon.dart new file mode 100644 index 0000000000..8907651c3b --- /dev/null +++ b/lib/widgets/icon_widgets/sol_token_icon.dart @@ -0,0 +1,95 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2025 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * + */ + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:isar_community/isar.dart'; + +import '../../models/isar/exchange_cache/currency.dart'; +import '../../services/exchange/change_now/change_now_exchange.dart'; +import '../../services/exchange/exchange_data_loading_service.dart'; +import '../../themes/coin_icon_provider.dart'; +import '../../utilities/logger.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../loading_indicator.dart'; + +/// Token icon widget for Solana tokens. +/// +/// Displays the token icon by attempting to fetch from exchange data service. +/// Falls back to generic Solana token icon if no icon is found. +class SolTokenIcon extends ConsumerStatefulWidget { + const SolTokenIcon({super.key, required this.mintAddress, this.size = 22}); + + /// The SOL token mint address. + final String mintAddress; + + final double size; + + @override + ConsumerState createState() => _SolTokenIconState(); +} + +class _SolTokenIconState extends ConsumerState { + String? imageUrl; + + @override + void initState() { + super.initState(); + _loadTokenIcon(); + } + + Future _loadTokenIcon() async { + try { + final isar = await ExchangeDataLoadingService.instance.isar; + final currency = await isar.currencies + .where() + .exchangeNameEqualTo(ChangeNowExchange.exchangeName) + .filter() + .tokenContractEqualTo(widget.mintAddress, caseSensitive: false) + .and() + .imageIsNotEmpty() + .findFirst(); + + if (mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + imageUrl = currency?.image; + }); + } + }); + } + } catch (e, s) { + Logging.instance.e("", error: e, stackTrace: s); + } + } + + @override + Widget build(BuildContext context) { + if (imageUrl == null || imageUrl!.isEmpty) { + // Fallback to Solana coin icon from theme. + return SvgPicture.file( + File(ref.watch(coinIconProvider(Solana(.main)))), + width: widget.size, + height: widget.size, + ); + } else { + // Display token icon from network. + return SvgPicture.network( + imageUrl!, + width: widget.size, + height: widget.size, + placeholderBuilder: (_) => const LoadingIndicator(), + ); + } + } +} diff --git a/lib/widgets/wallet_card.dart b/lib/widgets/wallet_card.dart index ed49e5ebb9..8c6ab834c6 100644 --- a/lib/widgets/wallet_card.dart +++ b/lib/widgets/wallet_card.dart @@ -14,8 +14,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/isar/models/ethereum/eth_contract.dart'; +import '../models/isar/models/solana/sol_contract.dart'; +import '../pages/token_view/sol_token_view.dart'; import '../pages/token_view/token_view.dart'; import '../pages/wallet_view/wallet_view.dart'; +import '../pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart'; import '../pages_desktop_specific/my_stack_view/wallet_view/desktop_token_view.dart'; import '../pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart'; import '../providers/db/main_db_provider.dart'; @@ -25,9 +28,13 @@ import '../utilities/logger.dart'; import '../utilities/show_loading.dart'; import '../utilities/show_node_tor_settings_mismatch.dart'; import '../utilities/util.dart'; +import '../wallets/crypto_currency/coins/solana.dart'; import '../wallets/isar/providers/eth/current_token_wallet_provider.dart'; +import '../wallets/isar/providers/solana/current_sol_token_wallet_provider.dart'; import '../wallets/wallet/impl/ethereum_wallet.dart'; +import '../wallets/wallet/impl/solana_wallet.dart'; import '../wallets/wallet/impl/sub_wallets/eth_token_wallet.dart'; +import '../wallets/wallet/impl/sub_wallets/solana_token_wallet.dart'; import '../wallets/wallet/intermediate/external_wallet.dart'; import '../wallets/wallet/wallet.dart'; import 'conditional_parent.dart'; @@ -50,7 +57,7 @@ class SimpleWalletCard extends ConsumerWidget { final bool popPrevious; final NavigatorState? desktopNavigatorState; - Future _loadTokenWallet( + Future _loadEthTokenWallet( BuildContext context, WidgetRef ref, Wallet wallet, @@ -91,6 +98,47 @@ class SimpleWalletCard extends ConsumerWidget { } } + Future _loadSolanaTokenWallet( + BuildContext context, + WidgetRef ref, + Wallet wallet, + SolContract token, + ) async { + final old = ref.read(solanaTokenServiceStateProvider); + // exit previous if there is one + unawaited(old?.exit()); + ref.read(solanaTokenServiceStateProvider.state).state = SolanaTokenWallet( + wallet as SolanaWallet, + token, + ); + + try { + await ref.read(pCurrentSolanaTokenWallet)!.init(); + return true; + } catch (_) { + await showDialog( + barrierDismissible: false, + context: context, + builder: (context) => BasicDialog( + title: "Failed to load token data", + desktopHeight: double.infinity, + desktopWidth: 450, + rightButton: PrimaryButton( + label: "OK", + onPressed: () { + Navigator.of(context).pop(); + Navigator.of(context).pop(); + if (desktopNavigatorState == null) { + Navigator.of(context).pop(); + } + }, + ), + ), + ); + return false; + } + } + void _openWallet(BuildContext context, WidgetRef ref) async { final nav = Navigator.of(context); @@ -124,57 +172,111 @@ class SimpleWalletCard extends ConsumerWidget { ); if (popPrevious) nav.pop(); - if (desktopNavigatorState != null) { - unawaited( - desktopNavigatorState!.pushNamed( - DesktopWalletView.routeName, - arguments: walletId, - ), - ); - } else { - unawaited( - nav.pushNamed( - WalletView.routeName, - arguments: walletId, - ), - ); + if (contractAddress == null) { + if (desktopNavigatorState != null) { + unawaited( + desktopNavigatorState!.pushNamed( + DesktopWalletView.routeName, + arguments: walletId, + ), + ); + } else { + unawaited( + nav.pushNamed( + WalletView.routeName, + arguments: walletId, + ), + ); + } } if (contractAddress != null) { - final contract = - ref.read(mainDBProvider).getEthContractSync(contractAddress!)!; - - final success = await showLoading( - whileFuture: _loadTokenWallet( - desktopNavigatorState?.context ?? context, - ref, - wallet, - contract, - ), - context: desktopNavigatorState?.context ?? context, - opaqueBG: true, - message: "Loading ${contract.name}", - rootNavigator: Util.isDesktop, - ); - - if (!success!) { - // TODO: show error dialog here? - Logging.instance.e( - "Failed to load token wallet for $contract", - ); - return; - } + if (wallet.cryptoCurrency is Solana) { + // Handle Solana token. + final token = ref + .read(mainDBProvider) + .getSolContractSync(contractAddress!); - if (desktopNavigatorState != null) { - await desktopNavigatorState!.pushNamed( - DesktopTokenView.routeName, - arguments: walletId, + if (token == null) { + Logging.instance.e( + "Failed to find Solana token with address: $contractAddress", + ); + return; + } + + final success = await showLoading( + whileFuture: _loadSolanaTokenWallet( + desktopNavigatorState?.context ?? context, + ref, + wallet, + token, + ), + context: desktopNavigatorState?.context ?? context, + opaqueBG: true, + message: "Loading ${token.name}", + rootNavigator: Util.isDesktop, ); + + if (!success!) { + Logging.instance.e( + "Failed to load token wallet for $token", + ); + return; + } + + if (desktopNavigatorState != null) { + await desktopNavigatorState!.pushNamed( + DesktopSolTokenView.routeName, + arguments: (walletId: walletId, tokenMint: contractAddress!), + ); + } else { + await nav.pushNamed( + SolTokenView.routeName, + arguments: (walletId: walletId, tokenMint: contractAddress!), + ); + } } else { - await nav.pushNamed( - TokenView.routeName, - arguments: (walletId: walletId, popPrevious: !Util.isDesktop), + // Handle Ethereum token (default). + final contract = ref.read(mainDBProvider).getEthContractSync(contractAddress!); + + if (contract == null) { + Logging.instance.e( + "Failed to find Ethereum contract with address: $contractAddress", + ); + return; + } + + final success = await showLoading( + whileFuture: _loadEthTokenWallet( + desktopNavigatorState?.context ?? context, + ref, + wallet, + contract, + ), + context: desktopNavigatorState?.context ?? context, + opaqueBG: true, + message: "Loading ${contract.name}", + rootNavigator: Util.isDesktop, ); + + if (!success!) { + Logging.instance.e( + "Failed to load token wallet for $contract", + ); + return; + } + + if (desktopNavigatorState != null) { + await desktopNavigatorState!.pushNamed( + DesktopTokenView.routeName, + arguments: walletId, + ); + } else { + await nav.pushNamed( + TokenView.routeName, + arguments: (walletId: walletId, popPrevious: !Util.isDesktop), + ); + } } } } diff --git a/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart b/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart index e88737d4ee..096428c475 100644 --- a/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart +++ b/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart @@ -12,12 +12,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../db/isar/main_db.dart'; import '../../../models/isar/models/ethereum/eth_contract.dart'; +import '../../../models/isar/models/solana/sol_contract.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/amount/amount_formatter.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; +import '../../../wallets/crypto_currency/coins/solana.dart'; import '../../../wallets/isar/providers/eth/token_balance_provider.dart'; +import '../../../wallets/isar/providers/solana/sol_token_balance_provider.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; class WalletInfoRowBalance extends ConsumerWidget { @@ -36,21 +39,49 @@ class WalletInfoRowBalance extends ConsumerWidget { final Amount totalBalance; EthContract? contract; + SolContract? splToken; + if (contractAddress == null) { totalBalance = info.cachedBalance.total + info.cachedBalanceSecondary.total + info.cachedBalanceTertiary.total; contract = null; + splToken = null; } else { - contract = MainDB.instance.getEthContractSync(contractAddress!)!; - totalBalance = ref - .watch( - pTokenBalance( - (walletId: walletId, contractAddress: contractAddress!), - ), - ) - .total; + // Check if it's a Solana wallet. + if (info.coin is Solana) { + splToken = MainDB.instance.getSolContractSync(contractAddress!); + if (splToken != null) { + final solanaTokenInfo = ref + .watch( + pSolanaTokenWalletInfo( + (walletId: walletId, tokenMint: contractAddress!), + ), + ); + totalBalance = solanaTokenInfo.getCachedBalance().total; + } else { + // Token not yet in database, show zero balance. + totalBalance = Amount(rawValue: BigInt.zero, fractionDigits: 0); + } + contract = null; + } else { + // Ethereum token. + contract = MainDB.instance.getEthContractSync(contractAddress!); + if (contract != null) { + totalBalance = ref + .watch( + pTokenBalance( + (walletId: walletId, contractAddress: contractAddress!), + ), + ) + .total; + } else { + // Contract not yet in database, show zero balance. + totalBalance = Amount(rawValue: BigInt.zero, fractionDigits: 0); + } + splToken = null; + } } return Text( diff --git a/lib/widgets/wallet_info_row/wallet_info_row.dart b/lib/widgets/wallet_info_row/wallet_info_row.dart index 69aa9060e2..ba2dfa7c18 100644 --- a/lib/widgets/wallet_info_row/wallet_info_row.dart +++ b/lib/widgets/wallet_info_row/wallet_info_row.dart @@ -11,11 +11,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/isar/models/contract.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; import '../../providers/providers.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; +import '../../wallets/crypto_currency/coins/solana.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../coin_ticker_tag.dart'; import '../custom_buttons/blue_text_button.dart'; @@ -39,14 +41,31 @@ class WalletInfoRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final wallet = ref.watch(pWallets).getWallet(walletId); + final walletInfo = ref.watch(pWalletInfo(walletId)); + + Contract? contract; + String? contractName; - EthContract? contract; if (contractAddress != null) { - contract = ref.watch( - mainDBProvider.select( - (value) => value.getEthContractSync(contractAddress!), - ), - ); + if (walletInfo.coin is Solana) { + // Solana token. + final solContract = ref.watch( + mainDBProvider.select( + (value) => value.getSolContractSync(contractAddress!), + ), + ); + contract = solContract; + contractName = solContract?.name; + } else { + // Ethereum token. + final ethContract = ref.watch( + mainDBProvider.select( + (value) => value.getEthContractSync(contractAddress!), + ), + ); + contract = ethContract; + contractName = ethContract?.name; + } } if (Util.isDesktop) { @@ -65,11 +84,11 @@ class WalletInfoRow extends ConsumerWidget { contractAddress: contractAddress, ), const SizedBox(width: 12), - contract != null + contractName != null ? Row( children: [ Text( - contract.name, + contractName!, style: STextStyles.desktopTextExtraSmall( context, @@ -138,11 +157,11 @@ class WalletInfoRow extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (contract != null) + if (contractName != null) Row( children: [ Text( - contract.name, + contractName!, style: STextStyles.titleBold12(context), ), const SizedBox(width: 4), diff --git a/test/cached_electrumx_test.mocks.dart b/test/cached_electrumx_test.mocks.dart index 1da0fa70b8..504934e682 100644 --- a/test/cached_electrumx_test.mocks.dart +++ b/test/cached_electrumx_test.mocks.dart @@ -864,6 +864,19 @@ class MockPrefs extends _i1.Mock implements _i10.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -1093,6 +1106,18 @@ class MockPrefs extends _i1.Mock implements _i10.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) diff --git a/test/pages/send_view/send_view_test.mocks.dart b/test/pages/send_view/send_view_test.mocks.dart index 91fa2a84e8..36200d3895 100644 --- a/test/pages/send_view/send_view_test.mocks.dart +++ b/test/pages/send_view/send_view_test.mocks.dart @@ -778,6 +778,19 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -1007,6 +1020,18 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) diff --git a/test/screen_tests/exchange/exchange_view_test.mocks.dart b/test/screen_tests/exchange/exchange_view_test.mocks.dart index f27f332742..56bee69bfa 100644 --- a/test/screen_tests/exchange/exchange_view_test.mocks.dart +++ b/test/screen_tests/exchange/exchange_view_test.mocks.dart @@ -326,6 +326,19 @@ class MockPrefs extends _i1.Mock implements _i5.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -555,6 +568,18 @@ class MockPrefs extends _i1.Mock implements _i5.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) diff --git a/test/widget_tests/managed_favorite_test.mocks.dart b/test/widget_tests/managed_favorite_test.mocks.dart index 4cbe4bf179..28d64ceb68 100644 --- a/test/widget_tests/managed_favorite_test.mocks.dart +++ b/test/widget_tests/managed_favorite_test.mocks.dart @@ -551,6 +551,19 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -780,6 +793,18 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) diff --git a/test/widget_tests/node_options_sheet_test.mocks.dart b/test/widget_tests/node_options_sheet_test.mocks.dart index 23b04ffd95..e30eade5d2 100644 --- a/test/widget_tests/node_options_sheet_test.mocks.dart +++ b/test/widget_tests/node_options_sheet_test.mocks.dart @@ -29,7 +29,6 @@ import 'package:stackwallet/wallets/isar/models/wallet_info.dart' as _i11; import 'package:stackwallet/wallets/wallet/wallet.dart' as _i5; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface.dart' as _i6; -import 'package:tor_ffi_plugin/tor_ffi_plugin.dart' as _i22; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -445,6 +444,19 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -674,6 +686,18 @@ class MockPrefs extends _i1.Mock implements _i12.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) @@ -1008,14 +1032,10 @@ class MockTorService extends _i1.Mock implements _i20.TorService { as ({_i8.InternetAddress host, int port})); @override - void init({required String? torDataDirPath, _i22.Tor? mockableOverride}) => - super.noSuchMethod( - Invocation.method(#init, [], { - #torDataDirPath: torDataDirPath, - #mockableOverride: mockableOverride, - }), - returnValueForMissingStub: null, - ); + void init({required String? torDataDirPath}) => super.noSuchMethod( + Invocation.method(#init, [], {#torDataDirPath: torDataDirPath}), + returnValueForMissingStub: null, + ); @override _i10.Future start() => diff --git a/test/widget_tests/transaction_card_test.mocks.dart b/test/widget_tests/transaction_card_test.mocks.dart index 4ac1c8177d..3db3e7075e 100644 --- a/test/widget_tests/transaction_card_test.mocks.dart +++ b/test/widget_tests/transaction_card_test.mocks.dart @@ -520,6 +520,19 @@ class MockPrefs extends _i1.Mock implements _i13.Prefs { ) as ({bool enabled, int minutes})); + @override + bool get privacyScreen => + (super.noSuchMethod(Invocation.getter(#privacyScreen), returnValue: false) + as bool); + + @override + bool get disableScreenShots => + (super.noSuchMethod( + Invocation.getter(#disableScreenShots), + returnValue: false, + ) + as bool); + @override set lastUnlockedTimeout(int? lastUnlockedTimeout) => super.noSuchMethod( Invocation.setter(#lastUnlockedTimeout, lastUnlockedTimeout), @@ -749,6 +762,18 @@ class MockPrefs extends _i1.Mock implements _i13.Prefs { returnValueForMissingStub: null, ); + @override + set privacyScreen(bool? privacyScreen) => super.noSuchMethod( + Invocation.setter(#privacyScreen, privacyScreen), + returnValueForMissingStub: null, + ); + + @override + set disableScreenShots(bool? disableScreenShots) => super.noSuchMethod( + Invocation.setter(#disableScreenShots, disableScreenShots), + returnValueForMissingStub: null, + ); + @override bool get hasListeners => (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) @@ -910,6 +935,14 @@ class MockPriceService extends _i1.Mock implements _i21.PriceService { ) as _i10.Future>); + @override + _i10.Future> get solTokenContractAddressesToCheck => + (super.noSuchMethod( + Invocation.getter(#solTokenContractAddressesToCheck), + returnValue: _i10.Future>.value({}), + ) + as _i10.Future>); + @override Duration get updateInterval => (super.noSuchMethod( @@ -1647,6 +1680,50 @@ class MockMainDB extends _i1.Mock implements _i3.MainDB { returnValueForMissingStub: _i10.Future.value(), ) as _i10.Future); + + @override + _i8.QueryBuilder<_i28.SolContract, _i28.SolContract, _i8.QWhere> + getSolContracts() => + (super.noSuchMethod( + Invocation.method(#getSolContracts, []), + returnValue: + _FakeQueryBuilder_7< + _i28.SolContract, + _i28.SolContract, + _i8.QWhere + >(this, Invocation.method(#getSolContracts, [])), + ) + as _i8.QueryBuilder<_i28.SolContract, _i28.SolContract, _i8.QWhere>); + + @override + _i10.Future<_i28.SolContract?> getSolContract(String? tokenMint) => + (super.noSuchMethod( + Invocation.method(#getSolContract, [tokenMint]), + returnValue: _i10.Future<_i28.SolContract?>.value(), + ) + as _i10.Future<_i28.SolContract?>); + + @override + _i28.SolContract? getSolContractSync(String? tokenMint) => + (super.noSuchMethod(Invocation.method(#getSolContractSync, [tokenMint])) + as _i28.SolContract?); + + @override + _i10.Future putSolContract(_i28.SolContract? token) => + (super.noSuchMethod( + Invocation.method(#putSolContract, [token]), + returnValue: _i10.Future.value(0), + ) + as _i10.Future); + + @override + _i10.Future putSolContracts(List<_i28.SolContract>? tokens) => + (super.noSuchMethod( + Invocation.method(#putSolContracts, [tokens]), + returnValue: _i10.Future.value(), + returnValueForMissingStub: _i10.Future.value(), + ) + as _i10.Future); } /// A class which mocks [IThemeAssets].