diff --git a/doc/guides/nip13-proof-of-work.md b/doc/guides/nip13-proof-of-work.md deleted file mode 100644 index 0a704695f..000000000 --- a/doc/guides/nip13-proof-of-work.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -order: 60 -icon: shield-check ---- - -# NIP-13: Proof of Work - -Add computational proof-of-work to events for spam prevention. - -```dart -final minedEvent = Nip01Event( - pubKey: keyPair.publicKey, - kind: 1, - tags: [], - content: 'message', -).minePoW(12); - -if (minedEvent.checkPoWDifficulty(10)) { - print('Valid PoW, event has difficulty >= 10'); -} -``` - -## API - -**Event Methods:** -- `minePoW(difficulty)` - Add PoW -- `checkPoWDifficulty(target)` - Verify -- `powDifficulty` - Get difficulty - -**Nip13 Class:** -- `Nip13.mineEvent(event, difficulty)` -- `Nip13.validateEvent(event)` diff --git a/doc/library-development/ADRs/wallet-cashu.md b/doc/library-development/ADRs/wallet-cashu.md new file mode 100644 index 000000000..c14319b72 --- /dev/null +++ b/doc/library-development/ADRs/wallet-cashu.md @@ -0,0 +1,103 @@ +# Architecture Decision Record: Wallet Cashu API + +Title: Wallet Cashu - api design + +## status + +completed + +Updated on 03-09-2025 + +## contributors + +- Main contributor(s): leo-lox + +- Reviewer(s): frnandu, nogringo + +- Final decision made by: frnandu, leo-lox, nogringo + +## Context and Problem Statement + +We want to introduce a wallet use-case. To support multiple types of wallets like NWC and Cashu, we need different implementations. +Depending on the specific needs of a wallet, the capabilities are different. +How can we achieve a wallet design for the Cashu wallet that works for our users as well as for the generic wallet use-case? + +## Main Proposal + +Give the users methods to start a action like [spend, mint, melt] and notify about pending transactions via BehaviorSubjects. +The objects, by the behavior subjects then have methods to confirm or cancel where appropriate. +This is needed so the end-user can check the fees (transaction summary) before making a transaction. + +A pseudocode flow would look like this: + +```dart +main(){ + BehaviorSubject pendingTransactions = BehaviorSubject(); + + + /// initiate a transaction + void spend(Unit 'sat', Reciever receiver) { + /// ...wallet implementation + } + + /// user code listen to pending transactions + pendingTransactions.listen((transaction) { + + /// tbd if we have a stauts pending or a diffrent subscription for done (sucessfull, err) transactions + if (transaction.type == TransactionType.spend && transaction.status == TransactionStatus.pending) { + + /// display transaction summary to user + displayTransactionSummary(transaction.details); + + // User confirms the transaction + if (userConfirms()) { + transaction.confirm() + } else { + transaction.cancel() + } + } + + if(transaction.status == TransactionStatus.done) { + /// display result to user [sucess, error] + displayTransactionResult(transaction); + } + + }); +} +``` + +Flow: + +1. Listen to pending transaction +2. Initiate the transaction by calling a function. +3. React to pending transactions and confirm/decline them +4. React to transaction completed + +## Consequences + +The reactive nature of transactions makes it necessary to use some form of subscriptions. +Using this approach, the available options to the user/dev are quite clear. + +- Pros + + - Clear separations of what options are available at a given time. + - Data is directly available; no need to call a getter + - Setup for the user/dev is structured + - Clear separation between pending and final. + - Does not necessarily require cashu/implementation knowledge + +- Cons + - Requires subscription management for the user/dev + - More complex to implement (for us) + - less control for the user/dev, although we can expose methods if more control is needed. + +## Alternative proposals + +Use functions for each transaction step and user/dev uses them manualy. +pro: - a lot more control +con: - more complex, requires cashu knolege + +## Final Notes + 13-08-2025 +Proposal dismissed +Proceeding with a simpler method-based approach in combination with transaction streams. diff --git a/doc/usecases/accounts.md b/doc/usecases/accounts.md index bbc621980..80aaac2a2 100644 --- a/doc/usecases/accounts.md +++ b/doc/usecases/accounts.md @@ -48,23 +48,6 @@ await ndk.accounts.loginWithBunkerConnection( Store the `BunkerConnection` details locally to re-establish the connection in future sessions. Use `bunkerConnection.toJson()` to serialize and `BunkerConnection.fromJson()` to restore. Without storing these, users will need to re-authenticate each time. !!! -### Authentication state - -```dart -ndk.accounts.authStateChanges.listen((account) { -if (account == null) { - print('No active user'); -} else { - print('Active user: ${account.pubkey}'); -} -}); -``` - -Events are fired when the following occurs: -- On login -- On logout -- On switch account - ## When to use Use it to log in an account. diff --git a/doc/usecases/cashu.md b/doc/usecases/cashu.md new file mode 100644 index 000000000..0ac8c3a41 --- /dev/null +++ b/doc/usecases/cashu.md @@ -0,0 +1,182 @@ +--- +icon: fiscal-host +title: cashu - eCash +--- + +[!badge variant="primary" text="high level"] + +!!!danger experimental +DO NOT USE IN PRODUCTION! +!!! + +!!! +no recovery option, if the user deletes the db (by resetting the app) funds are lost \ +This API is `experimental` you can try it and submit your feedback. +!!! + +## When to use + +Cashu usecase can manage eCash (digital cash) within your application. It provides functionalities for funding, spending, and receiving eCash tokens. + +## Examples + +## add mint url + +!!! +When you receive tokens or initiate funding, the mint gets added automatically +!!! + +```dart +/// adds to known mints +ndk.cashu.addMintToKnownMints(mintUrl: "https://example.mint"); + +/// stream [Set] of known mints +ndk.cashu.knownMints; + +/// get [CashuMintInfo] without adding it to known mints +ndk.cashu.getMintInfoNetwork(mintUrl: "https://example.mint"); + +``` + +## fund (mint) + +```dart + final initTransaction = await ndk.cashu.initiateFund( + mintUrl: "https://example.mint", + amount: "100", + unit: "sat", + method: "bolt11", + memo: "funding example", + ); + + /// pay the request (usually lnbc1...) + print(initTransaction.qoute!.request); + + /// retrieve funds and listen for status + final resultStream = + ndk.cashu.retrieveFunds(draftTransaction: initTransaction); + + await for (final result in resultStream) { + if (result.state == ndk_entities.WalletTransactionState.completed) { + /// transcation done + print(result.completionMsg); + } else if (result.state == ndk_entities.WalletTransactionState.pending) { + /// pending + } + else if (result.state == ndk_entities.WalletTransactionState.failed) { + /// transcation done + print(result.completionMsg); + } + } + +``` + +## redeem (melt) + +```dart + + final draftTransaction = await ndk.cashu.initiateRedeem( + mintUrl: "https://example.mint", + request: "lnbc1...", + unit: "sat" + method: "bolt11", + ); + + /// check if everything is ok (fees etc) + print(draftTransaction.qouteMelt.feeReserve); + + /// redeem funds and listen for status + final resultStream = + ndk.cashu.redeem(draftTransaction: draftTransaction); + + await for (final result in resultStream) { + if (result.state == ndk_entities.WalletTransactionState.completed) { + /// transcation done + print(result.completionMsg); + } else if (result.state == ndk_entities.WalletTransactionState.pending) { + /// pending + } + else if (result.state == ndk_entities.WalletTransactionState.failed) { + /// transcation done + print(result.completionMsg); + } + } + + +``` + +## spend + +```dart + final spendResult = await ndk.cashu.initiateSpend( + mintUrl: "https://example.mint", + amount: 5, + unit: "sat", + memo: "spending example", + ); + + print("token to spend: ${spendResult.token.toV4TokenString()}"); + print("transaction id: ${spendResult.transaction}"); + + /// listen to pending transactions List + await for (final transaction in ndk.cashu.pendingTransactions) { + print("latest transaction: $transaction"); + } + + /// listen to recent transactinos List + await for (final transaction in ndk.cashu.latestTransactions) { + print("latest transaction: $transaction"); + } + + +``` + +## receive + +```dart + + final rcvResultStream = _ndk.cashu.receive(tokenString); + + await for (final rcvResult in rcvResultStream) { + if (rcvResult.state == ndk_entities.WalletTransactionState.pending) { + /// pending + } else if (rcvResult.state == + ndk_entities.WalletTransactionState.completed) { + /// completed + } else if (rcvResult.state == + ndk_entities.WalletTransactionState.failed) { + /// failed + print(result.completionMsg); + } + } + +``` + +!!! +All transactions are also available via `pendingTransactions` and `latestTransactions` streams.\ +As well as in the `Wallets` usecase +!!! + +## check balance + +```dart + /// balances for all mints [List] + final balances = await ndk.cashu.getBalances(); + print(balances); + + /// balance for one mint and unit [int] + final singleBalance = await getBalanceMintUnit( + mintUrl: "https://example.mint", + unit: "sat", + ); + + /// stream of [List] + ndk.cashu.balances; + +``` + +!!! +balances are also available via `Wallets` usecase +!!! + + diff --git a/doc/usecases/wallets.md b/doc/usecases/wallets.md new file mode 100644 index 000000000..6c730e059 --- /dev/null +++ b/doc/usecases/wallets.md @@ -0,0 +1,92 @@ +--- +icon: credit-card +--- + +[!badge variant="primary" text="high level"] + +!!!danger experimental +DO NOT USE IN PRODUCTION! +!!! + +## When to use + +`Wallets` usecase manages combines multiple wallets (e.g., Cashu, NWC) within your application. It provides functionalities for creating, managing, and transacting. +If you build a transaction history or other reporting, you are advised to use this use case. You can switch or use multiple wallets and still have a unified transaction history. + +## Examples + +### balances + +```dart +/// balances of all wallets, split into walletId and unit +/// returns Stream> +final balances = await ndk.wallets.combinedBalances; + +/// get combined balance of all wallets in a specific unit +/// returns int +final combinedSat = ndk.wallets.getCombinedBalance("sat"); +``` + +### transactions + +```dart +/// get all pending transactions, fires immediately and on every change +/// returns Stream> +final pendingTransactions = await ndk.wallets.combinedPendingTransactions; + + +/// get all recent transactions, fires immediately and on every change +/// returns Stream> +final recentTransactions = await ndk.wallets.combinedRecentTransactions; + +/// get all transactions, with pagination and filtering options +/// returns Future> +final transactions = await ndk.wallets.combinedTransactions( + limit: 100, // optional + offset: 0, // optional, pagination + walletId: "mywalletId", // optional + unit: "sat", // optional + walletType: WalletType.cashu, // optional +); +``` + +### wallets + +```dart + +/// get all wallets +/// returns Stream> +final wallets = ndk.wallets.walletsStream; + +/// get default wallet +/// returns Wallet? +final defaultWallet = ndk.wallets.defaultWallet; + + +await ndk.wallets.addWallet(myWallet); + +setDefaultWallet("myWalletId"); + + +await ndk.wallets.removeWalet("myWalletId"); + +/// get all wallets supporting a specific unit +/// returns List +final walletsSupportingSat = ndk.wallets.getWalletsForUnit("sat"); + +``` + +### actions + +The wallets usecase provides unified actions that work across different wallet types. (WIP) + +```dart + +///! WIP none of the params are final +final zapResult = await ndk.wallets.zap( + pubkey: "pubkeyToZap", + amount: 10, + comment: "Hello World", + ); + +``` diff --git a/packages/amber/pubspec.lock b/packages/amber/pubspec.lock index 4e42956ef..b346a3a71 100644 --- a/packages/amber/pubspec.lock +++ b/packages/amber/pubspec.lock @@ -49,6 +49,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + bip32_keys: + dependency: transitive + description: + path: "." + ref: HEAD + resolved-ref: b5a0342220e7ee5aaf64d489a589bdee6ef8de22 + url: "https://github.com/1-leo/dart-bip32-keys" + source: git + version: "3.1.2" bip340: dependency: transitive description: @@ -57,6 +66,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + bip39_mnemonic: + dependency: transitive + description: + name: bip39_mnemonic + sha256: dd6bdfc2547d986b2c00f99bba209c69c0b6fa5c1a185e1f728998282f1249d5 + url: "https://pub.dev" + source: hosted + version: "4.0.1" boolean_selector: dependency: transitive description: @@ -65,6 +82,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + bs58check: + dependency: transitive + description: + name: bs58check + sha256: c4a164d42b25c2f6bc88a8beccb9fc7d01440f3c60ba23663a20a70faf484ea9 + url: "https://pub.dev" + source: hosted + version: "1.0.2" build: dependency: transitive description: @@ -129,6 +154,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.9.2" + cbor: + dependency: transitive + description: + name: cbor + sha256: f5239dd6b6ad24df67d1449e87d7180727d6f43b87b3c9402e6398c7a2d9609b + url: "https://pub.dev" + source: hosted + version: "6.3.7" characters: dependency: transitive description: @@ -325,6 +358,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + ieee754: + dependency: transitive + description: + name: ieee754 + sha256: "7d87451c164a56c156180d34a4e93779372edd191d2c219206100b976203128c" + url: "https://pub.dev" + source: hosted + version: "1.0.3" integration_test: dependency: "direct dev" description: flutter @@ -630,6 +671,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unorm_dart: + dependency: transitive + description: + name: unorm_dart + sha256: "8e3870a1caa60bde8352f9597dd3535d8068613269444f8e35ea8925ec84c1f5" + url: "https://pub.dev" + source: hosted + version: "0.3.1+1" vector_math: dependency: transitive description: diff --git a/packages/ndk/example/account_test.dart b/packages/ndk/example/account_test.dart new file mode 100644 index 000000000..0756c221b --- /dev/null +++ b/packages/ndk/example/account_test.dart @@ -0,0 +1,42 @@ +// ignore_for_file: avoid_print + +import 'package:ndk/config/bootstrap_relays.dart'; +import 'package:ndk/shared/nips/nip01/bip340.dart'; +import 'package:ndk/shared/nips/nip01/key_pair.dart'; +import 'package:test/test.dart'; +import 'package:ndk/ndk.dart'; + +void main() async { + test( + 'account', + () async { + // Create an instance of Ndk + // It's recommended to keep this instance global as it holds critical application state + final ndk = Ndk.defaultConfig(); + + // generate a new key + KeyPair key1 = Bip340.generatePrivateKey(); + + // login using private key + ndk.accounts + .loginPrivateKey(privkey: key1.privateKey!, pubkey: key1.publicKey); + + // broadcast a new event using the logged in account with it's signer to sign + NdkBroadcastResponse response = ndk.broadcast.broadcast( + nostrEvent: Nip01Event( + pubKey: key1.publicKey, + kind: Nip01Event.kTextNodeKind, + tags: [], + content: "test"), + specificRelays: DEFAULT_BOOTSTRAP_RELAYS); + await response.broadcastDoneFuture; + + // logout + ndk.accounts.logout(); + + // destroy ndk instance + ndk.destroy(); + }, + skip: true, + ); +} diff --git a/packages/ndk/example/files/blossom_example_test.dart b/packages/ndk/example/files/blossom_example_test.dart index d2180b883..3dc75dd02 100644 --- a/packages/ndk/example/files/blossom_example_test.dart +++ b/packages/ndk/example/files/blossom_example_test.dart @@ -10,7 +10,7 @@ void main() async { final downloadResult = await ndk.blossom.getBlob( sha256: "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553", - serverUrls: ["https://cdn.hzrd149.com"], + serverUrls: ["https://cdn.hzrd149.com", "https://nostr.download"], ); print( @@ -18,5 +18,5 @@ void main() async { ); expect(downloadResult.data.length, greaterThan(0)); - }); + }, skip: true); } diff --git a/packages/ndk/example/files/files_example_test.dart b/packages/ndk/example/files/files_example_test.dart index 8f73d9747..9107db4df 100644 --- a/packages/ndk/example/files/files_example_test.dart +++ b/packages/ndk/example/files/files_example_test.dart @@ -15,7 +15,7 @@ void main() async { "file of type: ${downloadResult.mimeType}, size: ${downloadResult.data.length}"); expect(downloadResult.data.length, greaterThan(0)); - }); + }, skip: true); test('download test - non blossom', () async { final ndk = Ndk.defaultConfig(); @@ -27,5 +27,5 @@ void main() async { "file of type: ${downloadResult.mimeType}, size: ${downloadResult.data.length}"); expect(downloadResult.data.length, greaterThan(0)); - }); + }, skip: true); } diff --git a/packages/ndk/lib/config/cashu_config.dart b/packages/ndk/lib/config/cashu_config.dart new file mode 100644 index 000000000..6c0bf1fad --- /dev/null +++ b/packages/ndk/lib/config/cashu_config.dart @@ -0,0 +1,10 @@ +// ignore_for_file: constant_identifier_names + +class CashuConfig { + static const String NUT_VERSION = 'v1'; + static const String DOMAIN_SEPARATOR_HashToCurve = + 'Secp256k1_HashToCurve_Cashu_'; + + static const Duration FUNDING_CHECK_INTERVAL = Duration(seconds: 2); + static const Duration SPEND_CHECK_INTERVAL = Duration(seconds: 5); +} diff --git a/packages/ndk/lib/data_layer/data_sources/http_request.dart b/packages/ndk/lib/data_layer/data_sources/http_request.dart index eb93497a9..85edf62ab 100644 --- a/packages/ndk/lib/data_layer/data_sources/http_request.dart +++ b/packages/ndk/lib/data_layer/data_sources/http_request.dart @@ -42,9 +42,12 @@ class HttpRequestDS { return response; } + /// Future post({ required Uri url, - required Uint8List body, + + /// String, Uint8List + required Object body, required headers, }) async { http.Response response = await _client.post( diff --git a/packages/ndk/lib/data_layer/models/cashu/cashu_event_model.dart b/packages/ndk/lib/data_layer/models/cashu/cashu_event_model.dart new file mode 100644 index 000000000..50d8f3173 --- /dev/null +++ b/packages/ndk/lib/data_layer/models/cashu/cashu_event_model.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; + +import '../../../domain_layer/entities/cashu/cashu_event_content.dart'; +import '../../../domain_layer/entities/nip_01_event.dart'; +import '../../../domain_layer/entities/cashu/cashu_event.dart'; +import '../../../domain_layer/repositories/event_signer.dart'; + +class CashuEventModel extends CashuEvent { + CashuEventModel({ + required super.mints, + required super.walletPrivkey, + required super.userPubkey, + }); + + /// creates a nostr event based on the WalletCashuEvent data + Future createNostrEvent({ + required EventSigner signer, + }) async { + final encryptedContent = await signer.encryptNip44( + plaintext: jsonEncode( + CashuEventContent(privKey: walletPrivkey, mints: mints) + .toCashuEventContent(), + ), + recipientPubKey: userPubkey); + + if (encryptedContent == null) { + throw Exception("could not encrypt cashu wallet event"); + } + + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return Nip01Event( + pubKey: userPubkey, + tags: [], + kind: CashuEvent.kWalletKind, + createdAt: now, + content: encryptedContent, + ); + } + + /// creates a WalletCashuEvent from a nip01Event + Future fromNip01Event({ + required Nip01Event nostrEvent, + required EventSigner signer, + }) async { + final decryptedContent = await signer.decryptNip44( + ciphertext: nostrEvent.content, + senderPubKey: nostrEvent.pubKey, + ); + if (decryptedContent == null) { + throw Exception("could not decrypt cashu wallet event"); + } + final jsonContent = jsonDecode(decryptedContent); + + final extractedContent = + CashuEventContent.fromCashuEventContent(jsonContent); + + return CashuEventModel( + walletPrivkey: extractedContent.privKey, + mints: extractedContent.mints, + userPubkey: nostrEvent.pubKey, + ); + } +} diff --git a/packages/ndk/lib/data_layer/models/cashu/cashu_spending_history_event_model.dart b/packages/ndk/lib/data_layer/models/cashu/cashu_spending_history_event_model.dart new file mode 100644 index 000000000..06dc96460 --- /dev/null +++ b/packages/ndk/lib/data_layer/models/cashu/cashu_spending_history_event_model.dart @@ -0,0 +1,37 @@ +import 'dart:convert'; + +import '../../../domain_layer/entities/cashu/cashu_spending_history_event.dart'; +import '../../../domain_layer/entities/cashu/cashu_spending_history_event_content.dart'; +import '../../../domain_layer/entities/nip_01_event.dart'; +import '../../../domain_layer/repositories/event_signer.dart'; + +class CashuSpendingHistoryEventModel extends CashuSpendingHistoryEvent { + CashuSpendingHistoryEventModel({ + required super.direction, + required super.amount, + required super.tokens, + }); + + Future fromNip01Event({ + required Nip01Event nostrEvent, + required EventSigner signer, + }) async { + final decryptedContent = await signer.decryptNip44( + ciphertext: nostrEvent.content, + senderPubKey: nostrEvent.pubKey, + ); + if (decryptedContent == null) { + throw Exception("could not decrypt cashu wallet event"); + } + final jsonContent = jsonDecode(decryptedContent); + + final extractedContent = + CashuSpendingHistoryEventContent.fromJson(jsonContent); + + return CashuSpendingHistoryEventModel( + amount: extractedContent.amount, + direction: extractedContent.direction, + tokens: extractedContent.tokens, + ); + } +} diff --git a/packages/ndk/lib/data_layer/models/cashu/cashu_token_event_model.dart b/packages/ndk/lib/data_layer/models/cashu/cashu_token_event_model.dart new file mode 100644 index 000000000..513139324 --- /dev/null +++ b/packages/ndk/lib/data_layer/models/cashu/cashu_token_event_model.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; + +import '../../../domain_layer/entities/cashu/cashu_token_event.dart'; +import '../../../domain_layer/entities/cashu/cashu_token_event_content.dart'; +import '../../../domain_layer/entities/nip_01_event.dart'; +import '../../../domain_layer/repositories/event_signer.dart'; + +class CashuTokenEventModel extends CashuTokenEvent { + CashuTokenEventModel( + {required super.mintUrl, + required super.proofs, + required super.deletedIds}); + + Future fromNip01Event({ + required Nip01Event nostrEvent, + required EventSigner signer, + }) async { + final decryptedContent = await signer.decryptNip44( + ciphertext: nostrEvent.content, + senderPubKey: nostrEvent.pubKey, + ); + if (decryptedContent == null) { + throw Exception("could not decrypt cashu wallet event"); + } + final jsonContent = jsonDecode(decryptedContent); + + final extractedContent = CashuTokenEventContent.fromJson(jsonContent); + + return CashuTokenEventModel( + mintUrl: extractedContent.mintUrl, + proofs: extractedContent.proofs.toSet(), + deletedIds: extractedContent.deletedIds.toSet(), + ); + } +} diff --git a/packages/ndk/lib/data_layer/repositories/cache_manager/mem_cache_manager.dart b/packages/ndk/lib/data_layer/repositories/cache_manager/mem_cache_manager.dart index 0781e7c3e..317fea684 100644 --- a/packages/ndk/lib/data_layer/repositories/cache_manager/mem_cache_manager.dart +++ b/packages/ndk/lib/data_layer/repositories/cache_manager/mem_cache_manager.dart @@ -1,11 +1,17 @@ import 'dart:core'; +import '../../../domain_layer/entities/cashu/cashu_keyset.dart'; +import '../../../domain_layer/entities/cashu/cashu_mint_info.dart'; +import '../../../domain_layer/entities/cashu/cashu_proof.dart'; import '../../../domain_layer/entities/contact_list.dart'; import '../../../domain_layer/entities/nip_01_event.dart'; import '../../../domain_layer/entities/nip_05.dart'; import '../../../domain_layer/entities/relay_set.dart'; import '../../../domain_layer/entities/user_relay_list.dart'; import '../../../domain_layer/entities/metadata.dart'; +import '../../../domain_layer/entities/wallet/wallet.dart'; +import '../../../domain_layer/entities/wallet/wallet_transaction.dart'; +import '../../../domain_layer/entities/wallet/wallet_type.dart'; import '../../../domain_layer/repositories/cache_manager.dart'; /// In memory database implementation @@ -30,6 +36,23 @@ class MemCacheManager implements CacheManager { /// In memory storage Map events = {}; + /// String for mint Url + Map> cashuKeysets = {}; + + /// String for mint Url + Map> cashuProofs = {}; + + List transactions = []; + + Set wallets = {}; + + Set cashuMintInfos = {}; + + /// In memory storage for cashu secret counters + /// Key is a combination of mintUrl and keysetId + /// value is the counter + final Map _cashuSecretCounters = {}; + @override Future saveUserRelayList(UserRelayList userRelayList) async { userRelayLists[userRelayList.pubKey] = userRelayList; @@ -297,4 +320,196 @@ class MemCacheManager implements CacheManager { Future close() async { return; } + + @override + Future> getKeysets({String? mintUrl}) { + if (cashuKeysets.containsKey(mintUrl)) { + return Future.value(cashuKeysets[mintUrl]?.toList() ?? []); + } else { + return Future.value(cashuKeysets.values.expand((e) => e).toList()); + } + } + + @override + Future saveKeyset(CahsuKeyset keyset) { + if (cashuKeysets.containsKey(keyset.mintUrl)) { + cashuKeysets[keyset.mintUrl]!.add(keyset); + } else { + cashuKeysets[keyset.mintUrl] = {keyset}; + } + return Future.value(); + } + + @override + Future> getProofs({ + String? mintUrl, + String? keysetId, + CashuProofState state = CashuProofState.unspend, + }) async { + if (cashuProofs.containsKey(mintUrl)) { + return cashuProofs[mintUrl]! + .where((proof) => + proof.state == state && + (keysetId == null || proof.keysetId == keysetId)) + .toList(); + } else { + return cashuProofs.values + .expand((proofs) => proofs) + .where((proof) => + proof.state == state && + (keysetId == null || proof.keysetId == keysetId)) + .toList(); + } + } + + @override + Future saveProofs({ + required List proofs, + required String mintUrl, + }) { + if (cashuProofs.containsKey(mintUrl)) { + cashuProofs[mintUrl]!.addAll(proofs); + } else { + cashuProofs[mintUrl] = Set.from(proofs); + } + return Future.value(); + } + + @override + Future removeProofs( + {required List proofs, required String mintUrl}) { + if (cashuProofs.containsKey(mintUrl)) { + final existingProofs = cashuProofs[mintUrl]!; + for (final proof in proofs) { + existingProofs.removeWhere((p) => p.secret == proof.secret); + } + if (existingProofs.isEmpty) { + cashuProofs.remove(mintUrl); + } + + return Future.value(); + } else { + return Future.error('No proofs found for mint URL: $mintUrl'); + } + } + + @override + Future> getTransactions({ + int? limit, + int? offset, + String? walletId, + String? unit, + WalletType? walletType, + }) { + List result = transactions.where((transaction) { + if (walletId != null && transaction.walletId != walletId) { + return false; + } + if (unit != null && transaction.unit != unit) { + return false; + } + if (walletType != null && transaction.walletType != walletType) { + return false; + } + return true; + }).toList(); + + if (offset != null && offset > 0) { + result = result.skip(offset).toList(); + } + + if (limit != null && limit > 0) { + result = result.take(limit).toList(); + } + + return Future.value(result); + } + + @override + Future saveTransactions( + {required List transactions}) { + /// Check if transactions are already present + /// if so update them + + for (final transaction in transactions) { + final existingIndex = this.transactions.indexWhere( + (t) => t.id == transaction.id && t.walletId == transaction.walletId); + if (existingIndex != -1) { + this.transactions[existingIndex] = transaction; + } else { + this.transactions.add(transaction); + } + } + return Future.value(); + } + + @override + Future?> getWallets({List? ids}) { + if (ids == null || ids.isEmpty) { + return Future.value(wallets.toList()); + } else { + final result = + wallets.where((wallet) => ids.contains(wallet.id)).toList(); + return Future.value(result.isNotEmpty ? result : null); + } + } + + @override + Future removeWallet(String id) { + wallets.removeWhere((wallet) => wallet.id == id); + return Future.value(); + } + + @override + Future saveWallet(Wallet wallet) { + wallets.add(wallet); + return Future.value(); + } + + @override + Future?> getMintInfos({ + List? mintUrls, + }) { + if (mintUrls == null) { + return Future.value(cashuMintInfos.toList()); + } else { + final result = cashuMintInfos + .where( + (info) => mintUrls.any((url) => info.isMintUrl(url)), + ) + .toList(); + return Future.value(result.isNotEmpty ? result : null); + } + } + + @override + Future saveMintInfo({ + required CashuMintInfo mintInfo, + }) { + cashuMintInfos + .removeWhere((info) => info.urls.any((url) => mintInfo.isMintUrl(url))); + cashuMintInfos.add(mintInfo); + return Future.value(); + } + + @override + Future getCashuSecretCounter({ + required String mintUrl, + required String keysetId, + }) { + final key = '$mintUrl|$keysetId'; + return Future.value(_cashuSecretCounters[key] ?? 0); + } + + @override + Future setCashuSecretCounter({ + required String mintUrl, + required String keysetId, + required int counter, + }) async { + final key = '$mintUrl|$keysetId'; + _cashuSecretCounters[key] = counter; + + return; + } } diff --git a/packages/ndk/lib/data_layer/repositories/cashu/cashu_repo_impl.dart b/packages/ndk/lib/data_layer/repositories/cashu/cashu_repo_impl.dart new file mode 100644 index 000000000..2a5307864 --- /dev/null +++ b/packages/ndk/lib/data_layer/repositories/cashu/cashu_repo_impl.dart @@ -0,0 +1,428 @@ +import 'dart:convert'; + +import '../../../domain_layer/entities/cashu/cashu_keyset.dart'; +import '../../../domain_layer/entities/cashu/cashu_blinded_message.dart'; +import '../../../domain_layer/entities/cashu/cashu_blinded_signature.dart'; +import '../../../domain_layer/entities/cashu/cashu_melt_response.dart'; +import '../../../domain_layer/entities/cashu/cashu_mint_info.dart'; +import '../../../domain_layer/entities/cashu/cashu_proof.dart'; +import '../../../domain_layer/entities/cashu/cashu_quote.dart'; +import '../../../domain_layer/entities/cashu/cashu_quote_melt.dart'; +import '../../../domain_layer/entities/cashu/cashu_token_state_response.dart'; +import '../../../domain_layer/repositories/cashu_repo.dart'; +import '../../../domain_layer/usecases/cashu/cashu_keypair.dart'; +import '../../../domain_layer/usecases/cashu/cashu_tools.dart'; +import '../../data_sources/http_request.dart'; + +final headers = {'Content-Type': 'application/json'}; + +class CashuRepoImpl implements CashuRepo { + final HttpRequestDS client; + + CashuRepoImpl({ + required this.client, + }); + @override + Future> swap({ + required String mintUrl, + required List proofs, + required List outputs, + }) async { + final url = CashuTools.composeUrl(mintUrl: mintUrl, path: 'swap'); + + outputs.sort((a, b) => a.amount.compareTo(b.amount)); + + final body = { + 'inputs': proofs.map((e) => e.toJson()).toList(), + 'outputs': outputs.map((e) => e.toJson()).toList(), + }; + + final response = await client.post( + url: Uri.parse(url), + body: jsonEncode(body), + headers: headers, + ); + + if (response.statusCode != 200) { + throw Exception( + 'Error swapping cashu tokens: ${response.statusCode}, ${response.body}', + ); + } + + final responseBody = jsonDecode(response.body); + if (responseBody is! Map) { + throw Exception('Invalid response format: $responseBody'); + } + + final List signaturesUnparsed = responseBody['signatures']; + + if (signaturesUnparsed.isEmpty) { + throw Exception('No signatures returned from swap'); + } + + return signaturesUnparsed + .map((e) => CashuBlindedSignature.fromServerMap(e)) + .toList(); + } + + @override + Future> getKeysets({ + required String mintUrl, + }) async { + final url = CashuTools.composeUrl(mintUrl: mintUrl, path: 'keysets'); + + final response = await client.get( + url: Uri.parse(url), + headers: headers, + ); + + if (response.statusCode != 200) { + throw Exception( + 'Error fetching keysets: ${response.statusCode}, ${response.body}', + ); + } + final responseBody = jsonDecode(response.body); + + if (responseBody is! Map) { + throw Exception('Invalid response format: $responseBody'); + } + final List keysetsUnparsed = responseBody['keysets']; + return keysetsUnparsed + .map((e) => CahsuKeysetResponse.fromServerMap( + map: e as Map, + mintUrl: mintUrl, + )) + .toList(); + } + + @override + Future> getKeys({ + required String mintUrl, + String? keysetId, + }) async { + final baseUrl = CashuTools.composeUrl(mintUrl: mintUrl, path: 'keys'); + + final String url; + if (keysetId != null) { + url = '$baseUrl/$keysetId'; + } else { + url = baseUrl; + } + + final response = await client.get( + url: Uri.parse(url), + headers: headers, + ); + if (response.statusCode != 200) { + throw Exception( + 'Error fetching keys: ${response.statusCode}, ${response.body}', + ); + } + final responseBody = jsonDecode(response.body); + if (responseBody is! Map) { + throw Exception('Invalid response format: $responseBody'); + } + final List keysUnparsed = responseBody['keysets']; + return keysUnparsed + .map((e) => CahsuKeysResponse.fromServerMap( + map: e as Map, + mintUrl: mintUrl, + )) + .toList(); + } + + @override + Future getMintQuote({ + required String mintUrl, + required int amount, + required String unit, + required String method, + String description = '', + }) async { + CashuKeypair quoteKey = CashuKeypair.generateCashuKeyPair(); + + final url = + CashuTools.composeUrl(mintUrl: mintUrl, path: 'mint/quote/$method'); + + final body = { + 'amount': amount, + 'unit': unit, + 'description': description, + 'pubkey': quoteKey.publicKey, + }; + + final response = await client.post( + url: Uri.parse(url), + body: jsonEncode(body), + headers: headers, + ); + + if (response.statusCode != 200) { + throw Exception( + 'Error getting mint quote: ${response.statusCode}, ${response.body}', + ); + } + + final responseBody = jsonDecode(response.body); + if (responseBody is! Map) { + throw Exception('Invalid response format: $responseBody'); + } + + return CashuQuote.fromServerMap( + map: responseBody, + mintUrl: mintUrl, + quoteKey: quoteKey, + ); + } + + @override + Future checkMintQuoteState({ + required String mintUrl, + required String quoteID, + required String method, + }) async { + final url = CashuTools.composeUrl( + mintUrl: mintUrl, path: 'mint/quote/$method/$quoteID'); + + final response = await client.get( + url: Uri.parse(url), + headers: headers, + ); + + if (response.statusCode != 200) { + throw Exception( + 'Error checking quote state: ${response.statusCode}, ${response.body}', + ); + } + + final responseBody = jsonDecode(response.body); + if (responseBody is! Map) { + throw Exception('Invalid response format: $responseBody'); + } + + return CashuQuoteState.fromValue( + responseBody['state'] as String, + ); + } + + @override + Future> mintTokens({ + required String mintUrl, + required String quote, + required List blindedMessagesOutputs, + required String method, + required CashuKeypair quoteKey, + }) async { + final url = CashuTools.composeUrl(mintUrl: mintUrl, path: 'mint/$method'); + + if (blindedMessagesOutputs.isEmpty) { + throw Exception('No outputs provided for minting'); + } + + final quoteSignature = CashuTools.createMintSignature( + quote: quote, + blindedMessagesOutputs: blindedMessagesOutputs, + privateKeyHex: quoteKey.privateKey, + ); + + final body = { + 'quote': quote, + 'outputs': blindedMessagesOutputs.map((e) { + return { + 'id': e.id, + 'amount': e.amount, + 'B_': e.blindedMessage, + }; + }).toList(), + "signature": quoteSignature, + }; + + final response = await client.post( + url: Uri.parse(url), + body: jsonEncode(body), + headers: headers, + ); + + if (response.statusCode != 200) { + throw Exception( + 'Error swapping cashu tokens: ${response.statusCode}, ${response.body}', + ); + } + + final responseBody = jsonDecode(response.body); + if (responseBody is! Map) { + throw Exception('Invalid response format: $responseBody'); + } + + final List signaturesUnparsed = responseBody['signatures']; + + if (signaturesUnparsed.isEmpty) { + throw Exception('No signatures returned from mint'); + } + + return signaturesUnparsed + .map((e) => CashuBlindedSignature.fromServerMap(e)) + .toList(); + } + + @override + Future getMeltQuote({ + required String mintUrl, + required String request, + required String unit, + required String method, + }) async { + final url = + CashuTools.composeUrl(mintUrl: mintUrl, path: 'melt/quote/$method'); + + final body = { + 'request': request, + 'unit': unit, + }; + + final response = await client.post( + url: Uri.parse(url), + body: jsonEncode(body), + headers: headers, + ); + + if (response.statusCode != 200) { + throw Exception( + 'Error getting melt quote: ${response.statusCode}, ${response.body}', + ); + } + + return CashuQuoteMelt.fromServerMap( + json: jsonDecode(response.body) as Map, + mintUrl: mintUrl, + request: request, + ); + } + + @override + Future checkMeltQuoteState({ + required String mintUrl, + required String quoteID, + required String method, + }) async { + final url = CashuTools.composeUrl( + mintUrl: mintUrl, path: 'melt/quote/$method/$quoteID'); + + final response = await client.get( + url: Uri.parse(url), + headers: headers, + ); + + if (response.statusCode != 200) { + throw Exception( + 'Error checking quote state: ${response.statusCode}, ${response.body}', + ); + } + + final responseBody = jsonDecode(response.body); + if (responseBody is! Map) { + throw Exception('Invalid response format: $responseBody'); + } + + return CashuQuoteMelt.fromServerMap( + json: responseBody, + mintUrl: mintUrl, + ); + } + + @override + Future meltTokens({ + required String mintUrl, + required String quoteId, + required List proofs, + required List outputs, + String method = 'bolt11', + }) async { + final body = { + 'quote': quoteId, + 'inputs': proofs.map((e) => e.toJson()).toList(), + 'outputs': outputs.map((e) => e.toJson()).toList() + }; + final url = CashuTools.composeUrl(mintUrl: mintUrl, path: 'melt/$method'); + + final response = await client.post( + url: Uri.parse(url), + body: jsonEncode(body), + headers: headers, + ); + + if (response.statusCode != 200) { + throw Exception( + 'Error melting cashu tokens: ${response.statusCode}, ${response.body}', + ); + } + final responseBody = jsonDecode(response.body); + if (responseBody is! Map) { + throw Exception('Invalid response format: $responseBody'); + } + return CashuMeltResponse.fromServerMap( + map: responseBody, + mintUrl: mintUrl, + quoteId: quoteId, + ); + } + + @override + Future getMintInfo({required String mintUrl}) { + final url = CashuTools.composeUrl(mintUrl: mintUrl, path: 'info'); + + return client + .get( + url: Uri.parse(url), + headers: headers, + ) + .then((response) { + if (response.statusCode != 200) { + throw Exception( + 'Error fetching mint info: ${response.statusCode}, ${response.body}', + ); + } + final responseBody = jsonDecode(response.body); + if (responseBody is! Map) { + throw Exception('Invalid response format: $responseBody'); + } + return CashuMintInfo.fromJson(responseBody, mintUrl: mintUrl); + }); + } + + @override + Future> checkTokenState({ + required List proofPubkeys, + required String mintUrl, + }) async { + final url = CashuTools.composeUrl(mintUrl: mintUrl, path: 'checkstate'); + + final body = { + 'Ys': proofPubkeys, + }; + final response = await client.post( + url: Uri.parse(url), + body: jsonEncode(body), + headers: headers, + ); + if (response.statusCode != 200) { + throw Exception( + 'Error checking token state: ${response.statusCode}, ${response.body}', + ); + } + final responseBody = jsonDecode(response.body); + if (responseBody is! Map) { + throw Exception('Invalid response format: $responseBody'); + } + final List statesUnparsed = responseBody['states']; + if (statesUnparsed.isEmpty) { + throw Exception('No states returned from check state'); + } + + return statesUnparsed + .map((e) => CashuTokenStateResponse.fromServerMap( + e as Map, + )) + .toList(); + } +} diff --git a/packages/ndk/lib/data_layer/repositories/wallets/wallets_operations_impl.dart b/packages/ndk/lib/data_layer/repositories/wallets/wallets_operations_impl.dart new file mode 100644 index 000000000..55d4355db --- /dev/null +++ b/packages/ndk/lib/data_layer/repositories/wallets/wallets_operations_impl.dart @@ -0,0 +1,9 @@ +import '../../../domain_layer/repositories/wallets_operations_repo.dart'; + +class WalletsOperationsImpl implements WalletsOperationsRepo { + @override + Future zap() { + // TODO: implement zap + throw UnimplementedError(); + } +} diff --git a/packages/ndk/lib/data_layer/repositories/wallets/wallets_repo_impl.dart b/packages/ndk/lib/data_layer/repositories/wallets/wallets_repo_impl.dart new file mode 100644 index 000000000..793ffd69b --- /dev/null +++ b/packages/ndk/lib/data_layer/repositories/wallets/wallets_repo_impl.dart @@ -0,0 +1,263 @@ +import 'dart:async'; + +import 'package:rxdart/rxdart.dart'; + +import '../../../domain_layer/entities/wallet/wallet.dart'; +import '../../../domain_layer/entities/wallet/wallet_balance.dart'; +import '../../../domain_layer/entities/wallet/wallet_transaction.dart'; +import '../../../domain_layer/entities/wallet/wallet_type.dart'; +import '../../../domain_layer/repositories/cache_manager.dart'; +import '../../../domain_layer/repositories/wallets_repo.dart'; +import '../../../domain_layer/usecases/cashu/cashu.dart'; +import '../../../domain_layer/usecases/nwc/nwc.dart'; + +/// this class manages the wallets (storage) and +/// glues the specific wallet implementation to the generic wallets usecase \ +/// the glue code is readonly for actions look at [WalletsOperationsRepo] +class WalletsRepoImpl implements WalletsRepo { + final Cashu _cashuUseCase; + final Nwc _nwcUseCase; + final CacheManager _cacheManger; + + WalletsRepoImpl({ + required Cashu cashuUseCase, + required Nwc nwcUseCase, + required CacheManager cacheManager, + }) : _cashuUseCase = cashuUseCase, + _nwcUseCase = nwcUseCase, + _cacheManger = cacheManager; + + @override + Future addWallet(Wallet account) { + return _cacheManger.saveWallet(account); + } + + @override + Future getWallet(String id) async { + final wallets = await _cacheManger.getWallets(ids: [id]); + if (wallets == null || wallets.isEmpty) { + throw Exception('Wallet with id $id not found'); + } + return wallets.first; + } + + @override + Future> getWallets() async { + final wallets = await _cacheManger.getWallets(); + if (wallets == null) { + return []; + } + return wallets; + } + + @override + Future removeWallet(String id) async { + Wallet wallet = await getWallet(id); + if (wallet is NwcWallet) { + NwcWallet nwcWallet = wallet; + // close connection if exists + if (wallet.connection != null) { + await _nwcUseCase.disconnect(nwcWallet.connection!); + if (nwcWallet.balanceSubject != null) { + await nwcWallet.balanceSubject!.close(); + } + if (nwcWallet.transactionsSubject != null) { + await nwcWallet.transactionsSubject!.close(); + } + if (nwcWallet.pendingTransactionsSubject != null) { + await nwcWallet.pendingTransactionsSubject!.close(); + } + } + } + return _cacheManger.removeWallet(id); + } + + @override + Stream> getBalancesStream(String id) async* { + // delegate to appropriate use case based on account type + final useCase = await _getWalletUseCase(id); + if (useCase is Cashu) { + // transform to WalletBalance + yield* useCase.balances.map((balances) => balances.where((b) => b.mintUrl == id).expand((b) { + return b.balances.entries.map((entry) => WalletBalance( + unit: entry.key, + amount: entry.value, + walletId: b.mintUrl, + )); + }).toList()); + } else if (useCase is Nwc) { + NwcWallet wallet = (await getWallet(id)) as NwcWallet; + if (!wallet.isConnected()) { + await _initNwcWalletConnection(wallet); + } + wallet.balanceSubject ??= BehaviorSubject>(); + + final balanceResponse = await useCase.getBalance(wallet.connection!); + wallet.balanceSubject!.add([WalletBalance(walletId: id, unit: "sat", amount: balanceResponse.balanceSats)]); + yield* wallet.balanceSubject!.stream; + } else { + throw UnimplementedError('Unknown account type for balances stream'); + } + } + + Future _initNwcWalletConnection(NwcWallet wallet) async { + wallet.connection ??= await _nwcUseCase.connect(wallet.metadata["nwcUrl"], + doGetInfoMethod: true // TODO getInfo or not should be ndk config somehow + ); + + wallet.connection!.notificationStream.stream.listen((notification) async { + if (!notification.isPaymentReceived && !notification.isPaymentSent) { + return; // only incoming and outgoing payments are handled here + } + if (wallet.balanceSubject != null && notification.state == "settled") { + final balanceResponse = await _nwcUseCase.getBalance(wallet.connection!); + wallet.balanceSubject! + .add([WalletBalance(walletId: wallet.id, unit: "sat", amount: balanceResponse.balanceSats)]); + } + if (wallet.transactionsSubject != null || wallet.pendingTransactionsSubject != null) { + final transaction = NwcWalletTransaction( + id: notification.paymentHash, + walletId: wallet.id, + changeAmount: (notification.isIncoming ? notification.amount /1000 : -notification.amount /1000) as int, + unit: "sats", + walletType: WalletType.NWC, + state: notification.isSettled + ? WalletTransactionState.completed + : (notification.isPending?WalletTransactionState.pending: WalletTransactionState.failed), + metadata: notification.metadata ?? {}, + transactionDate: notification.settledAt ?? notification.createdAt, + initiatedDate: notification.createdAt, + ); + if (notification.isSettled) { + wallet.transactionsSubject!.add([transaction]); + } else if (notification.isPending) { + wallet.pendingTransactionsSubject!.add([transaction]); + } + } + }); + } + + /// get notified about possible new wallets \ + /// this is used to update the UI when new wallets are implicitly added \ + /// like when receiving something on a not yet existing wallet + @override + Stream> walletsUsecaseStream() { + return _cashuUseCase.knownMints.map((mints) { + return mints + .map((mint) => CashuWallet( + id: mint.urls.first, + mintUrl: mint.urls.first, + type: WalletType.CASHU, + name: mint.name ?? mint.urls.first, + supportedUnits: mint.supportedUnits, + mintInfo: mint, + )) + .toList(); + }); + } + + @override + Stream> getPendingTransactionsStream( + String id, + ) async* { + final useCase = await _getWalletUseCase(id); + if (useCase is Cashu) { + /// filter transaction stream by id + yield* useCase.pendingTransactions.map( + (transactions) => transactions.where((transaction) => transaction.walletId == id).toList(), + ); + } else if (useCase is Nwc) { + NwcWallet wallet = (await getWallet(id)) as NwcWallet; + if (!wallet.isConnected()) { + await _initNwcWalletConnection(wallet); + } + wallet.pendingTransactionsSubject ??= BehaviorSubject>(); + final transactions = await _nwcUseCase.listTransactions(wallet.connection!, unpaid: true); + wallet.pendingTransactionsSubject!.add(transactions.transactions.reversed + .where((e) => e.state != null && e.state == "pending") + .map((e) => NwcWalletTransaction( + id: e.paymentHash, + walletId: wallet.id, + changeAmount: e.isIncoming ? e.amountSat : -e.amountSat, + unit: "sats", + walletType: WalletType.NWC, + state: e.state != null && e.state == "settled" + ? WalletTransactionState.completed + : WalletTransactionState.pending, + metadata: e.metadata ?? {}, + transactionDate: e.settledAt ?? e.createdAt, + initiatedDate: e.createdAt, + )) + .toList()); + yield* wallet.pendingTransactionsSubject!.stream; + } else { + throw UnimplementedError('Unknown account type for pending transactions stream'); + } + } + + @override + Stream> getRecentTransactionsStream( + String id, + ) async* { + final useCase = await _getWalletUseCase(id); + if (useCase is Cashu) { + /// filter transaction stream by id + yield* useCase.latestTransactions.map( + (transactions) => transactions.where((transaction) => transaction.walletId == id).toList(), + ); + } else if (useCase is Nwc) { + NwcWallet wallet = (await getWallet(id)) as NwcWallet; + if (!wallet.isConnected()) { + await _initNwcWalletConnection(wallet); + } + wallet.transactionsSubject ??= BehaviorSubject>(); + final transactions = await _nwcUseCase.listTransactions(wallet.connection!, unpaid: false); + wallet.transactionsSubject!.add(transactions.transactions.reversed + .where((e) => e.state != null && e.state == "settled") + .map((e) => NwcWalletTransaction( + id: e.paymentHash, + walletId: wallet.id, + changeAmount: e.isIncoming ? e.amountSat : -e.amountSat, + unit: "sats", + walletType: WalletType.NWC, + state: e.state != null && e.state == "settled" + ? WalletTransactionState.completed + : WalletTransactionState.pending, + metadata: e.metadata ?? {}, + transactionDate: e.settledAt ?? e.createdAt, + initiatedDate: e.createdAt, + )) + .toList()); + yield* wallet.transactionsSubject!.stream; + } else { + throw UnimplementedError('Unknown account type for recent transactions stream'); + } + } + + @override + Future> getTransactions({ + int? limit, + int? offset, + String? walletId, + String? unit, + WalletType? walletType, + }) { + return _cacheManger.getTransactions( + limit: limit, + offset: offset, + walletId: walletId, + unit: unit, + walletType: walletType, + ); + } + + Future _getWalletUseCase(String id) async { + final account = await getWallet(id); + switch (account.type) { + case WalletType.CASHU: + return _cashuUseCase; + case WalletType.NWC: + return _nwcUseCase; + } + } +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_blinded_message.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_blinded_message.dart new file mode 100644 index 000000000..b7d2a9ad4 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_blinded_message.dart @@ -0,0 +1,48 @@ +class CashuBlindedMessage { + CashuBlindedMessage({ + required this.id, + required this.amount, + required this.blindedMessage, + }); + + final String id; + final int amount; + + /// B_ + final String blindedMessage; + + factory CashuBlindedMessage.fromServerMap(Map json) { + return CashuBlindedMessage( + id: json['id'], + amount: int.tryParse(json['amount']) ?? 0, + blindedMessage: json['B_'], + ); + } + + Map toJson() { + return { + 'id': id, + 'amount': amount, + 'B_': blindedMessage, + }; + } + + @override + String toString() { + return '${super.toString()}, id: $id, amount: $amount, blindedMessage: $blindedMessage'; + } +} + +class CashuBlindedMessageItem { + final CashuBlindedMessage blindedMessage; + final String secret; + final BigInt r; + final int amount; + + CashuBlindedMessageItem({ + required this.blindedMessage, + required this.secret, + required this.r, + required this.amount, + }); +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_blinded_signature.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_blinded_signature.dart new file mode 100644 index 000000000..aa6492a98 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_blinded_signature.dart @@ -0,0 +1,28 @@ +class CashuBlindedSignature { + CashuBlindedSignature({ + required this.id, + required this.amount, + required this.blindedSignature, + }); + + final String id; + final int amount; + + /// C_ blinded signature + final String blindedSignature; + + factory CashuBlindedSignature.fromServerMap(Map json) { + return CashuBlindedSignature( + id: json['id'], + amount: json['amount'] is int + ? json['amount'] + : int.tryParse(json['amount']) ?? 0, + blindedSignature: json['C_'] ?? '', + ); + } + + @override + String toString() { + return '${super.toString()}, id: $id, amount: $amount, blindedSignature: $blindedSignature'; + } +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_event.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_event.dart new file mode 100644 index 000000000..bcae02e8e --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_event.dart @@ -0,0 +1,24 @@ +import '../nip_01_event.dart'; + +class CashuEvent { + static const int kWalletKind = 17375; + + final String walletPrivkey; + final Set mints; + + final String userPubkey; + + late final Nip01Event? nostrEvent; + + CashuEvent({ + required this.walletPrivkey, + required this.mints, + required this.userPubkey, + Nip01Event? nostrEvent, + }) { + if (nostrEvent != null) { + this.nostrEvent = nostrEvent; + return; + } + } +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_event_content.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_event_content.dart new file mode 100644 index 000000000..8e65cc73e --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_event_content.dart @@ -0,0 +1,47 @@ +class CashuEventContent { + final String privKey; + final Set mints; + + CashuEventContent({ + required this.privKey, + required this.mints, + }); + + /// converts to plain list data from WalletCashuEvent + List> toCashuEventContent() { + final jsonList = [ + ["privkey", privKey] + ]; + + jsonList.addAll(mints.map((mint) => ["mint", mint])); + + return jsonList; + } + + /// extracts data from plain lists + factory CashuEventContent.fromCashuEventContent( + List> jsonList, + ) { + String? privKey; + final Set mints = {}; + + for (final item in jsonList) { + if (item.length == 2) { + final key = item[0]; + final value = item[1]; + + if (key == 'privkey') { + privKey = value; + } else if (key == 'mint') { + mints.add(value); + } + } + } + + if (privKey == null) { + throw ArgumentError('Input list does not contain a private key.'); + } + + return CashuEventContent(privKey: privKey, mints: mints); + } +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_keyset.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_keyset.dart new file mode 100644 index 000000000..b810e614e --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_keyset.dart @@ -0,0 +1,152 @@ +class CahsuKeyset { + final String id; + final String mintUrl; + final String unit; + final bool active; + final int inputFeePPK; + final Set mintKeyPairs; + int? fetchedAt; + + CahsuKeyset({ + required this.id, + required this.mintUrl, + required this.unit, + required this.active, + required this.inputFeePPK, + required this.mintKeyPairs, + this.fetchedAt, + }) { + fetchedAt ??= DateTime.now().millisecondsSinceEpoch ~/ 1000; + } + + factory CahsuKeyset.fromResponses({ + required CahsuKeysetResponse keysetResponse, + required CahsuKeysResponse keysResponse, + }) { + if (keysetResponse.id != keysResponse.id || + keysetResponse.mintUrl != keysResponse.mintUrl || + keysetResponse.unit != keysResponse.unit) { + throw ArgumentError('Keyset and keys responses do not match'); + } + + return CahsuKeyset( + id: keysetResponse.id, + mintUrl: keysetResponse.mintUrl, + unit: keysetResponse.unit, + active: keysetResponse.active, + inputFeePPK: keysetResponse.inputFeePPK, + mintKeyPairs: keysResponse.mintKeyPairs, + ); + } + + factory CahsuKeyset.fromJson(Map json) { + return CahsuKeyset( + id: json['id'] as String, + mintUrl: json['mintUrl'] as String, + unit: json['unit'] as String, + active: json['active'] as bool, + inputFeePPK: json['inputFeePPK'] as int, + mintKeyPairs: (json['mintKeyPairs'] as List) + .map((e) => CahsuMintKeyPair( + amount: e['amount'] as int, + pubkey: e['pubkey'] as String, + )) + .toSet(), + ); + } + + Map toJson() { + return { + 'id': id, + 'mintUrl': mintUrl, + 'unit': unit, + 'active': active, + 'inputFeePPK': inputFeePPK, + 'mintKeyPairs': mintKeyPairs + .map((pair) => {'amount': pair.amount, 'pubkey': pair.pubkey}) + .toList(), + 'fetchedAt': fetchedAt, + }; + } +} + +class CahsuMintKeyPair { + final int amount; + final String pubkey; + + CahsuMintKeyPair({ + required this.amount, + required this.pubkey, + }); +} + +class CahsuKeysetResponse { + final String id; + final String mintUrl; + final String unit; + final bool active; + final int inputFeePPK; + + CahsuKeysetResponse({ + required this.id, + required this.mintUrl, + required this.unit, + required this.active, + required this.inputFeePPK, + }); + + factory CahsuKeysetResponse.fromServerMap({ + required Map map, + required String mintUrl, + }) { + return CahsuKeysetResponse( + id: map['id'] as String, + mintUrl: mintUrl, + unit: map['unit'] as String, + active: map['active'] as bool, + inputFeePPK: map['input_fee_ppk'] as int, + ); + } +} + +class CahsuKeysResponse { + final String id; + final String mintUrl; + final String unit; + final Set mintKeyPairs; + + CahsuKeysResponse({ + required this.id, + required this.mintUrl, + required this.unit, + required this.mintKeyPairs, + }); + + factory CahsuKeysResponse.fromServerMap({ + required Map map, + required String mintUrl, + }) { + final mintKeyPairs = {}; + final keys = map['keys'] as Map; + + for (final entry in keys.entries) { + /// some mints have keysets with values like: 9223372036854775808, larger then int max \ + /// even accounting for fiat values these proofs are unrealistic \ + /// => skipped + final amount = int.tryParse(entry.key); + if (amount != null) { + mintKeyPairs.add(CahsuMintKeyPair( + amount: amount, + pubkey: entry.value, + )); + } + } + + return CahsuKeysResponse( + id: map['id'] as String, + mintUrl: mintUrl, + unit: map['unit'] as String, + mintKeyPairs: mintKeyPairs, + ); + } +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_melt_response.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_melt_response.dart new file mode 100644 index 000000000..c34dd696d --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_melt_response.dart @@ -0,0 +1,36 @@ +import 'cashu_blinded_signature.dart'; +import 'cashu_quote.dart'; + +class CashuMeltResponse { + final String qoteId; + final String mintUrl; + final CashuQuoteState state; + final String? paymentPreimage; + final List change; + + CashuMeltResponse({ + required this.qoteId, + required this.mintUrl, + required this.state, + this.paymentPreimage, + required this.change, + }); + + factory CashuMeltResponse.fromServerMap({ + required Map map, + required String mintUrl, + required String quoteId, + }) { + return CashuMeltResponse( + qoteId: quoteId, + mintUrl: mintUrl, + state: CashuQuoteState.fromValue(map['state'] as String), + paymentPreimage: map['payment_preimage'] as String?, + change: (map['change'] as List?) + ?.map((e) => CashuBlindedSignature.fromServerMap( + e as Map)) + .toList() ?? + [], + ); + } +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_mint_balance.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_mint_balance.dart new file mode 100644 index 000000000..147da5f1a --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_mint_balance.dart @@ -0,0 +1,14 @@ +class CashuMintBalance { + final String mintUrl; + final Map balances; + + CashuMintBalance({ + required this.mintUrl, + required this.balances, + }); + + @override + String toString() { + return 'CashuMintBalance(mintUrl: $mintUrl, balances: $balances)'; + } +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_mint_info.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_mint_info.dart new file mode 100644 index 000000000..719c681d9 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_mint_info.dart @@ -0,0 +1,290 @@ +import '../../../shared/logger/logger.dart'; + +class CashuMintInfo { + final String? name; + final String? pubkey; + final String? version; + final String? description; + final String? descriptionLong; + final List contact; + final String? motd; + final String? iconUrl; + final List urls; + + /// unix timestamp in seconds on the server + final int? time; + final String? tosUrl; + final Map nuts; + + CashuMintInfo({ + this.name, + this.version, + this.description, + required this.nuts, + this.pubkey, + this.descriptionLong, + this.contact = const [], + this.motd, + this.iconUrl, + this.urls = const [], + this.time, + this.tosUrl, + }); + + bool isMintUrl(String url) { + return urls.any((u) => u == url); + } + + Set get supportedUnits { + final units = {}; + for (final nut in nuts.values) { + final all = [ + if (nut.methods != null) ...nut.methods!, + if (nut.supportedMethods != null) ...nut.supportedMethods!, + ]; + for (final pm in all) { + final u = pm.unit?.trim(); + if (u != null && u.isNotEmpty) { + units.add(u.toLowerCase()); + } + } + } + return units; + } + + /// [mintUrl] is used when json['urls'] is not present \ + factory CashuMintInfo.fromJson( + Map json, { + String? mintUrl, + }) { + final nutsJson = (json['nuts'] as Map?) ?? {}; + final parsedNuts = {}; + nutsJson.forEach((k, v) { + final key = int.tryParse(k.toString()); + if (key != null) { + try { + if (v is List) { + // skip (non-spec compliant) + Logger.log.w( + 'Warning: Skipping nut $key - received List instead of Map (non-spec compliant)'); + return; + } + + parsedNuts[key] = + CashuMintNut.fromJson((v ?? {}) as Map); + } catch (e) { + Logger.log.w('Warning: Skipping nut $key due to parsing error: $e'); + } + } + }); + + return CashuMintInfo( + name: json['name'] as String?, + pubkey: json['pubkey'] as String?, + version: json['version'] as String?, + description: json['description'] as String?, + descriptionLong: json['description_long'] as String?, + contact: ((json['contact'] as List?) ?? const []) + .map((e) => CashuMintContact.fromJson(e as Map)) + .toList(), + motd: json['motd'] as String?, + iconUrl: json['icon_url'] as String?, + urls: ((json['urls'] as List?) ?? [mintUrl]) + .map((e) => e.toString()) + .toList(), + time: (json['time'] is num) ? (json['time'] as num).toInt() : null, + tosUrl: json['tos_url'] as String?, + nuts: parsedNuts, + ); + } + + Map toJson() { + return { + if (name != null) 'name': name, + if (pubkey != null) 'pubkey': pubkey, + if (version != null) 'version': version, + if (description != null) 'description': description, + if (descriptionLong != null) 'description_long': descriptionLong, + if (contact.isNotEmpty) + 'contact': contact.map((c) => c.toJson()).toList(), + if (motd != null) 'motd': motd, + if (iconUrl != null) 'icon_url': iconUrl, + if (urls.isNotEmpty) 'urls': urls, + if (time != null) 'time': time, + if (tosUrl != null) 'tos_url': tosUrl, + 'nuts': nuts.map((k, v) => MapEntry(k.toString(), v.toJson())), + }; + } +} + +class CashuMintContact { + final String method; + final String info; + + CashuMintContact({ + required this.method, + required this.info, + }); + + factory CashuMintContact.fromJson(Map json) { + return CashuMintContact( + method: (json['method'] ?? '') as String, + info: (json['info'] ?? '') as String, + ); + } + + Map toJson() => { + 'method': method, + 'info': info, + }; +} + +class CashuMintNut { + final List? methods; + final bool? disabled; + final bool? supported; + + // nut-17 + final List? supportedMethods; + + // nut-19 + final int? ttl; + final List? cachedEndpoints; + + CashuMintNut({ + this.methods, + this.disabled, + this.supported, + this.supportedMethods, + this.ttl, + this.cachedEndpoints, + }); + + factory CashuMintNut.fromJson(Map json) { + final methodsJson = json['methods']; + List? parsedMethods; + if (methodsJson is List) { + parsedMethods = methodsJson + .map( + (e) => CashuMintPaymentMethod.fromJson(e as Map)) + .toList(); + } + + bool? supportedBool; + List? supportedList; + final supportedJson = json['supported']; + if (supportedJson is bool) { + supportedBool = supportedJson; + } else if (supportedJson is List) { + supportedList = supportedJson + .map( + (e) => CashuMintPaymentMethod.fromJson(e as Map)) + .toList(); + } + + List? endpoints; + final ce = json['cached_endpoints']; + if (ce is List) { + endpoints = ce + .map((e) => + CashuMintCachedEndpoint.fromJson(e as Map)) + .toList(); + } + + return CashuMintNut( + methods: parsedMethods, + disabled: json['disabled'] is bool ? json['disabled'] as bool : null, + supported: supportedBool, + supportedMethods: supportedList, + ttl: (json['ttl'] is num) ? (json['ttl'] as num).toInt() : null, + cachedEndpoints: endpoints, + ); + } + + Map toJson() { + return { + if (methods != null) 'methods': methods!.map((m) => m.toJson()).toList(), + if (disabled != null) 'disabled': disabled, + if (supported != null) 'supported': supported, + if (supportedMethods != null) + 'supported': supportedMethods!.map((m) => m.toJson()).toList(), + if (ttl != null) 'ttl': ttl, + if (cachedEndpoints != null) + 'cached_endpoints': cachedEndpoints!.map((e) => e.toJson()).toList(), + }; + } +} + +class CashuMintPaymentMethod { + /// e.g. bolt11 + final String method; + + /// e.g. sat + final String? unit; + final int? minAmount; + final int? maxAmount; + final bool? description; + + /// nut-17 + final List? commands; + + const CashuMintPaymentMethod({ + required this.method, + this.unit, + this.minAmount, + this.maxAmount, + this.description, + this.commands, + }); + + factory CashuMintPaymentMethod.fromJson(Map json) { + return CashuMintPaymentMethod( + method: (json['method'] ?? '') as String, + unit: json['unit'] as String?, + minAmount: (json['min_amount'] is num) + ? (json['min_amount'] as num).toInt() + : null, + maxAmount: (json['max_amount'] is num) + ? (json['max_amount'] as num).toInt() + : null, + description: + json['description'] is bool ? json['description'] as bool : null, + commands: (json['commands'] is List) + ? (json['commands'] as List).map((e) => e.toString()).toList() + : null, + ); + } + + Map toJson() { + return { + 'method': method, + if (unit != null) 'unit': unit, + if (minAmount != null) 'min_amount': minAmount, + if (maxAmount != null) 'max_amount': maxAmount, + if (description != null) 'description': description, + if (commands != null) 'commands': commands, + }; + } +} + +class CashuMintCachedEndpoint { + /// e.g. post + final String method; + + /// e.g. /v1/mint/bolt11 + final String path; + + CashuMintCachedEndpoint({required this.method, required this.path}); + + factory CashuMintCachedEndpoint.fromJson(Map json) { + return CashuMintCachedEndpoint( + method: (json['method'] ?? '') as String, + path: (json['path'] ?? '') as String, + ); + } + + Map toJson() => { + 'method': method, + 'path': path, + }; +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_proof.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_proof.dart new file mode 100644 index 000000000..f53a02573 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_proof.dart @@ -0,0 +1,88 @@ +import '../../usecases/cashu/cashu_tools.dart'; + +class CashuProof { + final String keysetId; + final int amount; + + final String secret; + + /// C unblinded signature + final String unblindedSig; + + CashuProofState state; + + CashuProof({ + required this.keysetId, + required this.amount, + required this.secret, + required this.unblindedSig, + this.state = CashuProofState.unspend, + }); + + /// Y derived public key + String get Y => CashuTools.ecPointToHex( + CashuTools.hashToCurve(secret), + ); + + Map toJson() { + return { + 'id': keysetId, + 'amount': amount, + 'secret': secret, + 'C': unblindedSig, + }; + } + + Map toV4Json() { + return { + 'a': amount, + 's': secret, + 'c': CashuTools.hexToBytes(unblindedSig), + }; + } + + factory CashuProof.fromV4Json({ + required Map json, + required String keysetId, + CashuProofState state = CashuProofState.unspend, + }) { + final unblindedSig = json['c'] as String?; + if (unblindedSig == null || unblindedSig.isEmpty) { + throw Exception('Unblinded signature is missing or empty'); + } + + return CashuProof( + keysetId: keysetId, + amount: json['a'] ?? 0, + secret: json['s']?.toString() ?? '', + unblindedSig: unblindedSig, + state: state); + } + + @override + bool operator ==(Object other) => + other is CashuProof && runtimeType == other.runtimeType && Y == other.Y; + + @override + int get hashCode => Y.hashCode; +} + +enum CashuProofState { + unspend('UNSPENT'), + pending('PENDING'), + spend('SPENT'); + + final String value; + + const CashuProofState(this.value); + + factory CashuProofState.fromValue(String value) { + return CashuProofState.values.firstWhere( + (transactionType) => transactionType.value == value, + orElse: () => CashuProofState.unspend, + ); + } + + @override + String toString() => value; +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_quote.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_quote.dart new file mode 100644 index 000000000..1745fd38f --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_quote.dart @@ -0,0 +1,88 @@ +import '../../usecases/cashu/cashu_keypair.dart'; + +class CashuQuote { + final String quoteId; + final String request; + final int amount; + final String unit; + final CashuQuoteState state; + + final CashuKeypair quoteKey; + + /// expires in seconds + final int expiry; + final String mintUrl; + + CashuQuote({ + required this.quoteId, + required this.request, + required this.amount, + required this.unit, + required this.state, + required this.expiry, + required this.mintUrl, + required this.quoteKey, + }); + + factory CashuQuote.fromServerMap({ + required Map map, + required String mintUrl, + required CashuKeypair quoteKey, + }) { + return CashuQuote( + quoteId: map['quote'] as String, + request: map['request'] as String, + amount: map['amount'] as int, + unit: map['unit'] as String, + state: CashuQuoteState.fromValue(map['state'] as String), + expiry: map['expiry'] as int, + mintUrl: mintUrl, + quoteKey: quoteKey, + ); + } + + factory CashuQuote.fromJson(Map json) { + return CashuQuote( + quoteId: json['quoteId'] as String, + request: json['request'] as String, + amount: json['amount'] as int, + unit: json['unit'] as String, + state: CashuQuoteState.fromValue(json['state'] as String), + expiry: json['expiry'] as int, + mintUrl: json['mintUrl'] as String, + quoteKey: CashuKeypair.fromJson(json['quoteKey'] as Map), + ); + } + + Map toJson() { + return { + 'quoteId': quoteId, + 'request': request, + 'amount': amount, + 'unit': unit, + 'state': state.value, + 'expiry': expiry, + 'mintUrl': mintUrl, + 'quoteKey': quoteKey.toJson(), + }; + } +} + +enum CashuQuoteState { + unpaid('UNPAID'), + + pending('PENDING'), + + paid('PAID'); + + final String value; + + const CashuQuoteState(this.value); + + factory CashuQuoteState.fromValue(String value) { + return CashuQuoteState.values.firstWhere( + (t) => t.value == value, + orElse: () => CashuQuoteState.unpaid, + ); + } +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_quote_melt.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_quote_melt.dart new file mode 100644 index 000000000..55a57a1c7 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_quote_melt.dart @@ -0,0 +1,73 @@ +import 'cashu_quote.dart'; + +class CashuQuoteMelt { + final String request; + final String quoteId; + final int amount; + final int? feeReserve; + final bool paid; + final int? expiry; + final String mintUrl; + final CashuQuoteState state; + final String unit; + + CashuQuoteMelt({ + required this.quoteId, + required this.amount, + required this.feeReserve, + required this.paid, + required this.expiry, + required this.mintUrl, + required this.state, + required this.unit, + required this.request, + }); + + factory CashuQuoteMelt.fromServerMap({ + required Map json, + required String mintUrl, + String? request, + }) { + return CashuQuoteMelt( + quoteId: json['quote'] as String, + amount: json['amount'] as int, + unit: json['unit'] as String, + state: CashuQuoteState.fromValue(json['state'] as String), + expiry: json['expiry'] as int?, + paid: json['paid'] != null ? json['paid'] as bool : false, + feeReserve: + (json['fee_reserve'] != null ? json['fee_reserve'] as int : 0), + request: + request ?? (json['request'] != null ? json['request'] as String : ''), + mintUrl: mintUrl, + ); + } + + factory CashuQuoteMelt.fromJson(Map json) { + return CashuQuoteMelt( + quoteId: json['quoteId'] as String, + amount: json['amount'] as int, + unit: json['unit'] as String, + state: CashuQuoteState.fromValue(json['state'] as String), + expiry: json['expiry'] as int?, + paid: json['paid'] as bool, + feeReserve: json['feeReserve'] as int?, + request: json['request'] as String, + mintUrl: json['mintUrl'] as String, + ); + } + + Map toJson() { + return { + 'quoteId': quoteId, + 'amount': amount, + 'feeReserve': feeReserve ?? 0, + 'paid': paid, + 'expiry': expiry ?? 0, + 'mintUrl': mintUrl, + 'state': state.value, + 'unit': unit, + 'request': request, + }; + } +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_spending_history_event.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_spending_history_event.dart new file mode 100644 index 000000000..8441f2897 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_spending_history_event.dart @@ -0,0 +1,58 @@ +import '../tuple.dart'; + +enum CashuSpendDirection { + sent('out'), + received('in'); + + final String value; + + const CashuSpendDirection(this.value); + + factory CashuSpendDirection.fromValue(String value) { + return CashuSpendDirection.values.firstWhere( + (transactionType) => transactionType.value == value, + orElse: () => CashuSpendDirection.received, + ); + } +} + +enum CashuSpendMarker { + /// A new token event was created + created('created'), + + /// A token event was destroyed + destroyed('destroyed'), + + /// A NIP-61 nutzap was redeemed + redeemed('redeemed'); + + final String value; + + const CashuSpendMarker(this.value); + + factory CashuSpendMarker.fromValue(String value) { + return CashuSpendMarker.values.firstWhere( + (t) => t.value == value, + orElse: () => CashuSpendMarker.created, + ); + } +} + +class CashuSpendingHistoryEvent { + static const int kSpendingHistoryKind = 7376; + + final CashuSpendDirection direction; + final int amount; + + /// tokens < TOKEN,SPEND_MARKER > + final List> tokens; + + final String? nutzapTokenId; + + CashuSpendingHistoryEvent({ + required this.direction, + required this.amount, + required this.tokens, + this.nutzapTokenId, + }); +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_spending_history_event_content.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_spending_history_event_content.dart new file mode 100644 index 000000000..c620f4a16 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_spending_history_event_content.dart @@ -0,0 +1,67 @@ +import '../tuple.dart'; +import 'cashu_spending_history_event.dart'; + +class CashuSpendingHistoryEventContent { + final CashuSpendDirection direction; + final int amount; + + /// tokens < TOKEN,SPEND_MARKER > + final List> tokens; + + CashuSpendingHistoryEventContent({ + required this.direction, + required this.amount, + required this.tokens, + }); + + /// extracts data from plain lists + factory CashuSpendingHistoryEventContent.fromJson( + List> jsonList, + ) { + CashuSpendDirection? direction; + int? amount; + List> tokens = []; + + for (final item in jsonList) { + if (item.isEmpty) continue; + + switch (item.first) { + case 'direction': + if (item.length > 1) { + direction = CashuSpendDirection.fromValue(item[1]); + } + break; + + case 'amount': + if (item.length > 1) { + amount = int.tryParse(item[1]); + } + break; + + case 'e': + if (item.length >= 4) { + final tokenId = item[1]; + final markerString = item[3]; + + CashuSpendMarker marker = CashuSpendMarker.fromValue(markerString); + + tokens.add(Tuple(tokenId, marker)); + } + break; + } + } + if (direction == null) { + throw Exception("err parsing direction"); + } + + if (amount == null) { + throw Exception("err parsing amount"); + } + + return CashuSpendingHistoryEventContent( + direction: direction, + amount: amount, + tokens: tokens, + ); + } +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_spending_result.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_spending_result.dart new file mode 100644 index 000000000..17c8fff8d --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_spending_result.dart @@ -0,0 +1,11 @@ +import '../../../entities.dart'; + +class CashuSpendingResult { + final CashuToken token; + final CashuWalletTransaction transaction; + + CashuSpendingResult({ + required this.token, + required this.transaction, + }); +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_token.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_token.dart new file mode 100644 index 000000000..35ee05c79 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_token.dart @@ -0,0 +1,85 @@ +import '../../usecases/cashu/cashu_token_encoder.dart'; +import '../../usecases/cashu/cashu_tools.dart'; +import 'cashu_proof.dart'; + +class CashuToken { + final List proofs; + + /// user msg + final String memo; + + final String unit; + + final String mintUrl; + + CashuToken({ + required this.proofs, + required this.memo, + required this.unit, + required this.mintUrl, + }); + + Map toV4Json() { + Map> allProofs = >{}; + + for (final proof in proofs) { + final keysetId = proof.keysetId; + final proofMaps = allProofs.putIfAbsent(keysetId, () => []); + proofMaps.add(proof.toV4Json()); + } + + final proofMap = allProofs.entries + .map((entry) => { + "i": CashuTools.hexToBytes(entry.key), + "p": entry.value, + }) + .toList(); + + return { + 'm': mintUrl, + 'u': unit, + if (memo.isNotEmpty) 'd': memo, + 't': proofMap, + }; + } + + String toV4TokenString() { + return CashuTokenEncoder.encodeTokenV4( + token: this, + ); + } + + factory CashuToken.fromV4Json(Map json) { + final mint = json['m']?.toString() ?? ''; + final unit = json['u']?.toString() ?? ''; + final memo = json['d']?.toString() ?? ''; + final tokensJson = json['t'] ?? []; + + if (tokensJson is! List) { + throw Exception('Invalid token format: "t" should be a list'); + } + + final myProofs = List.empty(growable: true); + + for (final tokenJson in tokensJson) { + final keysetId = tokenJson['i'] as String; + + final proofsJson = tokenJson['p'] as List? ?? []; + + for (final proofJson in proofsJson) { + final myProof = CashuProof.fromV4Json( + json: proofJson as Map, + keysetId: keysetId, + ); + myProofs.add(myProof); + } + } + + return CashuToken( + mintUrl: mint, + proofs: myProofs, + memo: memo, + unit: unit, + ); + } +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_token_event.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_token_event.dart new file mode 100644 index 000000000..211dcaa4d --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_token_event.dart @@ -0,0 +1,42 @@ +import '../nip_01_event.dart'; + +class CashuTokenEvent { + static const int kUnspendProofKind = 7375; + + final String mintUrl; + final Set proofs; + final Set deletedIds; + + late final Nip01Event? nostrEvent; + + CashuTokenEvent({ + required this.mintUrl, + required this.proofs, + required this.deletedIds, + }); +} + +class CashuProof { + final String id; + final int amount; + final String secret; + + /// C unblinded signature + final String unblindedSig; + + CashuProof({ + required this.id, + required this.amount, + required this.secret, + required this.unblindedSig, + }); + + factory CashuProof.fromJson(Map json) { + return CashuProof( + id: json['id'] as String, + amount: json['amount'] as int, + secret: json['secret'] as String, + unblindedSig: json['C'] as String, + ); + } +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_token_event_content.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_token_event_content.dart new file mode 100644 index 000000000..594702163 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_token_event_content.dart @@ -0,0 +1,30 @@ +import 'cashu_token_event.dart'; + +class CashuTokenEventContent { + final String mintUrl; + final List proofs; + final List deletedIds; + + CashuTokenEventContent({ + required this.mintUrl, + required this.proofs, + required this.deletedIds, + }); + + /// extracts data from plain lists + factory CashuTokenEventContent.fromJson( + Map jsonList, + ) { + return CashuTokenEventContent( + mintUrl: jsonList['mint'] as String, + proofs: (jsonList['proofs'] as List) + .map((proofJson) => + CashuProof.fromJson(proofJson as Map)) + .toList(), + deletedIds: (jsonList['del'] as List?) + ?.map((id) => id as String) + .toList() ?? + [], + ); + } +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_token_state_response.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_token_state_response.dart new file mode 100644 index 000000000..4cc54c3ab --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_token_state_response.dart @@ -0,0 +1,21 @@ +import 'cashu_proof.dart'; + +class CashuTokenStateResponse { + final String Y; + final CashuProofState state; + final String? witness; + + CashuTokenStateResponse({ + required this.Y, + required this.state, + this.witness, + }); + + factory CashuTokenStateResponse.fromServerMap(Map json) { + return CashuTokenStateResponse( + Y: json['Y'] as String, + state: CashuProofState.fromValue(json['state'] as String), + witness: json['witness'] as String?, + ); + } +} diff --git a/packages/ndk/lib/domain_layer/entities/cashu/cashu_user_seedphrase.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_user_seedphrase.dart new file mode 100644 index 000000000..c012fe4c8 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_user_seedphrase.dart @@ -0,0 +1,15 @@ +import 'package:bip39_mnemonic/bip39_mnemonic.dart'; +export 'package:bip39_mnemonic/bip39_mnemonic.dart' + show Language, MnemonicLength; + +class CashuUserSeedphrase { + final String seedPhrase; + final Language language; + final String passphrase; + + CashuUserSeedphrase({ + required this.seedPhrase, + this.language = Language.english, + this.passphrase = '', + }); +} diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet.dart new file mode 100644 index 000000000..2707efb9c --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet.dart @@ -0,0 +1,122 @@ +import 'package:ndk/domain_layer/entities/wallet/wallet_balance.dart'; +import 'package:ndk/domain_layer/entities/wallet/wallet_transaction.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../usecases/nwc/nwc_connection.dart'; +import '../cashu/cashu_mint_info.dart'; +import 'wallet_type.dart'; + +/// compatitability layer for generic wallets usecase as well as storage. +/// [metadata] is used to store additional information required for the specific wallet type +abstract class Wallet { + /// local wallet identifier + final String id; + + final WalletType type; + + /// unit like sat, usd, etc. + final Set supportedUnits; + + /// user defined name for the wallet + String name; + + /// metadata to store additional information for the specific wallet type + /// e.g. for Cashu store mintUrl + final Map metadata; + + Wallet({ + required this.id, + required this.name, + required this.type, + required this.supportedUnits, + required this.metadata, + }); + + /// constructs the concrete wallet type based on the type string \ + /// metadata is used to provide additional information required for the wallet type + static Wallet toWalletType({ + required String id, + required String name, + required WalletType type, + required Set supportedUnits, + required Map metadata, + }) { + switch (type) { + case WalletType.CASHU: + final mintUrl = metadata['mintUrl'] as String?; + if (mintUrl == null || mintUrl.isEmpty) { + throw ArgumentError('CashuWallet requires metadata["mintUrl"]'); + } + return CashuWallet( + id: id, + name: name, + type: type, + supportedUnits: supportedUnits, + metadata: metadata, + mintUrl: mintUrl, + mintInfo: CashuMintInfo.fromJson( + metadata['mintInfo'] as Map, + ), + ); + case WalletType.NWC: + final nwcUrl = metadata['nwcUrl'] as String?; + if (nwcUrl == null || nwcUrl.isEmpty) { + throw ArgumentError('NwcWallet requires metadata["nwcUrl"]'); + } + return NwcWallet( + id: id, + name: name, + type: type, + supportedUnits: supportedUnits, + metadata: metadata, + nwcUrl: nwcUrl, + ); + } + } +} + +class CashuWallet extends Wallet { + final String mintUrl; + final CashuMintInfo mintInfo; + + CashuWallet({ + required super.id, + required super.name, + super.type = WalletType.CASHU, + required super.supportedUnits, + required this.mintUrl, + required this.mintInfo, + Map? metadata, + }) : super( + /// update metadata to include mintUrl + metadata: Map.unmodifiable({ + ...(metadata ?? const {}), + 'mintUrl': mintUrl, + 'mintInfo': mintInfo.toJson(), + }), + ); +} + +class NwcWallet extends Wallet { + final String nwcUrl; + NwcConnection? connection; + BehaviorSubject>? balanceSubject; + BehaviorSubject>? transactionsSubject; + BehaviorSubject>? pendingTransactionsSubject; + + bool isConnected() => connection != null; + + NwcWallet({ + required super.id, + required super.name, + super.type = WalletType.NWC, + required super.supportedUnits, + required this.nwcUrl, + Map? metadata, + }) : super( + metadata: Map.unmodifiable({ + ...(metadata ?? const {}), + 'nwcUrl': nwcUrl, + }), + ); +} diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet_balance.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet_balance.dart new file mode 100644 index 000000000..48abeb59f --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet_balance.dart @@ -0,0 +1,11 @@ +class WalletBalance { + final String walletId; + final String unit; + final int amount; + + WalletBalance({ + required this.walletId, + required this.unit, + required this.amount, + }); +} diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart new file mode 100644 index 000000000..3c2790ff0 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart @@ -0,0 +1,253 @@ +import '../cashu/cashu_keyset.dart'; +import '../cashu/cashu_quote.dart'; +import '../cashu/cashu_quote_melt.dart'; +import 'wallet_type.dart'; + +abstract class WalletTransaction { + final String id; + final String walletId; + + /// positive for incoming, negative for outgoing + final int changeAmount; + final String unit; + final WalletType walletType; + final WalletTransactionState state; + final String? completionMsg; + + /// Date in milliseconds since epoch + int? transactionDate; + + /// Date in milliseconds since epoch + int? initiatedDate; + + /// metadata to store additional information for the specific transaction type + final Map metadata; + + WalletTransaction({ + required this.id, + required this.walletId, + required this.changeAmount, + required this.unit, + required this.walletType, + required this.state, + required this.metadata, + this.completionMsg, + this.transactionDate, + this.initiatedDate, + }); + + /// constructs the concrete wallet type based on the type string \ + /// metadata is used to provide additional information required for the wallet type + static WalletTransaction toTransactionType({ + required String id, + required String walletId, + required int changeAmount, + required String unit, + required WalletType walletType, + required WalletTransactionState state, + required Map metadata, + String? completionMsg, + int? transactionDate, + int? initiatedDate, + String? token, + List? proofPubKeys, + }) { + switch (walletType) { + case WalletType.CASHU: + return CashuWalletTransaction( + id: id, + walletId: walletId, + changeAmount: changeAmount, + unit: unit, + walletType: walletType, + state: state, + mintUrl: metadata['mintUrl'] as String, + completionMsg: completionMsg, + transactionDate: transactionDate, + initiatedDate: initiatedDate, + note: metadata['note'] as String?, + method: metadata['method'] as String?, + qoute: metadata['qoute'] != null + ? CashuQuote.fromJson(metadata['qoute'] as Map) + : null, + qouteMelt: metadata['qouteMelt'] != null + ? CashuQuoteMelt.fromJson( + metadata['qouteMelt'] as Map) + : null, + usedKeysets: metadata['usedKeyset'] != null + ? (metadata['usedKeyset'] as List) + .map((k) => CahsuKeyset.fromJson(k as Map)) + .toList() + : null, + token: metadata['token'] as String? ?? token, + proofPubKeys: proofPubKeys, + ); + case WalletType.NWC: + return NwcWalletTransaction( + id: id, + walletId: walletId, + changeAmount: changeAmount, + unit: unit, + walletType: walletType, + state: state, + metadata: metadata, + completionMsg: completionMsg, + transactionDate: transactionDate, + initiatedDate: initiatedDate, + ); + } + } +} + +class CashuWalletTransaction extends WalletTransaction { + String mintUrl; + String? note; + String? method; + CashuQuote? qoute; + CashuQuoteMelt? qouteMelt; + List? usedKeysets; + + String? token; + + List? proofPubKeys; + + CashuWalletTransaction({ + required super.id, + required super.walletId, + required super.changeAmount, + required super.unit, + required super.walletType, + required super.state, + required this.mintUrl, + super.completionMsg, + super.transactionDate, + super.initiatedDate, + this.note, + this.method, + this.qoute, + this.qouteMelt, + this.usedKeysets, + this.token, + this.proofPubKeys, + Map? metadata, + }) : super( + metadata: metadata ?? + { + 'mintUrl': mintUrl, + 'note': note, + 'method': method, + 'qoute': qoute?.toJson(), + 'qouteMelt': qouteMelt?.toJson(), + 'usedKeyset': usedKeysets?.map((k) => k.toJson()).toList(), + 'token': token, + 'proofPubKeys': proofPubKeys, + }, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CashuWalletTransaction && + runtimeType == other.runtimeType && + id == other.id && + token == other.token; + + @override + int get hashCode => id.hashCode; + + CashuWalletTransaction copyWith({ + String? id, + String? walletId, + int? changeAmount, + String? unit, + WalletType? walletType, + WalletTransactionState? state, + String? mintUrl, + String? note, + String? method, + CashuQuote? qoute, + CashuQuoteMelt? qouteMelt, + List? usedKeysets, + int? transactionDate, + int? initiatedDate, + String? completionMsg, + String? token, + List? proofPubKeys, + }) { + return CashuWalletTransaction( + id: id ?? this.id, + walletId: walletId ?? this.walletId, + changeAmount: changeAmount ?? this.changeAmount, + unit: unit ?? this.unit, + walletType: walletType ?? this.walletType, + state: state ?? this.state, + mintUrl: mintUrl ?? this.mintUrl, + note: note ?? this.note, + method: method ?? this.method, + qoute: qoute ?? this.qoute, + qouteMelt: qouteMelt ?? this.qouteMelt, + usedKeysets: usedKeysets ?? this.usedKeysets, + transactionDate: transactionDate ?? this.transactionDate, + initiatedDate: initiatedDate ?? this.initiatedDate, + completionMsg: completionMsg ?? this.completionMsg, + token: token ?? this.token, + proofPubKeys: proofPubKeys ?? this.proofPubKeys, + ); + } +} + +class NwcWalletTransaction extends WalletTransaction { + NwcWalletTransaction({ + required super.id, + required super.walletId, + required super.changeAmount, + required super.unit, + required super.walletType, + required super.state, + required super.metadata, + super.completionMsg, + super.transactionDate, + super.initiatedDate, + }); +} + +enum WalletTransactionState { + /// pending states + + /// draft requires user confirmation + draft('DRAFT'), + + /// payment is in flight + pending('PENDING'), + + /// done states + /// transaction went through + completed('SUCCESS'), + + /// canceld by user - usually a canceld draft, or not sufficient funds + canceled('CANCELED'), + + /// transaction failed + failed('FAILED'); + + bool get isPending => this == draft || this == pending; + + bool get isDone => this == completed || this == canceled || this == failed; + + final String value; + + const WalletTransactionState(this.value); + + factory WalletTransactionState.fromValue(String value) { + return WalletTransactionState.values.firstWhere( + (state) => state.value == value, + orElse: () => + throw ArgumentError('Invalid pending transaction state: $value'), + ); + } + + @override + String toString() { + return value; + } +} diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet_type.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet_type.dart new file mode 100644 index 000000000..81e112b5a --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet_type.dart @@ -0,0 +1,20 @@ +enum WalletType { + // ignore: constant_identifier_names + NWC('nwc'), + // ignore: constant_identifier_names + CASHU('cashu'); + + final String value; + + const WalletType(this.value); + + factory WalletType.fromValue(String value) { + return WalletType.values.firstWhere( + (kind) => kind.value == value, + orElse: () => throw ArgumentError('Invalid event kind value: $value'), + ); + } + + @override + String toString() => value; +} diff --git a/packages/ndk/lib/domain_layer/repositories/cache_manager.dart b/packages/ndk/lib/domain_layer/repositories/cache_manager.dart index 227a9ae54..20dc32f86 100644 --- a/packages/ndk/lib/domain_layer/repositories/cache_manager.dart +++ b/packages/ndk/lib/domain_layer/repositories/cache_manager.dart @@ -1,9 +1,15 @@ +import '../entities/cashu/cashu_keyset.dart'; +import '../entities/cashu/cashu_mint_info.dart'; +import '../entities/cashu/cashu_proof.dart'; import '../entities/contact_list.dart'; import '../entities/nip_01_event.dart'; import '../entities/nip_05.dart'; import '../entities/relay_set.dart'; import '../entities/user_relay_list.dart'; import '../entities/metadata.dart'; +import '../entities/wallet/wallet.dart'; +import '../entities/wallet/wallet_transaction.dart'; +import '../entities/wallet/wallet_type.dart'; abstract class CacheManager { /// closes the cache manger \ @@ -79,4 +85,72 @@ abstract class CacheManager { Future> loadNip05s(List pubKeys); Future removeNip05(String pubKey); Future removeAllNip05s(); + + /// wallets methods + + Future saveWallet(Wallet wallet); + + Future removeWallet(String id); + + /// return all if [ids] is null + Future?> getWallets({List? ids}); + + Future> getTransactions({ + int? limit, + int? offset, + String? walletId, + String? unit, + WalletType? walletType, + }); + + /// upserts transactions \ + /// if transaction with same id exists, it will be updated + Future saveTransactions({ + required List transactions, + }); + + /// cashu methods + + Future saveKeyset(CahsuKeyset keyset); + + /// get all keysets if no mintUrl is provided \ + Future> getKeysets({ + String? mintUrl, + }); + + Future saveProofs({ + required List proofs, + required String mintUrl, + }); + + Future> getProofs({ + String? mintUrl, + String? keysetId, + CashuProofState state = CashuProofState.unspend, + }); + + Future removeProofs({ + required List proofs, + required String mintUrl, + }); + + Future saveMintInfo({ + required CashuMintInfo mintInfo, + }); + + /// return all if no mintUrls are provided + Future?> getMintInfos({ + List? mintUrls, + }); + + Future getCashuSecretCounter({ + required String mintUrl, + required String keysetId, + }); + + Future setCashuSecretCounter({ + required String mintUrl, + required String keysetId, + required int counter, + }); } diff --git a/packages/ndk/lib/domain_layer/repositories/cashu_repo.dart b/packages/ndk/lib/domain_layer/repositories/cashu_repo.dart new file mode 100644 index 000000000..41e64e73b --- /dev/null +++ b/packages/ndk/lib/domain_layer/repositories/cashu_repo.dart @@ -0,0 +1,93 @@ +import '../entities/cashu/cashu_keyset.dart'; +import '../entities/cashu/cashu_blinded_message.dart'; +import '../entities/cashu/cashu_blinded_signature.dart'; +import '../entities/cashu/cashu_melt_response.dart'; +import '../entities/cashu/cashu_mint_info.dart'; +import '../entities/cashu/cashu_proof.dart'; +import '../entities/cashu/cashu_quote.dart'; +import '../entities/cashu/cashu_quote_melt.dart'; +import '../entities/cashu/cashu_token_state_response.dart'; +import '../usecases/cashu/cashu_keypair.dart'; + +abstract class CashuRepo { + Future> swap({ + required String mintUrl, + required List proofs, + required List outputs, + }); + + Future> getKeysets({ + required String mintUrl, + }); + + Future> getKeys({ + required String mintUrl, + String? keysetId, + }); + + Future getMintQuote({ + required String mintUrl, + required int amount, + required String unit, + required String method, + String description = '', + }); + + Future checkMintQuoteState({ + required String mintUrl, + required String quoteID, + required String method, + }); + + Future> mintTokens({ + required String mintUrl, + required String quote, + required List blindedMessagesOutputs, + required String method, + required CashuKeypair quoteKey, + }); + + /// [mintUrl] is the URL of the mint \ + /// [request] is usually a lightning invoice \ + /// [unit] is usually 'sat' \ + /// [method] is usually 'bolt11' \ + /// Returns a [CashuQuoteMelt] object containing the melt quote details. + Future getMeltQuote({ + required String mintUrl, + required String request, + required String unit, + required String method, + }); + + /// [mintUrl] is the URL of the mint \ + /// [quoteID] is the ID of the melt quote \ + /// [method] is usually 'bolt11' \ + /// Returns a [CashuQuoteMelt] object containing the melt quote details. + Future checkMeltQuoteState({ + required String mintUrl, + required String quoteID, + required String method, + }); + + /// [mintUrl] is the URL of the mint \ + /// [quoteId] is the ID of the melt quote \ + /// [proofs] is a list of [CashuProof] inputs \ + /// [outputs] is a list of blank! [CashuBlindedMessage] outputs \ + /// Returns a [CashuMeltResponse] object containing the melt response details. + Future meltTokens({ + required String mintUrl, + required String quoteId, + required List proofs, + required List outputs, + required String method, + }); + + Future getMintInfo({ + required String mintUrl, + }); + + Future> checkTokenState({ + required List proofPubkeys, + required String mintUrl, + }); +} diff --git a/packages/ndk/lib/domain_layer/repositories/wallets_operations_repo.dart b/packages/ndk/lib/domain_layer/repositories/wallets_operations_repo.dart new file mode 100644 index 000000000..6eb423015 --- /dev/null +++ b/packages/ndk/lib/domain_layer/repositories/wallets_operations_repo.dart @@ -0,0 +1,7 @@ +/// Repository to glue the specific wallet implementations to common operations \ +/// available on all wallets. +abstract class WalletsOperationsRepo { + /// todo: + /// just to get an idea what this repo should do + Future zap(); +} diff --git a/packages/ndk/lib/domain_layer/repositories/wallets_repo.dart b/packages/ndk/lib/domain_layer/repositories/wallets_repo.dart new file mode 100644 index 000000000..8a81f1e6f --- /dev/null +++ b/packages/ndk/lib/domain_layer/repositories/wallets_repo.dart @@ -0,0 +1,25 @@ +import '../entities/wallet/wallet.dart'; +import '../entities/wallet/wallet_balance.dart'; +import '../entities/wallet/wallet_transaction.dart'; +import '../entities/wallet/wallet_type.dart'; + +abstract class WalletsRepo { + Future> getWallets(); + Future getWallet(String id); + Future addWallet(Wallet account); + Future removeWallet(String id); + + Stream> getBalancesStream(String id); + Stream> getPendingTransactionsStream( + String id); + Stream> getRecentTransactionsStream(String id); + + Future> getTransactions({ + int? limit, + int? offset, + String? walletId, + String? unit, + WalletType? walletType, + }); + Stream> walletsUsecaseStream(); +} diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart new file mode 100644 index 000000000..43903adb9 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart @@ -0,0 +1,1126 @@ +import 'package:rxdart/rxdart.dart'; + +import '../../../config/cashu_config.dart'; +import '../../../shared/logger/logger.dart'; +import '../../../shared/nips/nip01/helpers.dart'; +import '../../entities/cashu/cashu_blinded_message.dart'; +import '../../entities/cashu/cashu_blinded_signature.dart'; +import '../../entities/cashu/cashu_mint_balance.dart'; +import '../../entities/cashu/cashu_mint_info.dart'; +import '../../entities/cashu/cashu_proof.dart'; +import '../../entities/cashu/cashu_quote.dart'; +import '../../entities/cashu/cashu_spending_result.dart'; +import '../../entities/cashu/cashu_token.dart'; +import '../../entities/cashu/cashu_user_seedphrase.dart'; +import '../../entities/wallet/wallet_transaction.dart'; +import '../../entities/wallet/wallet_type.dart'; +import '../../repositories/cache_manager.dart'; +import '../../repositories/cashu_repo.dart'; +import 'cashu_bdhke.dart'; +import 'cashu_cache_decorator.dart'; +import 'cashu_keysets.dart'; + +import 'cashu_seed.dart'; +import 'cashu_token_encoder.dart'; +import 'cashu_tools.dart'; +import 'cashu_proof_select.dart'; + +class Cashu { + final CashuRepo _cashuRepo; + final CacheManager _cacheManager; + late final CashuCacheDecorator _cacheManagerCashu; + + late final CashuKeysets _cashuKeysets; + late final CashuProofSelect _cashuWalletProofSelect; + + late final CashuSeed _cashuSeed; + + Cashu({ + required CashuRepo cashuRepo, + required CacheManager cacheManager, + CashuUserSeedphrase? cashuUserSeedphrase, + }) : _cashuRepo = cashuRepo, + _cacheManager = cacheManager { + _cashuKeysets = CashuKeysets( + cashuRepo: _cashuRepo, + cacheManager: _cacheManager, + ); + _cashuWalletProofSelect = CashuProofSelect( + cashuRepo: _cashuRepo, + ); + _cacheManagerCashu = CashuCacheDecorator(cacheManager: _cacheManager); + + _cashuSeed = CashuSeed( + userSeedPhrase: cashuUserSeedphrase, + ); + if (cashuUserSeedphrase == null) { + Logger.log.w( + 'Cashu initialized without user seed phrase, cashu features will not work \nSet the seed phrase using NdkConfig or Cashu.setCashuSeedPhrase()'); + } + } + + /// mints this usecase has interacted with \ + ///? does not mark trusted mints! + final Set _knownMints = {}; + + BehaviorSubject>? _knownMintsSubject; + + final List _latestTransactions = []; + + BehaviorSubject>? _latestTransactionsSubject; + + final Set _pendingTransactions = {}; + final BehaviorSubject> + _pendingTransactionsSubject = + BehaviorSubject>.seeded([]); + + /// stream of balances \ + BehaviorSubject>? _balanceSubject; + + /// set cashu user seed phrase, required for using cashu features \ + /// ideally use the NdkConfig to set the seed phrase on initialization \ + /// you can use CashuSeed.generateSeedPhrase() to generate a new seed phrase + void setCashuSeedPhrase(CashuUserSeedphrase userSeedPhrase) { + _cashuSeed.setSeedPhrase( + seedPhrase: userSeedPhrase.seedPhrase, + ); + } + + Future getBalanceMintUnit({ + required String unit, + required String mintUrl, + }) async { + final proofs = await _cacheManagerCashu.getProofs(mintUrl: mintUrl); + final filteredProofs = CashuTools.filterProofsByUnit( + proofs: proofs, + unit: unit, + keysets: await _cashuKeysets.getKeysetsFromMint(mintUrl), + ); + + return CashuTools.sumOfProofs(proofs: filteredProofs); + } + + /// get balances for all mints \ + Future> getBalances({ + bool returnZeroValues = true, + }) async { + final allProofs = await _cacheManagerCashu.getProofs(); + final allKeysets = await _cacheManagerCashu.getKeysets(); + // {"mintUrl": {unit: balance}} + final balances = >{}; + + final distinctKeysetIds = allKeysets.map((keyset) => keyset.id).toSet(); + + for (final keysetId in distinctKeysetIds) { + final mintUrl = + allKeysets.firstWhere((keyset) => keyset.id == keysetId).mintUrl; + if (!balances.containsKey(mintUrl)) { + balances[mintUrl] = {}; + } + + final keysetProofs = + allProofs.where((proof) => proof.keysetId == keysetId).toList(); + + if (!returnZeroValues && keysetProofs.isEmpty) { + continue; + } + + final unit = + allKeysets.firstWhere((keyset) => keyset.id == keysetId).unit; + final totalBalanceForKeyset = CashuTools.sumOfProofs( + proofs: keysetProofs, + ); + + if (totalBalanceForKeyset >= 0) { + balances[mintUrl]![unit] = + totalBalanceForKeyset + (balances[mintUrl]![unit] ?? 0); + } + } + final mintBalances = balances.entries + .map((entry) => CashuMintBalance( + mintUrl: entry.key, + balances: entry.value, + )) + .toList(); + return mintBalances; + } + + Future _updateBalances() async { + final balances = await getBalances(); + _balanceSubject ??= + BehaviorSubject>.seeded(balances); + _balanceSubject!.add(balances); + } + + /// list of balances for all mints + BehaviorSubject> get balances { + if (_balanceSubject == null) { + _balanceSubject = BehaviorSubject>.seeded([]); + + getBalances().then((balances) { + _balanceSubject?.add(balances); + }).catchError((error) { + _balanceSubject?.addError(error); + }); + } + + return _balanceSubject!; + } + + /// list of the latest transactions + BehaviorSubject> get latestTransactions { + if (_latestTransactionsSubject == null) { + _latestTransactionsSubject = + BehaviorSubject>.seeded( + _latestTransactions, + ); + _getLatestTransactionsDb().then((transactions) { + _latestTransactions.clear(); + _latestTransactions.addAll(transactions); + _latestTransactionsSubject?.add(_latestTransactions); + }).catchError((error) { + _latestTransactionsSubject?.addError( + Exception('Failed to load latest transactions: $error'), + ); + }); + } + + return _latestTransactionsSubject!; + } + + /// pending transactions that are not yet completed \ + /// e.g. funding transactions + BehaviorSubject> get pendingTransactions { + return _pendingTransactionsSubject; + } + + /// mints this usecase has interacted with \ + ///? does not mark trusted mints! + BehaviorSubject> get knownMints { + if (_knownMintsSubject == null) { + _knownMintsSubject = BehaviorSubject>.seeded( + _knownMints, + ); + _getMintInfosDb().then((mintInfos) { + _knownMints.clear(); + _knownMints.addAll(mintInfos); + _knownMintsSubject?.add(_knownMints); + }).catchError((error) { + _knownMintsSubject?.addError( + Exception('Failed to load known mints: $error'), + ); + }); + } + + return _knownMintsSubject!; + } + + Future> _getLatestTransactionsDb({ + int limit = 10, + }) async { + final transactions = await _cacheManagerCashu.getTransactions( + limit: limit, + ); + + final fTransactions = + transactions.whereType().toList(); + + return fTransactions; + } + + Future> _getMintInfosDb() async { + final mintInfos = await _cacheManager.getMintInfos(); + if (mintInfos == null) { + return []; + } + return mintInfos; + } + + /// get mint info from network \ + /// [mintUrl] is the URL of the mint \ + /// Returns a [CashuMintInfo] object containing the mint details. + /// throws if the mint info cannot be fetched + Future getMintInfoNetwork({ + required String mintUrl, + }) { + return _cashuRepo.getMintInfo(mintUrl: mintUrl); + } + + /// checks if the mint can be fetched \ + /// and adds it to known mints \ + /// [mintUrl] is the URL of the mint \ + /// Returns true if the mint was added to known mints, false otherwise (already known). + /// Throws if the mint info cannot be fetched + Future addMintToKnownMints({ + required String mintUrl, + }) async { + final result = await _checkIfMintIsKnown(mintUrl); + return !result; + } + + /// check if mint is known \ + /// if not, it will be added to the known mints \ + /// Returns true if mint is known, false otherwise + Future _checkIfMintIsKnown(String mintUrl) async { + final mintInfos = await _cacheManager.getMintInfos( + mintUrls: [mintUrl], + ); + + if (mintInfos == null || mintInfos.isEmpty) { + // fetch mint info from network + final mintInfoNetwork = await _cashuRepo.getMintInfo(mintUrl: mintUrl); + + await _cacheManager.saveMintInfo(mintInfo: mintInfoNetwork); + _knownMints.add(mintInfoNetwork); + _knownMintsSubject?.add(_knownMints); + return false; + } + return true; + } + + /// initiate funding e.g. minting tokens \ + /// [mintUrl] - URL of the mint to fund from \ + /// [amount] - amount to fund \ + /// [unit] - unit of the amount (e.g. sat) \ + /// [method] - payment method (e.g. bolt11) \ + /// Returns a [CashuWalletTransaction] draft transaction that can be used to track the funding process. + /// Throws if there are no keysets available + Future initiateFund({ + required String mintUrl, + required int amount, + required String unit, + required String method, + String? memo, + }) async { + await _checkIfMintIsKnown(mintUrl); + final keysets = await _cashuKeysets.getKeysetsFromMint(mintUrl); + + if (keysets.isEmpty) { + throw Exception('No keysets found for mint: $mintUrl'); + } + + final keyset = CashuTools.filterKeysetsByUnitActive( + keysets: keysets, + unit: unit, + ); + + final quote = await _cashuRepo.getMintQuote( + mintUrl: mintUrl, + amount: amount, + unit: unit, + method: method, + description: memo ?? '', + ); + + CashuWalletTransaction draftTransaction = CashuWalletTransaction( + id: quote.quoteId, //todo use a better id + mintUrl: mintUrl, + walletId: mintUrl, + changeAmount: amount, + unit: unit, + walletType: WalletType.CASHU, + state: WalletTransactionState.draft, + initiatedDate: DateTime.now().millisecondsSinceEpoch ~/ 1000, + qoute: quote, + usedKeysets: [keyset], + method: method, + ); + + // add to pending transactions + _pendingTransactions.add(draftTransaction); + _pendingTransactionsSubject.add(_pendingTransactions.toList()); + + return draftTransaction; + } + + /// retrieve funds from a pending funding transaction \ + /// [draftTransaction] - the draft transaction from initiateFund() \ + /// Returns a stream of [CashuWalletTransaction] that emits the transaction state as it progresses. + /// Throws if the draft transaction is missing required fields. + Stream retrieveFunds({ + required CashuWalletTransaction draftTransaction, + }) async* { + if (draftTransaction.qoute == null) { + throw Exception("Quote is not available in the transaction"); + } + if (draftTransaction.method == null) { + throw Exception("Method is not specified in the transaction"); + } + if (draftTransaction.usedKeysets == null) { + throw Exception("Used keysets is not specified in the transaction"); + } + final quote = draftTransaction.qoute!; + final mintUrl = draftTransaction.mintUrl; + + await _checkIfMintIsKnown(mintUrl); + + CashuQuoteState payStatus; + + final pendingTransaction = draftTransaction.copyWith( + state: WalletTransactionState.pending, + ); + + // update pending transactions + _pendingTransactions.add(pendingTransaction); + _pendingTransactionsSubject.add(_pendingTransactions.toList()); + yield pendingTransaction; + + while (true) { + payStatus = await _cashuRepo.checkMintQuoteState( + mintUrl: mintUrl, + quoteID: quote.quoteId, + method: draftTransaction.method!, + ); + + if (payStatus == CashuQuoteState.paid) { + break; + } + + // check if quote has expired + final currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (currentTime >= quote.expiry) { + final expiredTransaction = pendingTransaction.copyWith( + state: WalletTransactionState.failed, + completionMsg: 'Quote expired before payment was received', + ); + yield expiredTransaction; + // remove expired transaction + _removePendingTransaction(expiredTransaction); + Logger.log.w('Quote expired before payment was received'); + return; + } + + await Future.delayed(CashuConfig.FUNDING_CHECK_INTERVAL); + } + + List splittedAmounts = CashuTools.splitAmount(quote.amount); + final blindedMessagesOutputs = await CashuBdhke.createBlindedMsgForAmounts( + keysetId: draftTransaction.usedKeysets!.first.id, + amounts: splittedAmounts, + cacheManager: _cacheManagerCashu, + cashuSeed: _cashuSeed, + mintUrl: mintUrl, + ); + + final mintResponse = await _cashuRepo.mintTokens( + mintUrl: mintUrl, + quote: quote.quoteId, + blindedMessagesOutputs: blindedMessagesOutputs + .map( + (e) => CashuBlindedMessage( + amount: e.amount, + id: e.blindedMessage.id, + blindedMessage: e.blindedMessage.blindedMessage), + ) + .toList(), + method: draftTransaction.method!, + quoteKey: quote.quoteKey, + ); + + if (mintResponse.isEmpty) { + final failedTransaction = pendingTransaction.copyWith( + state: WalletTransactionState.failed, + completionMsg: 'Minting failed, no signatures returned', + ); + // remove expired transaction + + _removePendingTransaction(failedTransaction); + yield failedTransaction; + throw Exception('Minting failed, no signatures returned'); + } + + // unblind + final unblindedTokens = CashuBdhke.unblindSignatures( + mintSignatures: mintResponse, + blindedMessages: blindedMessagesOutputs, + mintPublicKeys: draftTransaction.usedKeysets!.first, + ); + if (unblindedTokens.isEmpty) { + final failedTransaction = pendingTransaction.copyWith( + state: WalletTransactionState.failed, + completionMsg: 'Unblinding failed, no tokens returned', + ); + // remove expired transaction + _removePendingTransaction(failedTransaction); + yield failedTransaction; + throw Exception('Unblinding failed, no tokens returned'); + } + await _cacheManagerCashu.saveProofs( + proofs: unblindedTokens, + mintUrl: mintUrl, + ); + + final completedTransaction = pendingTransaction.copyWith( + state: WalletTransactionState.completed, + transactionDate: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + + // remove completed transaction + _removePendingTransaction(completedTransaction); + + // save completed transaction + await _cacheManagerCashu + .saveTransactions(transactions: [completedTransaction]); + + // add to latest transactions + _latestTransactions.add(completedTransaction); + _latestTransactionsSubject?.add(_latestTransactions); + + // update balance + await _updateBalances(); + + yield completedTransaction; + } + + /// redeem token for x (usually lightning) + /// [mintUrl] - URL of the mint + /// [request] - the method request to redeem (like lightning invoice) + /// [unit] - the unit of the token (sat) + /// [method] - the method to use for redemption (bolt11) + /// Returns a [CashuWalletTransaction] with info about fees. \ + /// use redeem() to complete the redeem process. + Future initiateRedeem({ + required String mintUrl, + required String request, + required String unit, + required String method, + }) async { + final meltQuote = await _cashuRepo.getMeltQuote( + mintUrl: mintUrl, + request: request, + unit: unit, + method: method, + ); + + final draftTransaction = CashuWalletTransaction( + id: meltQuote.quoteId, + walletId: mintUrl, + changeAmount: -1 * meltQuote.amount, + unit: unit, + walletType: WalletType.CASHU, + state: WalletTransactionState.draft, + mintUrl: mintUrl, + qouteMelt: meltQuote, + method: method, + initiatedDate: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + return draftTransaction; + } + + /// redeem tokens from a pending redeem transaction \ + /// use initiateRedeem() to create a draft transaction [CashuWalletTransaction] \ + Stream redeem({ + required CashuWalletTransaction draftRedeemTransaction, + }) async* { + if (draftRedeemTransaction.qouteMelt == null) { + throw Exception("Melt Quote is not available in the transaction"); + } + final meltQuote = draftRedeemTransaction.qouteMelt!; + final mintUrl = draftRedeemTransaction.mintUrl; + if (mintUrl.isEmpty) { + throw Exception("Mint URL is not specified in the transaction"); + } + if (draftRedeemTransaction.method == null) { + throw Exception("Method is not specified in the transaction"); + } + final method = draftRedeemTransaction.method!; + await _checkIfMintIsKnown(mintUrl); + + final unit = draftRedeemTransaction.unit; + if (unit.isEmpty) { + throw Exception("Unit is not specified in the transaction"); + } + final request = meltQuote.request; + if (request.isEmpty) { + throw Exception("Request is not specified in the transaction"); + } + + final mintKeysets = await _cashuKeysets.getKeysetsFromMint(mintUrl); + if (mintKeysets.isEmpty) { + throw Exception('No keysets found for mint: $mintUrl'); + } + + final keysetsForUnit = + CashuTools.filterKeysetsByUnit(keysets: mintKeysets, unit: unit); + + final int amountToSpend; + + if (meltQuote.feeReserve != null) { + amountToSpend = meltQuote.amount + meltQuote.feeReserve!; + } else { + amountToSpend = meltQuote.amount; + } + + late final ProofSelectionResult selectionResult; + + await _cacheManagerCashu.runInTransaction(() async { + final proofsUnfiltered = await _cacheManager.getProofs( + mintUrl: mintUrl, + ); + + final proofs = CashuTools.filterProofsByUnit( + proofs: proofsUnfiltered, unit: unit, keysets: keysetsForUnit); + + if (proofs.isEmpty) { + throw Exception('No proofs found for mint: $mintUrl and unit: $unit'); + } + + selectionResult = CashuProofSelect.selectProofsForSpending( + proofs: proofs, + targetAmount: amountToSpend, + keysets: keysetsForUnit, + ); + + _changeProofState( + proofs: selectionResult.selectedProofs, + state: CashuProofState.pending, + ); + + await _cacheManager.saveProofs( + proofs: selectionResult.selectedProofs, + mintUrl: mintUrl, + ); + }); + + final activeKeyset = + CashuTools.filterKeysetsByUnitActive(keysets: mintKeysets, unit: unit); + + /// outputs to send to mint + final List myOutputs = []; + + /// we dont have the exact amount + if (selectionResult.needsSplit) { + final blindedMessagesOutputsOverpay = + await CashuBdhke.createBlindedMsgForAmounts( + keysetId: activeKeyset.id, + amounts: CashuTools.splitAmount(selectionResult.splitAmount), + cacheManager: _cacheManagerCashu, + cashuSeed: _cashuSeed, + mintUrl: mintUrl, + ); + myOutputs.addAll( + blindedMessagesOutputsOverpay, + ); + } + + /// blank outputs for (lightning) fee reserve + if (meltQuote.feeReserve != null) { + final numBlankOutputs = + CashuTools.calculateNumberOfBlankOutputs(meltQuote.feeReserve!); + + final blankOutputs = await CashuBdhke.createBlindedMsgForAmounts( + keysetId: activeKeyset.id, + amounts: List.generate(numBlankOutputs, (_) => 0), + cacheManager: _cacheManagerCashu, + cashuSeed: _cashuSeed, + mintUrl: mintUrl, + ); + myOutputs.addAll(blankOutputs); + } + + myOutputs.sort( + (a, b) => b.amount.compareTo(a.amount)); // sort outputs by amount desc + + final pendingTransaction = draftRedeemTransaction.copyWith( + state: WalletTransactionState.pending, + ); + _addPendingTransaction(pendingTransaction); + yield pendingTransaction; + + try { + final meltResult = await _cashuRepo.meltTokens( + mintUrl: mintUrl, + quoteId: meltQuote.quoteId, + proofs: selectionResult.selectedProofs, + outputs: myOutputs + .map( + (e) => CashuBlindedMessage( + amount: e.amount, + id: e.blindedMessage.id, + blindedMessage: e.blindedMessage.blindedMessage, + ), + ) + .toList(), + method: method, + ); + + /// mark used proofs as spent + _changeProofState( + proofs: selectionResult.selectedProofs, + state: CashuProofState.spend, + ); + await _cacheManager.saveProofs( + proofs: selectionResult.selectedProofs, + mintUrl: mintUrl, + ); + + /// save change proofs if any + if (meltResult.change.isNotEmpty) { + /// unblind change proofs + final changeUnblinded = CashuBdhke.unblindSignatures( + mintSignatures: meltResult.change, + blindedMessages: myOutputs, + mintPublicKeys: activeKeyset, + ); + + await _cacheManagerCashu.saveProofs( + proofs: changeUnblinded, + mintUrl: mintUrl, + ); + } + + final completedTransaction = pendingTransaction.copyWith( + state: WalletTransactionState.completed, + transactionDate: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + // remove completed transaction + _removePendingTransaction(completedTransaction); + // save completed transaction + _addAndSaveLatestTransaction(completedTransaction); + + // update balance + await _updateBalances(); + yield completedTransaction; + } catch (e) { + final failedTransaction = pendingTransaction.copyWith( + state: WalletTransactionState.failed, + completionMsg: 'Redeeming failed: $e', + ); + + // release proofs + _changeProofState( + proofs: selectionResult.selectedProofs, + state: CashuProofState.unspend, + ); + await _cacheManagerCashu.saveProofs( + proofs: selectionResult.selectedProofs, + mintUrl: mintUrl, + ); + + _removePendingTransaction(failedTransaction); + yield failedTransaction; + return; + } + } + + Future initiateSpend({ + required String mintUrl, + required int amount, + required String unit, + String? memo, + }) async { + if (amount <= 0) { + throw Exception('Amount must be greater than zero'); + } + + final allKeysets = await _cashuKeysets.getKeysetsFromMint(mintUrl); + if (allKeysets.isEmpty) { + throw Exception('No keysets found for mint: $mintUrl'); + } + + final keysetsForUnit = CashuTools.filterKeysetsByUnit( + keysets: allKeysets, + unit: unit, + ); + + late final ProofSelectionResult selectionResult; + + await _cacheManagerCashu.runInTransaction( + () async { + // fetch proofs for the mint + final allProofs = await _cacheManager.getProofs( + mintUrl: mintUrl, + ); + + final proofsForUnit = CashuTools.filterProofsByUnit( + proofs: allProofs, + unit: unit, + keysets: allKeysets, + ); + if (proofsForUnit.isEmpty) { + throw Exception('No proofs found for mint: $mintUrl and unit: $unit'); + } + + // select proofs for spending + selectionResult = CashuProofSelect.selectProofsForSpending( + proofs: proofsForUnit, + targetAmount: amount, + keysets: keysetsForUnit, + ); + + if (selectionResult.selectedProofs.isEmpty) { + throw Exception('Not enough funds to spend the requested amount'); + } + + Logger.log.d( + 'Selected ${selectionResult.selectedProofs.length} proofs for spending, total: ${selectionResult.totalSelected} $unit'); + + // mark proofs as pending + _changeProofState( + proofs: selectionResult.selectedProofs, + state: CashuProofState.pending, + ); + + await _cacheManager.saveProofs( + proofs: selectionResult.selectedProofs, + mintUrl: mintUrl, + ); + }, + ); + + final transactionId = "spend-${Helpers.getRandomString(5)}"; + + CashuWalletTransaction pendingTransaction = CashuWalletTransaction( + id: transactionId, + mintUrl: mintUrl, + walletId: mintUrl, + changeAmount: -1 * amount, + unit: unit, + walletType: WalletType.CASHU, + state: WalletTransactionState.pending, + initiatedDate: DateTime.now().millisecondsSinceEpoch ~/ 1000, + usedKeysets: keysetsForUnit, + ); + // add to pending transactions + + _addPendingTransaction(pendingTransaction); + Logger.log.d( + 'Initiated spend for $amount $unit from mint $mintUrl, using ${selectionResult.selectedProofs.length} proofs'); + + final List proofsToReturn; + + // split so we get exact change + if (selectionResult.needsSplit) { + Logger.log.d( + 'Need to split ${selectionResult.splitAmount} $unit from ${selectionResult.totalSelected} total'); + + final SplitResult splitResult; + try { + // split to get exact change + splitResult = await _cashuWalletProofSelect.performSplit( + mint: mintUrl, + proofsToSplit: selectionResult.selectedProofs, + targetAmount: amount, + changeAmount: selectionResult.splitAmount, + keysets: keysetsForUnit, + cacheManagerCashu: _cacheManagerCashu, + cashuSeed: _cashuSeed, + ); + } catch (e) { + _changeProofState( + proofs: selectionResult.selectedProofs, + state: CashuProofState.unspend, + ); + + // update proofs so they can be used again + await _cacheManagerCashu.saveProofs( + proofs: selectionResult.selectedProofs, + mintUrl: mintUrl, + ); + + _removePendingTransaction(pendingTransaction); + // mark transaction as failed + final completedTransaction = pendingTransaction.copyWith( + state: WalletTransactionState.failed, + transactionDate: DateTime.now().millisecondsSinceEpoch ~/ 1000, + completionMsg: 'Failed to swap proofs to get exact change: $e', + ); + await _addAndSaveLatestTransaction(completedTransaction); + + Logger.log.e('Error during spend initiation: $e'); + throw Exception('Spend initiation failed: $e'); + } + + // save change proofs + await _cacheManagerCashu.saveProofs( + proofs: splitResult.changeProofs, + mintUrl: mintUrl, + ); + + proofsToReturn = splitResult.exactProofs; + } else { + proofsToReturn = selectionResult.selectedProofs; + Logger.log.d('No split needed, using selected proofs directly'); + } + + /// mark proofs as spent + _changeProofState( + proofs: selectionResult.selectedProofs, + state: CashuProofState.spend, + ); + + /// update proofs in cache + await _cacheManagerCashu.saveProofs( + proofs: selectionResult.selectedProofs, + mintUrl: mintUrl, + ); + + pendingTransaction = pendingTransaction.copyWith( + proofPubKeys: proofsToReturn.map((e) => e.Y).toList(), + ); + _addPendingTransaction(pendingTransaction); + + await _updateBalances(); + + _checkSpendingState( + transaction: pendingTransaction, + ); + + final token = proofsToToken( + proofs: proofsToReturn, + mintUrl: mintUrl, + unit: unit, + memo: memo ?? '', + ); + + pendingTransaction = pendingTransaction.copyWith( + token: token.toV4TokenString(), + ); + _addPendingTransaction(pendingTransaction); + + return CashuSpendingResult( + token: token, + transaction: pendingTransaction, + ); + } + + /// todo: restore pending transaction from cache + /// todo: recover funds + /// todo: timeout + void _checkSpendingState({ + required CashuWalletTransaction transaction, + }) async { + if (transaction.proofPubKeys == null || transaction.proofPubKeys!.isEmpty) { + throw Exception('No proof public keys provided for checking state'); + } + + while (true) { + final checkResult = await _cashuRepo.checkTokenState( + proofPubkeys: transaction.proofPubKeys!, + mintUrl: transaction.mintUrl, + ); + + /// check that all proofs are spent + if (checkResult.every((e) => e.state == CashuProofState.spend)) { + Logger.log.d('All proofs are spent for transaction ${transaction.id}'); + final completedTransaction = transaction.copyWith( + state: WalletTransactionState.completed, + transactionDate: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + await _addAndSaveLatestTransaction(completedTransaction); + _removePendingTransaction(transaction); + + // mark proofs as spent in db + final allPendingProofs = await _cacheManagerCashu.getProofs( + mintUrl: transaction.mintUrl, + state: CashuProofState.pending, + ); + + final transactionProofs = allPendingProofs + .where((e) => transaction.proofPubKeys!.contains(e.Y)) + .toList(); + + _changeProofState( + proofs: transactionProofs, + state: CashuProofState.spend, + ); + await _cacheManagerCashu.saveProofs( + proofs: transactionProofs, + mintUrl: transaction.mintUrl, + ); + + return; + } + + // retry after a delay + await Future.delayed(CashuConfig.SPEND_CHECK_INTERVAL); + } + } + + /// accept token from user + /// [token] - the Cashu token string to receive \ + /// returns a stream of [CashuWalletTransaction] that emits the transaction state as it progresses. + Stream receive(String token) async* { + final rcvToken = CashuTokenEncoder.decodedToken(token); + if (rcvToken == null) { + throw Exception('Invalid Cashu token format'); + } + + if (rcvToken.proofs.isEmpty) { + throw Exception('No proofs found in the Cashu token'); + } + + await _checkIfMintIsKnown(rcvToken.mintUrl); + + final keysets = await _cashuKeysets.getKeysetsFromMint(rcvToken.mintUrl); + + if (keysets.isEmpty) { + throw Exception('No keysets found for mint: ${rcvToken.mintUrl}'); + } + + final keyset = CashuTools.filterKeysetsByUnitActive( + keysets: keysets, + unit: rcvToken.unit, + ); + + final rcvSum = CashuTools.sumOfProofs(proofs: rcvToken.proofs); + + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + CashuWalletTransaction pendingTransaction = CashuWalletTransaction( + id: rcvToken.mintUrl + now.toString(), //todo use a better id + mintUrl: rcvToken.mintUrl, + walletId: rcvToken.mintUrl, + changeAmount: rcvSum, + unit: rcvToken.unit, + walletType: WalletType.CASHU, + state: WalletTransactionState.pending, + initiatedDate: DateTime.now().millisecondsSinceEpoch ~/ 1000, + + usedKeysets: [keyset], + note: rcvToken.memo, + ); + + _addPendingTransaction(pendingTransaction); + yield pendingTransaction; + + List splittedAmounts = CashuTools.splitAmount(rcvSum); + final blindedMessagesOutputs = await CashuBdhke.createBlindedMsgForAmounts( + keysetId: keyset.id, + amounts: splittedAmounts, + cacheManager: _cacheManagerCashu, + cashuSeed: _cashuSeed, + mintUrl: rcvToken.mintUrl, + ); + + blindedMessagesOutputs.sort( + (a, b) => a.blindedMessage.amount.compareTo(b.blindedMessage.amount), + ); + + final List myBlindedSingatures; + try { + myBlindedSingatures = await _cashuRepo.swap( + mintUrl: rcvToken.mintUrl, + proofs: rcvToken.proofs, + outputs: blindedMessagesOutputs + .map( + (e) => CashuBlindedMessage( + amount: e.amount, + id: e.blindedMessage.id, + blindedMessage: e.blindedMessage.blindedMessage, + ), + ) + .toList(), + ); + } catch (e) { + _removePendingTransaction(pendingTransaction); + final failedTransaction = pendingTransaction.copyWith( + state: WalletTransactionState.failed, + completionMsg: 'Failed to swap proofs: $e', + transactionDate: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + await _addAndSaveLatestTransaction(failedTransaction); + yield failedTransaction; + throw Exception('Failed to swap proofs: $e'); + } + + // unblind + final myUnblindedTokens = CashuBdhke.unblindSignatures( + mintSignatures: myBlindedSingatures, + blindedMessages: blindedMessagesOutputs, + mintPublicKeys: keyset, + ); + + if (myUnblindedTokens.isEmpty) { + _removePendingTransaction(pendingTransaction); + final failedTransaction = pendingTransaction.copyWith( + state: WalletTransactionState.failed, + completionMsg: 'Unblinding failed, no tokens returned', + ); + await _addAndSaveLatestTransaction(failedTransaction); + yield failedTransaction; + throw Exception('Unblinding failed, no tokens returned'); + } + + // check if we recived our own proofs + // final ownTokens = await _cacheManager.getProofs(mintUrl: rcvToken.mintUrl); + + // final sameSendRcv = rcvToken.proofs + // .where((e) => ownTokens.any((ownToken) => ownToken.Y == e.Y)) + // .toList(); + + // await _cacheManagerCashu.atomicSaveAndRemove( + // proofsToRemove: sameSendRcv, + // tokensToSave: myUnblindedTokens, + // mintUrl: rcvToken.mintUrl, + // ); + await _cacheManagerCashu.saveProofs( + proofs: myUnblindedTokens, + mintUrl: rcvToken.mintUrl, + ); + + _removePendingTransaction(pendingTransaction); + + final completedTransaction = pendingTransaction.copyWith( + state: WalletTransactionState.completed, + transactionDate: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + _addAndSaveLatestTransaction(completedTransaction); + + _updateBalances(); + + yield completedTransaction; + } + + CashuToken proofsToToken({ + required List proofs, + required String mintUrl, + required String unit, + String memo = "", + }) { + if (proofs.isEmpty) { + throw Exception('No proofs provided for token conversion'); + } + final cashuToken = CashuToken( + proofs: proofs, + mintUrl: mintUrl, + memo: memo, + unit: unit, + ); + return cashuToken; + } + + void _addPendingTransaction( + CashuWalletTransaction transaction, + ) { + // update transaction + _pendingTransactions.removeWhere((t) => t.id == transaction.id); + + _pendingTransactions.add(transaction); + _pendingTransactionsSubject.add(_pendingTransactions.toList()); + } + + void _removePendingTransaction( + CashuWalletTransaction transaction, + ) { + _pendingTransactions.removeWhere((t) => t.id == transaction.id); + _pendingTransactionsSubject.add(_pendingTransactions.toList()); + } + + Future _addAndSaveLatestTransaction( + CashuWalletTransaction transaction, + ) async { + _latestTransactions.add(transaction); + _latestTransactionsSubject?.add(_latestTransactions); + await _cacheManagerCashu.saveTransactions(transactions: [transaction]); + } +} + +void _changeProofState({ + required List proofs, + required CashuProofState state, +}) { + for (final proof in proofs) { + proof.state = state; + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_bdhke.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_bdhke.dart new file mode 100644 index 000000000..d4e75cbaa --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_bdhke.dart @@ -0,0 +1,152 @@ +import 'dart:math'; + +import 'package:pointycastle/export.dart'; + +import '../../../shared/logger/logger.dart'; +import '../../entities/cashu/cashu_keyset.dart'; +import '../../entities/cashu/cashu_blinded_message.dart'; +import '../../entities/cashu/cashu_blinded_signature.dart'; +import '../../entities/cashu/cashu_proof.dart'; +import 'cashu_cache_decorator.dart'; +import 'cashu_seed.dart'; +import 'cashu_tools.dart'; + +typedef BlindMessageResult = (String B_, BigInt r); + +class CashuBdhke { + static Future> createBlindedMsgForAmounts({ + required String keysetId, + required List amounts, + required CashuCacheDecorator cacheManager, + required CashuSeed cashuSeed, + required String mintUrl, + }) async { + List items = []; + + for (final amount in amounts) { + try { + final myCount = await cacheManager.getAndIncrementDerivationCounter( + keysetId: keysetId, + mintUrl: mintUrl, + ); + + final mySecret = + await cashuSeed.deriveSecret(counter: myCount, keysetId: keysetId); + final secret = mySecret.secretHex; + + final myR = BigInt.parse(mySecret.blindingHex, radix: 16); + + //final secret = Helpers.getSecureRandomString(32); + // ignore: non_constant_identifier_names, constant_identifier_names + final (B_, r) = blindMessage(secret, r: myR); + + if (B_.isEmpty) { + continue; + } + + final blindedMessage = CashuBlindedMessage( + id: keysetId, + amount: amount, + blindedMessage: B_, + ); + + items.add(CashuBlindedMessageItem( + blindedMessage: blindedMessage, + secret: secret, + r: r, + amount: amount, + )); + } catch (e) { + Logger.log.w( + 'Error creating blinded message for amount $amount: $e', + error: e, + ); + } + } + + return items; + } + + static BlindMessageResult blindMessage(String secret, {BigInt? r}) { + // Alice picks secret x and computes Y = hash_to_curve(x) + final ECPoint Y = CashuTools.hashToCurve(secret); + + final G = CashuTools.getG(); + + // Alice generates random blinding factor r + Random random = Random.secure(); + r ??= BigInt.from(random.nextInt(1000000)) + BigInt.one; + + // Alice sends to Bob: B_ = Y + rG (blinding) + final ECPoint? blindedMessage = Y + (G * r); + if (blindedMessage == null) { + throw Exception('Failed to compute blinded message'); + } + final String blindedMessageHex = CashuTools.ecPointToHex(blindedMessage); + return (blindedMessageHex, r); + } + + static ECPoint? unblindingSignature({ + required String cHex, + required String kHex, + required BigInt r, + }) { + final C_ = CashuTools.pointFromHexString(cHex); + final K = CashuTools.pointFromHexString(kHex); + final rK = K * r; + if (rK == null) return null; + return C_ - rK; + } + + static List unblindSignatures({ + required List mintSignatures, + required List blindedMessages, + required CahsuKeyset mintPublicKeys, + }) { + List tokens = []; + + if (mintSignatures.length != blindedMessages.length) { + throw Exception( + 'Mismatched lengths: ${mintSignatures.length} signatures, ${blindedMessages.length} messages'); + } + + /// copy lists and sort by amount descending + final sortedSignatures = List.from(mintSignatures) + ..sort((a, b) => b.amount.compareTo(a.amount)); + final sortedMessages = List.from(blindedMessages) + ..sort((a, b) => b.amount.compareTo(a.amount)); + + for (int i = 0; i < sortedSignatures.length; i++) { + final signature = sortedSignatures[i]; + final blindedMsg = sortedMessages[i]; + + final matchingKeys = mintPublicKeys.mintKeyPairs + .where((e) => e.amount == blindedMsg.amount) + .toList(); + + if (matchingKeys.isEmpty) { + throw Exception('No mint public key for amount ${blindedMsg.amount}'); + } + final mintPubKey = matchingKeys.first; + + final unblindedSig = unblindingSignature( + cHex: signature.blindedSignature, + kHex: mintPubKey.pubkey, + r: blindedMsg.r, + ); + + if (unblindedSig == null) { + throw Exception('Failed to unblind signature'); + } + + tokens.add(CashuProof( + secret: blindedMsg.secret, + amount: blindedMsg.amount, + unblindedSig: CashuTools.ecPointToHex(unblindedSig), + keysetId: mintPublicKeys.id, + )); + } + + return tokens; + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_cache_decorator.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_cache_decorator.dart new file mode 100644 index 000000000..2ee14b569 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_cache_decorator.dart @@ -0,0 +1,141 @@ +import 'dart:async'; + +import '../../../shared/helpers/mutex_simple.dart'; +import '../../entities/cashu/cashu_keyset.dart'; +import '../../entities/cashu/cashu_proof.dart'; +import '../../entities/wallet/wallet_transaction.dart'; +import '../../entities/wallet/wallet_type.dart'; +import '../../repositories/cache_manager.dart'; + +class CashuCacheDecorator implements CacheManager { + final MutexSimple _mutex; + final CacheManager _delegate; + + CashuCacheDecorator({ + required CacheManager cacheManager, + MutexSimple? mutex, + }) : _delegate = cacheManager, + _mutex = mutex ?? MutexSimple(); + + @override + Future saveProofs({ + required List proofs, + required String mintUrl, + }) async { + await _mutex.synchronized(() async { + await _delegate.saveProofs(proofs: proofs, mintUrl: mintUrl); + }); + } + + @override + Future removeProofs({ + required List proofs, + required String mintUrl, + }) async { + await _mutex.synchronized(() async { + await _delegate.removeProofs(proofs: proofs, mintUrl: mintUrl); + }); + } + + @override + Future> getProofs({ + String? mintUrl, + String? keysetId, + CashuProofState state = CashuProofState.unspend, + }) async { + return await _mutex.synchronized(() async { + return await _delegate.getProofs( + mintUrl: mintUrl, + keysetId: keysetId, + state: state, + ); + }); + } + + @override + Future> getKeysets({ + String? mintUrl, + }) { + return _mutex.synchronized(() async { + return await _delegate.getKeysets(mintUrl: mintUrl); + }); + } + + @override + Future saveKeyset(CahsuKeyset keyset) async { + await _mutex.synchronized(() async { + await _delegate.saveKeyset(keyset); + }); + } + + @override + Future> getTransactions({ + int? limit, + int? offset, + String? walletId, + String? unit, + WalletType? walletType, + }) { + return _mutex.synchronized(() async { + return await _delegate.getTransactions( + limit: limit, + offset: offset, + walletId: walletId, + unit: unit, + walletType: walletType, + ); + }); + } + + @override + Future saveTransactions({ + required List transactions, + }) { + return _mutex.synchronized(() async { + await _delegate.saveTransactions(transactions: transactions); + }); + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw UnimplementedError( + 'CashuCacheDecorator does not implement ${invocation.memberName}. Add an explicit delegate method.'); + } + + Future runInTransaction(Future Function() action) async { + return await _mutex.synchronized(() async { + return await action(); + }); + } + + Future atomicSaveAndRemove({ + required List proofsToRemove, + required List tokensToSave, + required String mintUrl, + }) async { + await runInTransaction(() async { + await _delegate.removeProofs(proofs: proofsToRemove, mintUrl: mintUrl); + await _delegate.saveProofs(proofs: tokensToSave, mintUrl: mintUrl); + }); + } + + Future getAndIncrementDerivationCounter({ + required String keysetId, + required String mintUrl, + }) async { + return await runInTransaction(() async { + final currentValue = await _delegate.getCashuSecretCounter( + mintUrl: mintUrl, + keysetId: keysetId, + ); + final newValue = currentValue + 1; + await _delegate.setCashuSecretCounter( + mintUrl: mintUrl, + keysetId: keysetId, + counter: newValue, + ); + + return currentValue; + }); + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_keypair.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_keypair.dart new file mode 100644 index 000000000..aaa98f0de --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_keypair.dart @@ -0,0 +1,58 @@ +import 'package:convert/convert.dart'; +import 'package:pointycastle/export.dart'; + +import '../../../shared/nips/nip01/helpers.dart'; +import 'cashu_tools.dart'; + +class CashuKeypair { + final String privateKey; + final String publicKey; + + CashuKeypair({ + required this.privateKey, + required this.publicKey, + }); + + static CashuKeypair generateCashuKeyPair() { + // 32-byte private key + final privKey = Helpers.getSecureRandomHex(32); + + // derive the public key as an EC point + final pubKeyPoint = derivePublicKey(privKey); + + // convert the EC point to hex format (compressed) + final pubKey = pubKeyPoint.getEncoded(true); + final pubKeyHex = hex.encode(pubKey); + + return CashuKeypair( + privateKey: privKey, + publicKey: pubKeyHex, + ); + } + + static ECPoint derivePublicKey(String privateKeyHex) { + // hex private key to BigInt + final privateKeyInt = BigInt.parse(privateKeyHex, radix: 16); + + final G = CashuTools.getG(); + + // calculate public key: pubKey = privKey * G + final publicKeyPoint = G * privateKeyInt; + + return publicKeyPoint!; + } + + factory CashuKeypair.fromJson(Map json) { + return CashuKeypair( + privateKey: json['privateKey'] as String, + publicKey: json['publicKey'] as String, + ); + } + + Map toJson() { + return { + 'privateKey': privateKey, + 'publicKey': publicKey, + }; + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_keysets.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_keysets.dart new file mode 100644 index 000000000..00c1ba978 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_keysets.dart @@ -0,0 +1,82 @@ +import '../../entities/cashu/cashu_keyset.dart'; +import '../../repositories/cache_manager.dart'; +import '../../repositories/cashu_repo.dart'; + +class CashuKeysets { + final CashuRepo _cashuRepo; + final CacheManager _cacheManager; + + CashuKeysets({ + required CashuRepo cashuRepo, + required CacheManager cacheManager, + }) : _cashuRepo = cashuRepo, + _cacheManager = cacheManager; + + /// Fetches keysets from the cache or network. \ + /// If the cache is stale or empty, it fetches from the network. \ + /// Returns a list of [CahsuKeyset]. \ + /// [mintUrl] The URL of the mint to fetch keysets from. \ + /// [validityDurationSeconds] The duration in seconds for which the cache is valid. + Future> getKeysetsFromMint( + String mintUrl, { + int validityDurationSeconds = 24 * 60 * 60, // 24 hours + }) async { + final cachedKeysets = await getKeysetFromCache(mintUrl); + + if (cachedKeysets != null && cachedKeysets.isNotEmpty) { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + final isCacheStale = cachedKeysets.any((keyset) => + keyset.fetchedAt == null || + (now - keyset.fetchedAt!) >= validityDurationSeconds); + + if (!isCacheStale) { + return cachedKeysets; + } + } + + final networkKeyset = await getKeysetMintFromNetwork(mintUrl: mintUrl); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + for (final keyset in networkKeyset) { + keyset.fetchedAt = now; + await saveKeyset(keyset); + } + return networkKeyset; + } + + Future> getKeysetMintFromNetwork({ + required String mintUrl, + }) async { + final List mintKeys = []; + final keySets = await _cashuRepo.getKeysets( + mintUrl: mintUrl, + ); + + for (final keySet in keySets) { + final keys = await _cashuRepo.getKeys( + mintUrl: mintUrl, + keysetId: keySet.id, + ); + + mintKeys.add( + CahsuKeyset.fromResponses( + keysetResponse: keySet, + keysResponse: keys.first, + ), + ); + } + return mintKeys; + } + + Future saveKeyset(CahsuKeyset keyset) async { + await _cacheManager.saveKeyset(keyset); + } + + Future?> getKeysetFromCache(String mintUrl) async { + try { + return await _cacheManager.getKeysets(mintUrl: mintUrl); + } catch (e) { + return null; + } + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_proof_select.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_proof_select.dart new file mode 100644 index 000000000..e78c0a96b --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_proof_select.dart @@ -0,0 +1,478 @@ +import '../../entities/cashu/cashu_keyset.dart'; +import '../../entities/cashu/cashu_blinded_message.dart'; +import '../../entities/cashu/cashu_proof.dart'; +import '../../repositories/cashu_repo.dart'; +import 'cashu_bdhke.dart'; +import 'cashu_cache_decorator.dart'; +import 'cashu_seed.dart'; +import 'cashu_tools.dart'; + +class ProofSelectionResult { + final List selectedProofs; + final int totalSelected; + final int fees; + + /// amount that needs to be split + final int splitAmount; + final bool needsSplit; + + /// breakdown by keyset + final Map feesByKeyset; + + ProofSelectionResult({ + required this.selectedProofs, + required this.totalSelected, + required this.fees, + required this.splitAmount, + required this.needsSplit, + required this.feesByKeyset, + }); +} + +class SplitResult { + final List exactProofs; + final List changeProofs; + + SplitResult({ + required this.exactProofs, + required this.changeProofs, + }); +} + +class CashuProofSelect { + final CashuRepo _cashuRepo; + + CashuProofSelect({ + required CashuRepo cashuRepo, + }) : _cashuRepo = cashuRepo; + + /// Find keyset by ID from list + static CahsuKeyset? _findKeysetById( + List keysets, String keysetId) { + try { + return keysets.firstWhere((keyset) => keyset.id == keysetId); + } catch (e) { + return null; + } + } + + /// Calculate fees for a list of proofs across multiple keysets + static int calculateFees( + List proofs, + List keysets, + ) { + if (proofs.isEmpty) return 0; + + int sumFees = 0; + for (final proof in proofs) { + final keyset = _findKeysetById(keysets, proof.keysetId); + if (keyset != null) { + sumFees += keyset.inputFeePPK; + } else { + throw Exception( + 'Keyset not found for proof with keyset ID: ${proof.keysetId}'); + } + } + + /// Round up: (sumFees + 999) // 1000 + /// @see nut02 + return ((sumFees + 999) ~/ 1000); + } + + /// Calculate fees with breakdown by keyset + static Map calculateFeesWithBreakdown({ + required List proofs, + required List keysets, + }) { + if (proofs.isEmpty) { + return { + 'totalFees': 0, + 'feesByKeyset': {}, + 'ppkByKeyset': {}, + }; + } + + final Map feesByKeyset = {}; + final Map ppkByKeyset = {}; + int totalPpk = 0; + + // Group proofs by keyset and calculate fees + for (final proof in proofs) { + final keysetId = proof.keysetId; + final keyset = _findKeysetById(keysets, keysetId); + + if (keyset == null) { + throw Exception('Keyset not found for proof with keyset ID: $keysetId'); + } + + final inputFeePpk = keyset.inputFeePPK; + ppkByKeyset[keysetId] = (ppkByKeyset[keysetId] ?? 0) + inputFeePpk; + totalPpk += inputFeePpk; + } + + // Convert PPK to actual fees (single rounding approach) + final totalFees = ((totalPpk + 999) ~/ 1000); + + // Calculate individual keyset fees for breakdown (informational) + for (final entry in ppkByKeyset.entries) { + final keysetFee = ((entry.value + 999) ~/ 1000); + feesByKeyset[entry.key] = keysetFee; + } + + return { + 'totalFees': totalFees, + 'feesByKeyset': feesByKeyset, + 'ppkByKeyset': ppkByKeyset, + 'totalPpk': totalPpk, + }; + } + + /// Get the active keyset for creating new outputs + static CahsuKeyset? getActiveKeyset(List keysets) { + try { + return keysets.firstWhere((keyset) => keyset.active); + } catch (e) { + return null; // No active keyset found + } + } + + /// Sort proofs optimally considering both amount and fees + static List sortProofsOptimally( + List proofs, + List keysets, + ) { + return List.from(proofs) + ..sort((a, b) { + // Primary: prefer larger amounts + final amountComparison = b.amount.compareTo(a.amount); + if (amountComparison != 0) return amountComparison; + + // Secondary: prefer lower fee keysets + final keysetA = _findKeysetById(keysets, a.keysetId); + final keysetB = _findKeysetById(keysets, b.keysetId); + final feeA = keysetA?.inputFeePPK ?? 0; + final feeB = keysetB?.inputFeePPK ?? 0; + + // Lower fees first + final feeComparison = feeA.compareTo(feeB); + if (feeComparison != 0) return feeComparison; + + // Tertiary: prefer active keysets + final activeA = keysetA?.active ?? false; + final activeB = keysetB?.active ?? false; + return activeB + .toString() + .compareTo(activeA.toString()); // true comes before false + }); + } + + /// Swaps proofs in target amount and change + Future performSplit({ + required String mint, + required List proofsToSplit, + required int targetAmount, + required int changeAmount, + required List keysets, + required CashuCacheDecorator cacheManagerCashu, + required CashuSeed cashuSeed, + }) async { + final activeKeyset = getActiveKeyset(keysets); + + if (activeKeyset == null) { + throw Exception('No active keyset found for mint: $mint'); + } + + if (targetAmount <= 0 || changeAmount < 0) { + throw Exception('Invalid target or change amount'); + } + + // split the amounts by power of 2 + final targetAmountsSplit = CashuTools.splitAmount(targetAmount); + + final changeAmountsSplit = CashuTools.splitAmount(changeAmount); + + final outputs = [ + // amount we want to spend + ...targetAmountsSplit, + + // change to keep + ...changeAmountsSplit, + ]; + + final blindedMessagesOutputs = await CashuBdhke.createBlindedMsgForAmounts( + keysetId: activeKeyset.id, + amounts: outputs, + cacheManager: cacheManagerCashu, + cashuSeed: cashuSeed, + mintUrl: mint, + ); + + // sort to increase privacy + blindedMessagesOutputs.sort( + (a, b) => a.amount.compareTo(b.amount), + ); + + final blindedSignatures = await _cashuRepo.swap( + mintUrl: mint, + proofs: proofsToSplit, + outputs: blindedMessagesOutputs + .map( + (e) => CashuBlindedMessage( + amount: e.amount, + id: e.blindedMessage.id, + blindedMessage: e.blindedMessage.blindedMessage, + ), + ) + .toList(), + ); + + final myUnblindedTokens = CashuBdhke.unblindSignatures( + mintSignatures: blindedSignatures, + blindedMessages: blindedMessagesOutputs, + mintPublicKeys: activeKeyset, + ); + + final List exactProofs = []; + final List changeProofs = []; + + List targetAmountsWorkingList = List.from(targetAmountsSplit); + for (final proof in myUnblindedTokens) { + if (targetAmountsWorkingList.contains(proof.amount)) { + exactProofs.add(proof); + targetAmountsWorkingList.remove(proof.amount); + } else { + changeProofs.add(proof); + } + } + + return SplitResult( + /// first proofs is exact amount + exactProofs: exactProofs, + + /// change + changeProofs: changeProofs, + ); + } + + /// Selects proofs for spending target amount with multiple keysets + static ProofSelectionResult selectProofsForSpending({ + required List proofs, + required int targetAmount, + required List keysets, + int maxIterations = 15, + }) { + if (keysets.isEmpty) { + throw Exception('No keysets provided'); + } + + final sortedProofs = sortProofsOptimally(proofs, keysets); + + // First try to find exact match (including fees) + final exactMatch = + _findExactMatchWithFees(sortedProofs, targetAmount, keysets); + if (exactMatch.isNotEmpty) { + final feeData = + calculateFeesWithBreakdown(proofs: exactMatch, keysets: keysets); + return ProofSelectionResult( + selectedProofs: exactMatch, + totalSelected: exactMatch.fold(0, (sum, proof) => sum + proof.amount), + fees: feeData['totalFees'], + splitAmount: 0, + needsSplit: false, + feesByKeyset: feeData['feesByKeyset'], + ); + } + + // Iterative selection accounting for fees + return _selectWithFeeIteration( + sortedProofs: sortedProofs, + targetAmount: targetAmount, + keysets: keysets, + maxIterations: maxIterations, + ); + } + + /// Iteratively select proofs accounting for fees across multiple keysets + static ProofSelectionResult _selectWithFeeIteration({ + required List sortedProofs, + required int targetAmount, + required List keysets, + required int maxIterations, + }) { + final selected = []; + int iteration = 0; + + while (iteration < maxIterations) { + iteration++; + + final currentTotal = selected.fold(0, (sum, proof) => sum + proof.amount); + final feeData = + calculateFeesWithBreakdown(proofs: selected, keysets: keysets); + final currentFees = feeData['totalFees']; + final requiredTotal = targetAmount + currentFees; + + if (currentTotal >= requiredTotal) { + // We have enough! + final splitAmount = currentTotal - targetAmount - currentFees; + return ProofSelectionResult( + selectedProofs: selected, + totalSelected: currentTotal, + fees: currentFees, + splitAmount: splitAmount.toInt(), + needsSplit: splitAmount > 0, + feesByKeyset: feeData['feesByKeyset'], + ); + } + + // Need more inputs + final shortage = requiredTotal - currentTotal; + + // Find next best proof to add (prefer efficient proofs) + CashuProof? nextProof = _selectNextOptimalProof( + sortedProofs, + selected, + shortage.toInt(), + keysets, + ); + + if (nextProof == null) { + final availableTotal = + sortedProofs.fold(0, (sum, proof) => sum + proof.amount); + + throw Exception( + 'Insufficient funds: need $targetAmount + fees ($currentFees), have $availableTotal available'); + } + + selected.add(nextProof); + } + + throw Exception( + 'Fee calculation did not converge after $maxIterations iterations'); + } + + /// Select the next optimal proof considering amount and fee efficiency + static CashuProof? _selectNextOptimalProof( + List sortedProofs, + List alreadySelected, + int shortage, + List keysets, + ) { + CashuProof? bestProof; + double bestEfficiency = -1; + + for (final proof in sortedProofs) { + if (alreadySelected.contains(proof)) continue; + + final keyset = _findKeysetById(keysets, proof.keysetId); + if (keyset == null) continue; + + // Calculate efficiency: amount per fee unit + final feePpk = keyset.inputFeePPK; + final feeInSats = ((feePpk + 999) ~/ 1000); + final efficiency = + feeInSats > 0 ? proof.amount / feeInSats : proof.amount.toDouble(); + + // Prefer proofs that can cover the shortage efficiently + if (proof.amount >= shortage && efficiency > bestEfficiency) { + bestProof = proof; + bestEfficiency = efficiency; + } + } + + // If no proof can cover shortage, pick the most efficient one + if (bestProof == null) { + for (final proof in sortedProofs) { + if (alreadySelected.contains(proof)) continue; + + final keyset = _findKeysetById(keysets, proof.keysetId); + if (keyset == null) continue; + + final feePpk = keyset.inputFeePPK; + final feeInSats = ((feePpk + 999) ~/ 1000); + final efficiency = + feeInSats > 0 ? proof.amount / feeInSats : proof.amount.toDouble(); + + if (efficiency > bestEfficiency) { + bestProof = proof; + bestEfficiency = efficiency; + } + } + } + + return bestProof; + } + + /// Find exact match including fees across multiple keysets + static List _findExactMatchWithFees( + List proofs, + int targetAmount, + List keysets, + ) { + // Check single proof exact match + for (final proof in proofs) { + final singleProofFee = calculateFees([proof], keysets); + if (proof.amount == targetAmount + singleProofFee) { + return [proof]; + } + } + + // Check combinations with fee consideration + return _findExactCombinationWithFees(proofs, targetAmount, keysets, + maxProofs: 5); + } + + /// Find exact combination accounting for fees across multiple keysets + static List _findExactCombinationWithFees( + List proofs, + int targetAmount, + List keysets, { + int maxProofs = 5, + }) { + if (proofs.length > 20) return []; + + for (int len = 2; len <= maxProofs && len <= proofs.length; len++) { + final combination = + _findCombinationOfLengthWithFees(proofs, targetAmount, keysets, len); + if (combination.isNotEmpty) return combination; + } + + return []; + } + + /// Find combination of specific length with fee consideration + static List _findCombinationOfLengthWithFees( + List proofs, + int targetAmount, + List keysets, + int length, + ) { + List result = []; + + void backtrack(int start, List current, int currentSum) { + if (current.length == length) { + final fees = calculateFees(current, keysets); + if (currentSum == targetAmount + fees) { + result = List.from(current); + } + return; + } + + for (int i = start; i < proofs.length; i++) { + // Estimate if this combination could work + final estimatedFees = calculateFees([...current, proofs[i]], keysets); + if (currentSum + proofs[i].amount <= + targetAmount + estimatedFees + 100) { + // Small buffer + current.add(proofs[i]); + backtrack(i + 1, current, currentSum + proofs[i].amount); + current.removeLast(); + + if (result.isNotEmpty) return; + } + } + } + + backtrack(0, [], 0); + return result; + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_seed.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_seed.dart new file mode 100644 index 000000000..a085b68e9 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_seed.dart @@ -0,0 +1,120 @@ +import 'dart:typed_data'; + +import 'package:bip32_keys/bip32_keys.dart'; +import 'package:bip39_mnemonic/bip39_mnemonic.dart'; +import 'package:convert/convert.dart'; + +import '../../entities/cashu/cashu_user_seedphrase.dart'; + +class CashuSeedDeriveSecretResult { + final String secretHex; + final String blindingHex; + + CashuSeedDeriveSecretResult({ + required this.secretHex, + required this.blindingHex, + }); +} + +class CashuSeed { + static const int derivationPurpose = 129372; + static const int derivationCoinType = 0; + + Mnemonic? _userSeedPhrase; + + CashuSeed({ + CashuUserSeedphrase? userSeedPhrase, + }) { + if (userSeedPhrase != null) { + setSeedPhrase( + seedPhrase: userSeedPhrase.seedPhrase, + language: userSeedPhrase.language, + passphrase: userSeedPhrase.passphrase, + ); + } + } + + /// set the user seed phrase + /// throws an exception if the seed phrase is invalid + void setSeedPhrase({ + required String seedPhrase, + Language language = Language.english, + String passphrase = '', + }) { + _userSeedPhrase = Mnemonic.fromSentence( + seedPhrase, + language, + passphrase: passphrase, + ); + } + + /// generate a new seed phrase + /// optionally specify the language, passphrase and length + /// returns the generated seed phrase + static String generateSeedPhrase({ + Language language = Language.english, + String passphrase = '', + MnemonicLength length = MnemonicLength.words24, + }) { + final seed = Mnemonic.generate( + language, + length: length, + passphrase: passphrase, + ); + return seed.sentence; + } + + void _seedCheck() { + if (_userSeedPhrase == null) { + throw Exception('Seed phrase is not set'); + } + } + + static int keysetIdToInt(String keysetId) { + BigInt number = BigInt.parse(keysetId, radix: 16); + + //BigInt modulus = BigInt.from(2).pow(31) - BigInt.one; + /// precalculated for 2^31 - 1 + BigInt modulus = BigInt.from(2147483647); + + BigInt keysetIdInt = number % modulus; + + return keysetIdInt.toInt(); + } + + /// derive a secret and blinding factor from the seed phrase + /// using the keysetId and counter + /// throws an exception if the seed phrase is not set + /// returns a [CashuSeedDeriveSecretResult] containing the secret and blinding factor in hex format + CashuSeedDeriveSecretResult deriveSecret({ + required int counter, + required String keysetId, + }) { + _seedCheck(); + + final keysetIdInt = keysetIdToInt(keysetId); + + final mnemonicUnit8List = Uint8List.fromList(_userSeedPhrase!.seed); + + final masterKey = Bip32Keys.fromSeed( + mnemonicUnit8List, + ); + + final pathKeySecret = masterKey.derivePath( + "m/$derivationPurpose'/$derivationCoinType'/$keysetIdInt'/$counter'/0", + ); + + final pathKeyBlinding = masterKey.derivePath( + "m/$derivationPurpose'/$derivationCoinType'/$keysetIdInt'/$counter'/1", + ); + + final pathKeySecretHex = hex.encode(pathKeySecret.private!.toList()); + + final pathKeyBlindingHex = hex.encode(pathKeyBlinding.private!.toList()); + + return CashuSeedDeriveSecretResult( + secretHex: pathKeySecretHex, + blindingHex: pathKeyBlindingHex, + ); + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_swap.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_swap.dart new file mode 100644 index 000000000..e69de29bb diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_token_encoder.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_token_encoder.dart new file mode 100644 index 000000000..3f374b417 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_token_encoder.dart @@ -0,0 +1,60 @@ +import 'dart:convert'; + +import 'package:cbor/cbor.dart'; + +import '../../../shared/logger/logger.dart'; +import '../../entities/cashu/cashu_token.dart'; + +class CashuTokenEncoder { + static final v4Prefix = 'cashuB'; + + static String encodeTokenV4({ + required CashuToken token, + }) { + final json = token.toV4Json(); + final myCbor = CborValue(json); + final base64String = base64.encode(cbor.encode(myCbor)); + String base64URL = _base64urlFromBase64(base64String); + return v4Prefix + base64URL; + } + + static CashuToken? decodedToken(String token) { + Map? obj; + try { + // remove prefix before decoding + if (!token.startsWith(v4Prefix)) { + Logger.log.f('Invalid token format: missing prefix'); + return null; + } + + String tokenWithoutPrefix = token.substring(v4Prefix.length); + obj = _decodeBase64ToMapByCBOR(tokenWithoutPrefix); + } catch (e) { + Logger.log.f('Error decoding token: $e'); + } + + if (obj == null) return null; + + return CashuToken.fromV4Json(obj); + } + + static String _base64urlFromBase64(String base64String) { + String output = base64String.replaceAll('+', '-').replaceAll('/', '_'); + return output.split('=')[0]; + } + + static String _base64FromBase64url(String token) { + String normalizedBase64 = token.replaceAll('-', '+').replaceAll('_', '/'); + while (normalizedBase64.length % 4 != 0) { + normalizedBase64 += '='; + } + return normalizedBase64; + } + + static T _decodeBase64ToMapByCBOR(String token) { + String normalizedBase64 = _base64FromBase64url(token); + final decoded = base64.decode(normalizedBase64); + final cborValue = cbor.decode(decoded); + return cborValue.toJson() as T; + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/cashu/cashu_tools.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_tools.dart new file mode 100644 index 000000000..3499ec30d --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_tools.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:pointycastle/export.dart' hide Digest; + +import 'package:convert/convert.dart'; + +import '../../../config/cashu_config.dart'; +import '../../../shared/nips/nip01/bip340.dart'; +import '../../entities/cashu/cashu_keyset.dart'; +import '../../entities/cashu/cashu_blinded_message.dart'; +import '../../entities/cashu/cashu_proof.dart'; + +class CashuTools { + static String composeUrl({ + required String mintUrl, + required String path, + String version = '${CashuConfig.NUT_VERSION}/', + }) { + return '$mintUrl/$version$path'; + } + + /// Splits an amount into a list of powers of two. + /// eg, 5 will be split into [1, 4] + static List splitAmount(int value) { + return [ + for (int i = 0; value > 0; i++, value >>= 1) + if (value & 1 == 1) 1 << i + ]; + } + + static ECPoint getG() { + return ECCurve_secp256k1().G; + } + + static ECPoint hashToCurve(String hash) { + const maxAttempt = 65536; + + final hashBytes = Uint8List.fromList(utf8.encode(hash)); + Uint8List msgToHash = Uint8List.fromList( + [...CashuConfig.DOMAIN_SEPARATOR_HashToCurve.codeUnits, ...hashBytes]); + + var digest = SHA256Digest(); + Uint8List msgHash = digest.process(msgToHash); + + for (int counter = 0; counter < maxAttempt; counter++) { + Uint8List counterBytes = Uint8List(4) + ..buffer.asByteData().setUint32(0, counter, Endian.little); + Uint8List bytesToHash = Uint8List.fromList([...msgHash, ...counterBytes]); + + Uint8List hash = digest.process(bytesToHash); + + try { + String pointXHex = '02${hex.encode(hash)}'; + ECPoint point = pointFromHexString(pointXHex); + return point; + } catch (_) { + continue; + } + } + + throw Exception('Failed to find a valid point after $maxAttempt attempts'); + } + + static ECPoint pointFromHexString(String hexString) { + final curve = ECCurve_secp256k1(); + final bytes = hex.decode(hexString); + + return curve.curve.decodePoint(bytes)!; + } + + static String ecPointToHex(ECPoint point, {bool compressed = true}) { + return point + .getEncoded(compressed) + .map( + (byte) => byte.toRadixString(16).padLeft(2, '0'), + ) + .join(); + } + + static String createMintSignature({ + required String quote, + required List blindedMessagesOutputs, + required String privateKeyHex, + }) { + final StringBuffer messageBuffer = StringBuffer(); + + // add quote id + messageBuffer.write(quote); + + // add each B_ field(hex strings) + for (final output in blindedMessagesOutputs) { + messageBuffer.write(output.blindedMessage); + } + + final String messageToSign = messageBuffer.toString(); + + // hash the message + final Uint8List messageBytes = utf8.encode(messageToSign); + final Digest messageHash = sha256.convert(messageBytes); + final String messageHashHex = messageHash.toString(); + + final String signature = Bip340.sign(messageHashHex, privateKeyHex); + + return signature; + } + + static Uint8List hexToBytes(String hex) { + return Uint8List.fromList( + List.generate( + hex.length ~/ 2, + (i) => int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16), + ), + ); + } + + static String bytesToHex(Uint8List bytes) { + return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(''); + } + + /// Filters keysets by unit and returns the active keyset. + /// Throws an exception if no keysets are found with the specified unit + /// or if no active keyset is found. + static CahsuKeyset filterKeysetsByUnitActive({ + required List keysets, + required String unit, + }) { + final keysetsFiltered = + keysets.where((keyset) => keyset.unit == unit).toList(); + + if (keysetsFiltered.isEmpty) { + throw Exception('No keysets found with unit: $unit'); + } + + try { + return keysetsFiltered.firstWhere((keyset) => keyset.active); + } catch (_) { + throw Exception('No active keyset found for unit: $unit'); + } + } + + /// filters keysets by unit + static List filterKeysetsByUnit({ + required List keysets, + required String unit, + }) { + return keysets.where((keyset) => keyset.unit == unit).toList(); + } + + /// Sums the amounts of all proofs in the list. \ + /// Returns the total amount. + static int sumOfProofs({required List proofs}) { + return proofs.fold(0, (sum, proof) => sum + proof.amount); + } + + /// Calculates the number of blank outputs needed for a given fee reserve. + static int calculateNumberOfBlankOutputs(int feeReserveSat) { + if (feeReserveSat < 0) { + throw Exception("Fee reserve can't be negative."); + } + + if (feeReserveSat == 0) { + return 0; + } + + return max((log(feeReserveSat) / ln2).ceil(), 1); + } + + static List filterProofsByUnit({ + required List proofs, + required String unit, + required List keysets, + }) { + return proofs.where((proof) { + final keyset = keysets.firstWhere( + (keyset) => keyset.id == proof.keysetId, + orElse: () => throw Exception('Keyset not found for proof: $proof'), + ); + return keyset.unit == unit; + }).toList(); + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/consts/transaction_type.dart b/packages/ndk/lib/domain_layer/usecases/nwc/consts/transaction_type.dart index 9598f526c..fbbbfabfe 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/consts/transaction_type.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/consts/transaction_type.dart @@ -12,4 +12,7 @@ enum TransactionType { orElse: () => TransactionType.incoming, ); } + + @override + String toString() => value; } diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/nwc_notification.dart b/packages/ndk/lib/domain_layer/usecases/nwc/nwc_notification.dart index bb95bb60c..843341e2e 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/nwc_notification.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/nwc_notification.dart @@ -11,6 +11,7 @@ class NwcNotification { String? description; String? descriptionHash; String? preimage; + String? state; String paymentHash; int amount; int? feesPaid; @@ -24,6 +25,8 @@ class NwcNotification { bool get isPaymentReceived => notificationType == kPaymentReceived; bool get isPaymentSent => notificationType == kPaymentSent; bool get isHoldInvoiceAccepted => notificationType == kHoldInvoiceAccepted; + bool get isSettled => state == "settled"; + bool get isPending => state == "pending"; NwcNotification({ required this.notificationType, @@ -32,6 +35,7 @@ class NwcNotification { this.description, this.descriptionHash, this.preimage, + this.state, required this.paymentHash, required this.amount, this.feesPaid, @@ -51,6 +55,7 @@ class NwcNotification { description: map['description'] as String?, descriptionHash: map['description_hash'] as String?, preimage: map['preimage'] as String, + state: map['state'] as String?, paymentHash: map['payment_hash'] as String, amount: map['amount'] as int, feesPaid: map['fees_paid'] as int, @@ -66,6 +71,6 @@ class NwcNotification { @override toString() { - return 'NwcNotification{type: $type, invoice: $invoice, description: $description, descriptionHash: $descriptionHash, preimage: $preimage, paymentHash: $paymentHash, amount: $amount, feesPaid: $feesPaid, createdAt: $createdAt, expiresAt: $expiresAt, settledAt: $settledAt, metadata: $metadata}'; + return 'NwcNotification{type: $type, invoice: $invoice, state; $state description: $description, descriptionHash: $descriptionHash, preimage: $preimage, paymentHash: $paymentHash, amount: $amount, feesPaid: $feesPaid, createdAt: $createdAt, expiresAt: $expiresAt, settledAt: $settledAt, metadata: $metadata}'; } } diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/responses/list_transactions_response.dart b/packages/ndk/lib/domain_layer/usecases/nwc/responses/list_transactions_response.dart index 1f789860a..90c8c3a39 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/responses/list_transactions_response.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/responses/list_transactions_response.dart @@ -46,7 +46,7 @@ class TransactionResult extends Equatable { /// The hash of the transaction description. final String? descriptionHash; - /// The hash of the transaction description. + /// can be "pending", "settled", "expired" (for invoices) or "failed" (for payments), optional final String? state; /// The preimage of the transaction. diff --git a/packages/ndk/lib/domain_layer/usecases/relay_manager.dart b/packages/ndk/lib/domain_layer/usecases/relay_manager.dart index b632d60a6..04c2be711 100644 --- a/packages/ndk/lib/domain_layer/usecases/relay_manager.dart +++ b/packages/ndk/lib/domain_layer/usecases/relay_manager.dart @@ -542,7 +542,7 @@ class RelayManager { void _checkNetworkClose( RequestState state, RelayConnectivity relayConnectivity) { - /// recived everything, close the network controller + /// received everything, close the network controller if (state.didAllRequestsReceivedEOSE) { state.networkController.close(); updateRelayConnectivity(); diff --git a/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart new file mode 100644 index 000000000..6502bcc12 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart @@ -0,0 +1,359 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../entities/wallet/wallet_balance.dart'; +import '../../entities/wallet/wallet_transaction.dart'; +import '../../entities/wallet/wallet_type.dart'; +import '../../repositories/wallets_operations_repo.dart'; +import '../../repositories/wallets_repo.dart'; +import '../../entities/wallet/wallet.dart'; + +/// Proposal for a unified wallet system that can handle multiple wallet types (NWC, Cashu). +class Wallets { + final WalletsRepo _walletsRepository; + final WalletsOperationsRepo _walletsOperationsRepository; + + int latestTransactionCount; + + String? defaultWalletId; + + StreamSubscription>? _walletsUsecaseSubscription; + + /// in memory storage + final Set _wallets = {}; + final Map> _walletsBalances = {}; + final Map> _walletsPendingTransactions = {}; + final Map> _walletsRecentTransactions = {}; + + final BehaviorSubject> _walletsSubject = + BehaviorSubject>(); + + /// combined streams for all wallets + final BehaviorSubject> _combinedBalancesSubject = + BehaviorSubject>(); + + final BehaviorSubject> + _combinedPendingTransactionsSubject = + BehaviorSubject>(); + + final BehaviorSubject> + _combinedRecentTransactionsSubject = + BehaviorSubject>(); + + /// individual wallet streams - created on demand + final Map>> + _walletBalanceStreams = {}; + + final Map>> + _walletPendingTransactionStreams = {}; + + final Map>> + _walletRecentTransactionStreams = {}; + + /// stream subscriptions for cleanup + final Map> _subscriptions = {}; + + Wallets({ + required WalletsRepo walletsRepository, + required WalletsOperationsRepo walletsOperationsRepository, + this.latestTransactionCount = 10, + }) : _walletsRepository = walletsRepository, + _walletsOperationsRepository = walletsOperationsRepository { + _initializeWallet(); + } + + /// public-facing stream of combined balances, grouped by currency. + Stream> get combinedBalances => + _combinedBalancesSubject.stream; + + /// public-facing stream of combined pending transactions. + Stream> get combinedPendingTransactions => + _combinedPendingTransactionsSubject.stream; + + /// public-facing stream of combined recent transactions. + Stream> get combinedRecentTransactions => + _combinedRecentTransactionsSubject.stream; + + Future> combinedTransactions({ + int? limit, + int? offset, + String? walletId, + String? unit, + WalletType? walletType, + }) { + return _walletsRepository.getTransactions( + limit: limit, + offset: offset, + walletId: walletId, + unit: unit, + walletType: walletType, + ); + } + + /// stream of all wallets, \ + /// usecases can add new wallets dynamically + Stream> get walletsStream => _walletsSubject.stream; + + Wallet? get defaultWallet { + if (defaultWalletId == null) { + return null; + } + return _wallets.firstWhereOrNull((wallet) => wallet.id == defaultWalletId); + } + + Future _initializeWallet() async { + // load wallets from repository + final wallets = await _walletsRepository.getWallets(); + + for (final wallet in wallets) { + await _addWalletToMemory(wallet); + } + + // listen to wallet updates from usecases + _walletsUsecaseSubscription = + _walletsRepository.walletsUsecaseStream().listen((wallets) { + for (final wallet in wallets) { + if (!_wallets.any((w) => w.id == wallet.id)) { + addWallet(wallet); + } + } + }); + + _updateCombinedStreams(); + } + + void _updateCombinedStreams() { + // combine all wallet balances + final allBalances = + _walletsBalances.values.expand((balances) => balances).toList(); + _combinedBalancesSubject.add(allBalances); + + // combine all pending transactions + final allPending = _walletsPendingTransactions.values + .expand((transactions) => transactions) + .toList(); + _combinedPendingTransactionsSubject.add(allPending); + + // combine all recent transactions + final allRecent = _walletsRecentTransactions.values + .expand((transactions) => transactions) + .toList(); + _combinedRecentTransactionsSubject.add(allRecent); + } + + Future _addWalletToMemory(Wallet wallet) async { + // store wallet in memory + _wallets.add(wallet); + _walletsSubject.add(_wallets.toList()); + + // initialize empty data collections + _walletsBalances[wallet.id] = []; + _walletsPendingTransactions[wallet.id] = []; + _walletsRecentTransactions[wallet.id] = []; + + if (defaultWallet == null) { + setDefaultWallet(wallet.id); + } + } + + /// add a new wallet to the system + Future addWallet(Wallet wallet) async { + await _walletsRepository.addWallet(wallet); + await _addWalletToMemory(wallet); + _updateCombinedStreams(); + } + + /// remove wallet - persists on disk + Future removeWallet(String walletId) async { + await _walletsRepository.removeWallet(walletId); + + // clean up in-memory data + _wallets.removeWhere((wallet) => wallet.id == walletId); + _walletsBalances.remove(walletId); + _walletsPendingTransactions.remove(walletId); + _walletsRecentTransactions.remove(walletId); + + // clean up streams + _walletBalanceStreams[walletId]?.close(); + _walletPendingTransactionStreams[walletId]?.close(); + _walletRecentTransactionStreams[walletId]?.close(); + + _walletBalanceStreams.remove(walletId); + _walletPendingTransactionStreams.remove(walletId); + _walletRecentTransactionStreams.remove(walletId); + + // clean up subscriptions + _subscriptions[walletId]?.forEach((sub) => sub.cancel()); + _subscriptions.remove(walletId); + + // update wallets stream with the new list + _walletsSubject.add(_wallets.toList()); + + _updateCombinedStreams(); + + if (walletId == defaultWalletId) { + defaultWalletId = _wallets.isNotEmpty ? _wallets.first.id : null; + } + } + + /// set the default wallet to use by common operations \ + + void setDefaultWallet(String walletId) { + if (_wallets.any((wallet) => wallet.id == walletId)) { + defaultWalletId = walletId; + } else { + throw ArgumentError('Wallet with id $walletId does not exist.'); + } + } + + void _initBalanceStream(String id) { + if (_walletBalanceStreams[id] == null) { + _walletBalanceStreams[id] ??= BehaviorSubject>(); + final subscriptions = []; + subscriptions.add(_walletsRepository.getBalancesStream(id).listen((balances) { + _walletsBalances[id] = balances; + _walletBalanceStreams[id]?.add(balances); + _updateCombinedStreams(); + })); + if (_subscriptions[id] == null) { + _subscriptions[id] = subscriptions; + } else { + _subscriptions[id]?.addAll(subscriptions); + } + } + } + + void _initRecentTransactionStream(String id) { + if (_walletRecentTransactionStreams[id] == null) { + _walletRecentTransactionStreams[id] ??= + BehaviorSubject>(); + final subscriptions = []; + subscriptions.add(_walletsRepository.getRecentTransactionsStream(id).listen((transactions) { + transactions = transactions.where((tx) => tx.state.isDone).toList(); + _walletsRecentTransactions[id] = transactions; + _walletRecentTransactionStreams[id]?.add(transactions); + _updateCombinedStreams(); + })); + if (_subscriptions[id] == null) { + _subscriptions[id] = subscriptions; + } else { + _subscriptions[id]?.addAll(subscriptions); + } + } + } + + void _initPendingTransactionStream(String id) { + if (_walletPendingTransactionStreams[id] == null) { + _walletPendingTransactionStreams[id] ??= + BehaviorSubject>(); + final subscriptions = []; + subscriptions.add(_walletsRepository.getPendingTransactionsStream(id).listen((transactions) { + transactions = transactions.where((tx) => tx.state.isPending).toList(); + _walletsPendingTransactions[id] = transactions; + _walletPendingTransactionStreams[id]?.add(transactions); + _updateCombinedStreams(); + })); + if (_subscriptions[id] == null) { + _subscriptions[id] = subscriptions; + } else { + _subscriptions[id]?.addAll(subscriptions); + } + } + } + + Stream> getBalancesStream(String walletId) { + _initBalanceStream(walletId); + return _walletBalanceStreams[walletId]!.stream; + } + + Stream> getRecentTransactionsStream(String walletId) { + _initRecentTransactionStream(walletId); + return _walletRecentTransactionStreams[walletId]!.stream; + } + + Stream> getPendingTransactionsStream(String walletId) { + _initPendingTransactionStream(walletId); + return _walletPendingTransactionStreams[walletId]!.stream; + } + + int getBalance(String walletId, String unit) { + _initBalanceStream(walletId); + final balances = _walletsBalances[walletId]; + if (balances == null) { + return 0; + } + final balance = balances.firstWhereOrNull((balance) => balance.unit == unit); + return balance?.amount ?? 0; + } + + /// calculate combined balance for a specific currency + int getCombinedBalance(String unit) { + return _walletsBalances.values + .expand((balances) => balances) + .where((balance) => balance.unit == unit) + .fold(0, (sum, balance) => sum + balance.amount); + } + + /// get wallets that support a specific currency + List getWalletsForUnit(String unit) { + return _wallets + .where((wallet) => wallet.supportedUnits.any((u) => u == unit)) + .toList(); + } + + Future dispose() async { + final futures = []; + + _walletsUsecaseSubscription?.cancel(); + + // cancel all subscriptions + for (final subs in _subscriptions.values) { + for (final sub in subs) { + futures.add(sub.cancel()); + } + } + // close all streams + futures.addAll([ + _combinedBalancesSubject.close(), + _combinedPendingTransactionsSubject.close(), + _combinedRecentTransactionsSubject.close(), + ]); + + for (final stream in _walletBalanceStreams.values) { + futures.add(stream.close()); + } + for (final stream in _walletPendingTransactionStreams.values) { + futures.add(stream.close()); + } + for (final stream in _walletRecentTransactionStreams.values) { + futures.add(stream.close()); + } + + await Future.wait(futures); + + _wallets.clear(); + _walletsBalances.clear(); + _walletsPendingTransactions.clear(); + _walletsRecentTransactions.clear(); + _walletBalanceStreams.clear(); + _walletPendingTransactionStreams.clear(); + _walletRecentTransactionStreams.clear(); + _subscriptions.clear(); + defaultWalletId = null; + } + + /** + * here unified actions like zap, rcv ln (invoice) etc. + */ + + /// todo: just as an example + Future zap({ + required String pubkey, + required int amount, + String? comment, + }) { + return _walletsOperationsRepository.zap(); + } +} diff --git a/packages/ndk/lib/entities.dart b/packages/ndk/lib/entities.dart index 4b646ddd3..64475d722 100644 --- a/packages/ndk/lib/entities.dart +++ b/packages/ndk/lib/entities.dart @@ -31,3 +31,19 @@ export 'domain_layer/entities/user_relay_list.dart'; export 'domain_layer/entities/blossom_blobs.dart'; export 'domain_layer/entities/ndk_file.dart'; export 'domain_layer/entities/account.dart'; + +/// Cashu entities +export 'domain_layer/entities/cashu/cashu_keyset.dart'; +export 'domain_layer/entities/cashu/cashu_proof.dart'; +export 'domain_layer/entities/cashu/cashu_mint_info.dart'; +export 'domain_layer/entities/cashu/cashu_token.dart'; +export 'domain_layer/entities/cashu/cashu_user_seedphrase.dart'; + +/// Wallet entities +export 'domain_layer/entities/wallet/wallet.dart'; +export 'domain_layer/entities/wallet/wallet_transaction.dart'; +export 'domain_layer/entities/wallet/wallet_type.dart'; +export 'domain_layer/entities/wallet/wallet_balance.dart'; + +// testing +export 'domain_layer/usecases/wallets/wallets.dart'; diff --git a/packages/ndk/lib/ndk.dart b/packages/ndk/lib/ndk.dart index 44edab661..1641c3b21 100644 --- a/packages/ndk/lib/ndk.dart +++ b/packages/ndk/lib/ndk.dart @@ -78,6 +78,9 @@ export 'domain_layer/usecases/accounts/accounts.dart'; export 'domain_layer/usecases/files/blossom_user_server_list.dart'; export 'domain_layer/usecases/search/search.dart'; export 'domain_layer/usecases/gift_wrap/gift_wrap.dart'; +export 'domain_layer/usecases/cashu/cashu.dart'; +export 'domain_layer/usecases/cashu/cashu_seed.dart'; +export 'domain_layer/usecases/wallets/wallets.dart'; export 'domain_layer/usecases/bunkers/bunkers.dart'; export 'domain_layer/usecases/bunkers/models/bunker_connection.dart'; export 'domain_layer/usecases/bunkers/models/nostr_connect.dart'; diff --git a/packages/ndk/lib/presentation_layer/init.dart b/packages/ndk/lib/presentation_layer/init.dart index 8abafed0c..896230429 100644 --- a/packages/ndk/lib/presentation_layer/init.dart +++ b/packages/ndk/lib/presentation_layer/init.dart @@ -3,19 +3,26 @@ import '../shared/net/user_agent.dart'; import '../data_layer/data_sources/http_request.dart'; import '../data_layer/repositories/blossom/blossom_impl.dart'; +import '../data_layer/repositories/cashu/cashu_repo_impl.dart'; import '../data_layer/repositories/lnurl_http_impl.dart'; import '../data_layer/repositories/nip_05_http_impl.dart'; import '../data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart'; +import '../data_layer/repositories/wallets/wallets_operations_impl.dart'; +import '../data_layer/repositories/wallets/wallets_repo_impl.dart'; import '../domain_layer/entities/global_state.dart'; import '../domain_layer/entities/jit_engine_relay_connectivity_data.dart'; import '../domain_layer/repositories/blossom.dart'; +import '../domain_layer/repositories/cashu_repo.dart'; import '../domain_layer/repositories/lnurl_transport.dart'; import '../domain_layer/repositories/nip_05_repo.dart'; +import '../domain_layer/repositories/wallets_operations_repo.dart'; +import '../domain_layer/repositories/wallets_repo.dart'; import '../domain_layer/usecases/accounts/accounts.dart'; import '../domain_layer/usecases/broadcast/broadcast.dart'; import '../domain_layer/usecases/bunkers/bunkers.dart'; import '../domain_layer/usecases/cache_read/cache_read.dart'; import '../domain_layer/usecases/cache_write/cache_write.dart'; +import '../domain_layer/usecases/cashu/cashu.dart'; import '../domain_layer/usecases/connectivity/connectivity.dart'; import '../domain_layer/usecases/engines/network_engine.dart'; import '../domain_layer/usecases/files/blossom.dart'; @@ -35,6 +42,7 @@ import '../domain_layer/usecases/relay_sets_engine.dart'; import '../domain_layer/usecases/requests/requests.dart'; import '../domain_layer/usecases/search/search.dart'; import '../domain_layer/usecases/user_relay_lists/user_relay_lists.dart'; +import '../domain_layer/usecases/wallets/wallets.dart'; import '../domain_layer/usecases/zaps/zaps.dart'; import '../shared/logger/logger.dart'; import 'ndk_config.dart'; @@ -79,6 +87,8 @@ class Initialization { late Search search; late GiftWrap giftWrap; late Connectivy connectivity; + late Cashu cashu; + late Wallets wallets; late VerifyNip05 verifyNip05; @@ -139,6 +149,12 @@ class Initialization { client: _httpRequestDS, ); + final CashuRepo cashuRepo = CashuRepoImpl( + client: _httpRequestDS, + ); + + final WalletsOperationsRepo walletsOperationsRepo = WalletsOperationsImpl(); + /// use cases cacheWrite = CacheWrite(_ndkConfig.cache); cacheRead = CacheRead(_ndkConfig.cache); @@ -243,6 +259,23 @@ class Initialization { connectivity = Connectivy(relayManager); + cashu = Cashu( + cashuRepo: cashuRepo, + cacheManager: _ndkConfig.cache, + cashuUserSeedphrase: _ndkConfig.cashuUserSeedphrase, + ); + + final WalletsRepo walletsRepo = WalletsRepoImpl( + cashuUseCase: cashu, + nwcUseCase: nwc, + cacheManager: _ndkConfig.cache, + ); + + wallets = Wallets( + walletsRepository: walletsRepo, + walletsOperationsRepository: walletsOperationsRepo, + ); + /// set the user configured log level Logger.setLogLevel(_ndkConfig.logLevel); } diff --git a/packages/ndk/lib/presentation_layer/ndk.dart b/packages/ndk/lib/presentation_layer/ndk.dart index c37528ae7..b5b3b53d6 100644 --- a/packages/ndk/lib/presentation_layer/ndk.dart +++ b/packages/ndk/lib/presentation_layer/ndk.dart @@ -6,6 +6,7 @@ import '../domain_layer/entities/global_state.dart'; import '../domain_layer/usecases/accounts/accounts.dart'; import '../domain_layer/usecases/broadcast/broadcast.dart'; import '../domain_layer/usecases/bunkers/bunkers.dart'; +import '../domain_layer/usecases/cashu/cashu.dart'; import '../domain_layer/usecases/connectivity/connectivity.dart'; import '../domain_layer/usecases/files/blossom.dart'; import '../domain_layer/usecases/files/blossom_user_server_list.dart'; @@ -21,6 +22,7 @@ import '../domain_layer/usecases/relay_sets/relay_sets.dart'; import '../domain_layer/usecases/requests/requests.dart'; import '../domain_layer/usecases/search/search.dart'; import '../domain_layer/usecases/user_relay_lists/user_relay_lists.dart'; +import '../domain_layer/usecases/wallets/wallets.dart'; import '../domain_layer/usecases/zaps/zaps.dart'; import 'init.dart'; import 'ndk_config.dart'; @@ -147,6 +149,14 @@ class Ndk { @experimental Search get search => _initialization.search; + /// Cashu Wallet + @experimental // in development + Cashu get cashu => _initialization.cashu; + + /// Wallet combining all wallet accounts \ + @experimental + Wallets get wallets => _initialization.wallets; + /// Close all transports on relay manager Future destroy() async { final allFutures = [ diff --git a/packages/ndk/lib/presentation_layer/ndk_config.dart b/packages/ndk/lib/presentation_layer/ndk_config.dart index e0e08431e..72bf39165 100644 --- a/packages/ndk/lib/presentation_layer/ndk_config.dart +++ b/packages/ndk/lib/presentation_layer/ndk_config.dart @@ -1,8 +1,8 @@ -import 'package:ndk/config/broadcast_defaults.dart'; - import '../config/bootstrap_relays.dart'; +import '../config/broadcast_defaults.dart'; import '../config/logger_defaults.dart'; import '../config/request_defaults.dart'; +import '../domain_layer/entities/cashu/cashu_user_seedphrase.dart'; import '../domain_layer/entities/event_filter.dart'; import '../domain_layer/repositories/cache_manager.dart'; import '../domain_layer/repositories/event_verifier.dart'; @@ -47,6 +47,11 @@ class NdkConfig { /// value between 0.0 and 1.0 double defaultBroadcastConsiderDonePercent; + /// cashu user seed phrase, required for using cashu features \ + /// you can use CashuSeed.generateSeedPhrase() to generate a new seed phrase \ + /// Store this securely! Seed phrase allow full access to cashu funds! + final CashuUserSeedphrase? cashuUserSeedphrase; + /// log level LogLevel logLevel; @@ -63,6 +68,7 @@ class NdkConfig { /// [eventOutFilters] A list of filters to apply to the output stream (defaults to an empty list). \ /// [defaultQueryTimeout] The default timeout for queries (defaults to DEFAULT_QUERY_TIMEOUT). \ /// [logLevel] The log level for the NDK (defaults to warning). + /// [cashuUserSeedphrase] The cashu user seed phrase, required for using cashu features NdkConfig({ required this.eventVerifier, required this.cache, @@ -76,6 +82,7 @@ class NdkConfig { BroadcastDefaults.CONSIDER_DONE_PERCENT, this.logLevel = defaultLogLevel, this.userAgent = RequestDefaults.DEFAULT_USER_AGENT, + this.cashuUserSeedphrase, }); } diff --git a/packages/ndk/lib/shared/helpers/mutex_simple.dart b/packages/ndk/lib/shared/helpers/mutex_simple.dart new file mode 100644 index 000000000..c0aeddd57 --- /dev/null +++ b/packages/ndk/lib/shared/helpers/mutex_simple.dart @@ -0,0 +1,37 @@ +import 'dart:async'; +import 'dart:collection'; + +class MutexSimple { + final Queue> _waitQueue = Queue>(); + bool _isLocked = false; + + Future synchronized(Future Function() operation) async { + await _acquireLock(); + + try { + return await operation(); + } finally { + _releaseLock(); + } + } + + Future _acquireLock() async { + if (!_isLocked) { + _isLocked = true; + return; + } + + final completer = Completer(); + _waitQueue.add(completer); + await completer.future; + } + + void _releaseLock() { + if (_waitQueue.isNotEmpty) { + final nextCompleter = _waitQueue.removeFirst(); + nextCompleter.complete(); + } else { + _isLocked = false; + } + } +} diff --git a/packages/ndk/pubspec.lock b/packages/ndk/pubspec.lock index 04e19ae77..69d0b0d97 100644 --- a/packages/ndk/pubspec.lock +++ b/packages/ndk/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + ascii_qr: + dependency: "direct main" + description: + name: ascii_qr + sha256: "2046e400a0fa4ea0de5df44c87b992cdd1f76403bb15e64513b89263598750ae" + url: "https://pub.dev" + source: hosted + version: "1.0.1" async: dependency: transitive description: @@ -41,6 +49,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + bip32_keys: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: b5a0342220e7ee5aaf64d489a589bdee6ef8de22 + url: "https://github.com/1-leo/dart-bip32-keys" + source: git + version: "3.1.2" bip340: dependency: "direct main" description: @@ -49,6 +66,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + bip39_mnemonic: + dependency: "direct main" + description: + name: bip39_mnemonic + sha256: dd6bdfc2547d986b2c00f99bba209c69c0b6fa5c1a185e1f728998282f1249d5 + url: "https://pub.dev" + source: hosted + version: "4.0.1" boolean_selector: dependency: transitive description: @@ -57,6 +82,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + bs58check: + dependency: transitive + description: + name: bs58check + sha256: c4a164d42b25c2f6bc88a8beccb9fc7d01440f3c60ba23663a20a70faf484ea9 + url: "https://pub.dev" + source: hosted + version: "1.0.2" build: dependency: transitive description: @@ -113,6 +146,22 @@ packages: url: "https://pub.dev" source: hosted version: "8.11.0" + cbor: + dependency: "direct main" + description: + name: cbor + sha256: f5239dd6b6ad24df67d1449e87d7180727d6f43b87b3c9402e6398c7a2d9609b + url: "https://pub.dev" + source: hosted + version: "6.3.7" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -257,6 +306,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hex: + dependency: transitive + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" http: dependency: "direct main" description: @@ -289,6 +346,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + ieee754: + dependency: transitive + description: + name: ieee754 + sha256: "7d87451c164a56c156180d34a4e93779372edd191d2c219206100b976203128c" + url: "https://pub.dev" + source: hosted + version: "1.0.3" io: dependency: transitive description: @@ -417,6 +482,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" rxdart: dependency: "direct main" description: @@ -569,6 +642,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unorm_dart: + dependency: transitive + description: + name: unorm_dart + sha256: "8e3870a1caa60bde8352f9597dd3535d8068613269444f8e35ea8925ec84c1f5" + url: "https://pub.dev" + source: hosted + version: "0.3.1+1" vm_service: dependency: transitive description: diff --git a/packages/ndk/pubspec.yaml b/packages/ndk/pubspec.yaml index 39b5e0a20..4bb57a3c6 100644 --- a/packages/ndk/pubspec.yaml +++ b/packages/ndk/pubspec.yaml @@ -34,6 +34,13 @@ dependencies: cryptography: ^2.7.0 meta: ">=1.10.0 <2.0.0" xxh3: ^1.2.0 + ascii_qr: ^1.0.1 # Add ascii_qr dependency + cbor: ^6.3.7 + bip32_keys: + git: + url: https://github.com/1-leo/dart-bip32-keys + bip39_mnemonic: ^4.0.1 + dev_dependencies: build_runner: ^2.10.0 diff --git a/packages/ndk/test/cashu/cashu_dev_test.dart b/packages/ndk/test/cashu/cashu_dev_test.dart new file mode 100644 index 000000000..415266c0c --- /dev/null +++ b/packages/ndk/test/cashu/cashu_dev_test.dart @@ -0,0 +1,38 @@ +import 'package:http/http.dart' as http; +import 'package:ndk/data_layer/data_sources/http_request.dart'; +import 'package:ndk/data_layer/repositories/cashu/cashu_repo_impl.dart'; +import 'package:ndk/ndk.dart'; +import 'package:test/test.dart'; + +void main() { + setUp(() {}); + + group('dev tests', () { + test('fund', () async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + final mintUrl = 'http://127.0.0.1:8085'; + + final fundResponse = await ndk.cashu.initiateFund( + mintUrl: mintUrl, + amount: 52, + unit: 'sat', + method: 'bolt11', + ); + + print(fundResponse); + }); + + test('parse mint info', () async { + final mintUrl = 'http://127.0.0.1:8085'; + + final HttpRequestDS httpRequestDS = HttpRequestDS(http.Client()); + + final repo = CashuRepoImpl(client: httpRequestDS); + + final mintInfo = await repo.getMintInfo(mintUrl: mintUrl); + + print(mintInfo); + }); + }, skip: true); +} diff --git a/packages/ndk/test/cashu/cashu_fund_test.dart b/packages/ndk/test/cashu/cashu_fund_test.dart new file mode 100644 index 000000000..a8bd4dae6 --- /dev/null +++ b/packages/ndk/test/cashu/cashu_fund_test.dart @@ -0,0 +1,434 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:ndk/data_layer/data_sources/http_request.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_quote.dart'; +import 'package:ndk/domain_layer/usecases/cashu/cashu_keypair.dart'; +import 'package:ndk/entities.dart'; +import 'package:ndk/ndk.dart'; +import 'package:test/test.dart'; + +import 'cashu_spend_test.dart'; +import 'cashu_test_tools.dart'; +import 'mocks/cashu_http_client_mock.dart'; +import 'mocks/cashu_repo_mock.dart'; + +const devMintUrl = 'https://dev.mint.camelus.app'; +const failingMintUrl = 'https://mint.example.com'; + +void main() { + setUp(() {}); + + group('fund tests - exceptions ', () { + test('fund - invalid mint throws exception', () async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + expect( + () async => await ndk.cashu.initiateFund( + mintUrl: failingMintUrl, + amount: 52, + unit: 'sat', + method: 'bolt11', + ), + throwsA(isA()), + ); + }); + test('fund - no keyset throws exception', () async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + expect( + () async => await ndk.cashu.initiateFund( + mintUrl: devMintUrl, + amount: 52, + unit: 'nokeyset', + method: 'bolt11', + ), + throwsA(isA()), + ); + }); + + test('fund - retriveFunds no quote throws exception', () async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + final Stream response = ndk.cashu.retrieveFunds( + draftTransaction: CashuWalletTransaction( + id: 'test0', + walletId: '', + changeAmount: 5, + unit: 'sat', + walletType: WalletType.CASHU, + state: WalletTransactionState.draft, + mintUrl: devMintUrl, + qoute: null), + ); + + expect( + response, + emitsError(isA()), + ); + }); + + test('fund - retriveFunds exceptions', () async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + final baseDraftTransaction = CashuWalletTransaction( + id: 'test0', + walletId: '', + changeAmount: 5, + unit: 'sat', + walletType: WalletType.CASHU, + state: WalletTransactionState.draft, + mintUrl: devMintUrl, + qoute: null, + ); + + final Stream responseNoQuote = + ndk.cashu.retrieveFunds( + draftTransaction: baseDraftTransaction, + ); + + final Stream responseNoMethod = + ndk.cashu.retrieveFunds( + draftTransaction: baseDraftTransaction.copyWith( + qoute: CashuQuote( + quoteId: "quoteId", + request: "request", + amount: 5, + unit: 'sat', + state: CashuQuoteState.paid, + expiry: 0, + mintUrl: devMintUrl, + quoteKey: CashuKeypair.generateCashuKeyPair(), + ), + ), + ); + + final Stream responseNoKeysets = + ndk.cashu.retrieveFunds( + draftTransaction: baseDraftTransaction.copyWith( + method: "sat", + qoute: CashuQuote( + quoteId: "quoteId", + request: "request", + amount: 5, + unit: 'sat', + state: CashuQuoteState.paid, + expiry: 0, + mintUrl: devMintUrl, + quoteKey: CashuKeypair.generateCashuKeyPair(), + ), + ), + ); + + expect( + responseNoQuote, + emitsError(isA()), + ); + expect( + responseNoMethod, + emitsError(isA()), + ); + expect( + responseNoKeysets, + emitsError(isA()), + ); + }); + }); + + group('fund', () { + test("fund - initiateFund", () async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + const fundAmount = 5; + const fundUnit = "sat"; + + final draftTransaction = await ndk.cashu.initiateFund( + mintUrl: devMintUrl, + amount: fundAmount, + unit: fundUnit, + method: "bolt11", + ); + + expect(draftTransaction, isA()); + expect(draftTransaction.changeAmount, equals(fundAmount)); + expect(draftTransaction.unit, equals(fundUnit)); + expect(draftTransaction.mintUrl, equals(devMintUrl)); + expect(draftTransaction.state, equals(WalletTransactionState.draft)); + expect(draftTransaction.qoute, isA()); + expect(draftTransaction.qoute!.amount, equals(fundAmount)); + expect(draftTransaction.qoute!.unit, equals(fundUnit)); + expect(draftTransaction.qoute!.mintUrl, equals(devMintUrl)); + expect(draftTransaction.qoute!.state, equals(CashuQuoteState.unpaid)); + expect(draftTransaction.qoute!.request, isNotEmpty); + expect(draftTransaction.qoute!.quoteId, isNotEmpty); + expect(draftTransaction.qoute!.quoteKey, isA()); + expect(draftTransaction.qoute!.expiry, isA()); + expect(draftTransaction.method, equals("bolt11")); + expect(draftTransaction.usedKeysets!.length, greaterThan(0)); + expect(draftTransaction.transactionDate, isNull); + expect(draftTransaction.initiatedDate, isNotNull); + expect(draftTransaction.id, isNotEmpty); + }); + + test("fund - expired quote", () async { + const fundAmount = 5; + const fundUnit = "sat"; + const mockMintUrl = "http://mock.mint"; + + final myHttpMock = MockCashuHttpClient(); + + myHttpMock.setCustomResponse( + "POST", + "/v1/mint/quote/bolt11", + http.Response( + jsonEncode({ + "quote": "d00e6cbc-04c9-4661-8909-e47c19612bf0", + "request": + "lnbc50p1p5tctmqdqqpp5y7jyyyq3ezyu3p4c9dh6qpnjj6znuzrz35ernjjpkmw6lz7y2mxqsp59g4z52329g4z52329g4z52329g4z52329g4z52329g4z52329g4q9qrsgqcqzysl62hzvm9s5nf53gk22v5nqwf9nuy2uh32wn9rfx6grkjh6vr5jmy09mra5cna504azyhkd2ehdel9sm7fm72ns6ws2fk4m8cwc99hdgptq8hv4", + "amount": 5, + "unit": "sat", + "state": "UNPAID", + "expiry": 1757106960 + }), + 200, + headers: {'content-type': 'application/json'}, + )); + + myHttpMock.setCustomResponse( + "GET", + "/v1/mint/quote/bolt11/d00e6cbc-04c9-4661-8909-e47c19612bf0", + http.Response( + jsonEncode({ + "quote": "d00e6cbc-04c9-4661-8909-e47c19612bf0", + "request": + "lnbc50p1p5tctmqdqqpp5y7jyyyq3ezyu3p4c9dh6qpnjj6znuzrz35ernjjpkmw6lz7y2mxqsp59g4z52329g4z52329g4z52329g4z52329g4z52329g4z52329g4q9qrsgqcqzysl62hzvm9s5nf53gk22v5nqwf9nuy2uh32wn9rfx6grkjh6vr5jmy09mra5cna504azyhkd2ehdel9sm7fm72ns6ws2fk4m8cwc99hdgptq8hv4", + "amount": 5, + "unit": "sat", + "state": "UNPAID", + "expiry": 1757106960 + }), + 200, + headers: {'content-type': 'application/json'}, + )); + + final cashu = CashuTestTools.mockHttpCashu(customMockClient: myHttpMock); + + final draftTransaction = await cashu.initiateFund( + mintUrl: mockMintUrl, + amount: fundAmount, + unit: fundUnit, + method: "bolt11", + ); + + final transactionStream = + cashu.retrieveFunds(draftTransaction: draftTransaction); + + await expectLater( + transactionStream, + emitsInOrder([ + isA() + .having((t) => t.state, 'state', WalletTransactionState.pending), + isA() + .having((t) => t.state, 'state', WalletTransactionState.failed), + ]), + ); + // check balance + final allBalances = await cashu.getBalances(); + final balanceForMint = + allBalances.where((element) => element.mintUrl == mockMintUrl); + expect(balanceForMint.length, 1); + final balance = balanceForMint.first.balances[fundUnit]; + + expect(balance, equals(0)); + }); + + test("fund - mint err no signature", () async { + const fundAmount = 5; + const fundUnit = "sat"; + const mockMintUrl = "http://mock.mint"; + + final myHttpMock = MockCashuHttpClient(); + + final cashu = CashuTestTools.mockHttpCashu(customMockClient: myHttpMock); + + final draftTransaction = await cashu.initiateFund( + mintUrl: mockMintUrl, + amount: fundAmount, + unit: fundUnit, + method: "bolt11", + ); + + final transactionStream = + cashu.retrieveFunds(draftTransaction: draftTransaction); + + await expectLater( + transactionStream, + emitsInOrder([ + isA() + .having((t) => t.state, 'state', WalletTransactionState.pending), + emitsError(isA()), + ]), + ); + + //check balance + final allBalances = await cashu.getBalances(); + final balanceForMint = + allBalances.where((element) => element.mintUrl == mockMintUrl); + expect(balanceForMint.length, 1); + final balance = balanceForMint.first.balances[fundUnit]; + + expect(balance, equals(0)); + }); + test("fund - successfull", () async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + const fundAmount = 100; + const fundUnit = "sat"; + + final draftTransaction = await ndk.cashu.initiateFund( + mintUrl: devMintUrl, + amount: fundAmount, + unit: fundUnit, + method: "bolt11", + ); + final transactionStream = + ndk.cashu.retrieveFunds(draftTransaction: draftTransaction); + + await expectLater( + transactionStream, + emitsInOrder([ + isA() + .having((t) => t.state, 'state', WalletTransactionState.pending), + isA() + .having((t) => t.state, 'state', WalletTransactionState.completed) + .having((t) => t.transactionDate!, 'transactionDate', isA()), + ]), + ); + // check balance + final allBalances = await ndk.cashu.getBalances(); + final balanceForMint = + allBalances.where((element) => element.mintUrl == devMintUrl); + expect(balanceForMint.length, 1); + final balance = balanceForMint.first.balances[fundUnit]; + + expect(balance, equals(fundAmount)); + }); + + test("fund - successfull - e2e", () async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + const fundAmount = 250; + const fundUnit = "sat"; + + final draftTransaction = await ndk.cashu.initiateFund( + mintUrl: devMintUrl, + amount: fundAmount, + unit: fundUnit, + method: "bolt11", + ); + final transactionStream = + ndk.cashu.retrieveFunds(draftTransaction: draftTransaction); + + await expectLater( + transactionStream, + emitsInOrder([ + isA() + .having((t) => t.state, 'state', WalletTransactionState.pending), + isA() + .having((t) => t.state, 'state', WalletTransactionState.completed) + .having((t) => t.transactionDate!, 'transactionDate', isA()), + ]), + ); + // check balance + final allBalances = await ndk.cashu.getBalances(); + final balanceForMint = + allBalances.where((element) => element.mintUrl == devMintUrl); + expect(balanceForMint.length, 1); + final balance = balanceForMint.first.balances[fundUnit]; + + expect(balance, equals(fundAmount)); + + final spend200 = await ndk.cashu + .initiateSpend(mintUrl: devMintUrl, amount: 200, unit: "sat"); + final spend19 = await ndk.cashu + .initiateSpend(mintUrl: devMintUrl, amount: 18, unit: "sat"); + final spend31 = await ndk.cashu + .initiateSpend(mintUrl: devMintUrl, amount: 32, unit: "sat"); + + final spend200Token = spend200.token.toV4TokenString(); + final spend19Token = spend19.token.toV4TokenString(); + final spend31Token = spend31.token.toV4TokenString(); + + final allBalancesSpend = await ndk.cashu.getBalances(); + final balanceForMintSpend = + allBalancesSpend.where((element) => element.mintUrl == devMintUrl); + + final balanceSpend = balanceForMintSpend.first.balances[fundUnit]; + + expect(balanceSpend, equals(0)); + + final rcv = ndk.cashu.receive(spend200Token); + + await expectLater( + rcv, + emitsInOrder([ + isA() + .having((t) => t.state, 'state', WalletTransactionState.pending), + isA() + .having((t) => t.state, 'state', WalletTransactionState.completed) + .having((t) => t.transactionDate!, 'transactionDate', isA()), + ]), + ); + + final allBalancesRcv = await ndk.cashu.getBalances(); + final balanceForMintRcv = + allBalancesRcv.where((element) => element.mintUrl == devMintUrl); + + final balanceSpendRcv = balanceForMintRcv.first.balances[fundUnit]; + + expect(balanceSpendRcv, equals(200)); + }); + + test("fund - swap err, recovery of funds", () async { + final cache = MemCacheManager(); + + final myHttpMock = MockCashuHttpClient(); + + final cashuRepo = CashuRepoMock(client: HttpRequestDS(myHttpMock)); + + final cashu = Cashu(cashuRepo: cashuRepo, cacheManager: cache); + + await cache.saveProofs(proofs: [ + CashuProof( + keysetId: '00c726786980c4d9', + amount: 2, + secret: 'proof-s-2', + unblindedSig: '', + ), + CashuProof( + keysetId: '00c726786980c4d9', + amount: 4, + secret: 'proof-s-4', + unblindedSig: '', + ), + ], mintUrl: mockMintUrl); + + await expectLater( + () async => await cashu.initiateSpend( + mintUrl: mockMintUrl, + amount: 3, + unit: "sat", + ), + throwsA(isA()), + ); + + final proofs = await cache.getProofs(mintUrl: mockMintUrl); + expect(proofs.length, equals(2)); + + final pendingProofs = await cache.getProofs( + mintUrl: mockMintUrl, state: CashuProofState.pending); + expect(pendingProofs.length, equals(0)); + + final spendProofs = await cache.getProofs( + mintUrl: mockMintUrl, state: CashuProofState.spend); + expect(spendProofs.length, equals(0)); + }); + }); +} diff --git a/packages/ndk/test/cashu/cashu_proof_select_test.dart b/packages/ndk/test/cashu/cashu_proof_select_test.dart new file mode 100644 index 000000000..c5435734e --- /dev/null +++ b/packages/ndk/test/cashu/cashu_proof_select_test.dart @@ -0,0 +1,398 @@ +import 'package:ndk/domain_layer/entities/cashu/cashu_keyset.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_proof.dart'; +import 'package:ndk/domain_layer/usecases/cashu/cashu_proof_select.dart'; +import 'package:test/test.dart'; + +void main() { + setUp(() {}); + + List generateWalletKeyPairs(int length) { + return List.generate(length, (index) { + int amount = 1 << index; // 2^index: 1, 2, 4, 8, 16, 32, etc. + return CahsuMintKeyPair(amount: amount, pubkey: "pubkey${amount}"); + }); + } + + group('proof select', () { + final List myproofs = [ + CashuProof( + amount: 50, + keysetId: 'test-keyset', + secret: "proofSecret50-0", + unblindedSig: "", + ), + CashuProof( + amount: 4, + keysetId: 'test-keyset', + secret: "proofSecret4-0", + unblindedSig: "", + ), + CashuProof( + amount: 2, + keysetId: 'test-keyset', + secret: "proofSecret2-0", + unblindedSig: "", + ), + CashuProof( + amount: 50, + keysetId: 'test-keyset', + secret: "proofSecret50-1", + unblindedSig: "", + ), + CashuProof( + amount: 4, + keysetId: 'test-keyset', + secret: "proofSecret4-1", + unblindedSig: "", + ), + CashuProof( + amount: 2, + keysetId: 'test-keyset', + secret: "proofSecret2-1", + unblindedSig: "", + ), + CashuProof( + amount: 101, + keysetId: 'test-keyset', + secret: "proofSecret101-0", + unblindedSig: "", + ), + CashuProof( + amount: 1, + keysetId: 'test-keyset', + secret: "proofSecret1-0", + unblindedSig: "", + ), + CashuProof( + amount: 1, + keysetId: 'other-keyset', + secret: "proofSecret1-1", + unblindedSig: "", + ), + CashuProof( + amount: 2, + keysetId: 'other-keyset', + secret: "proofSecret2-2", + unblindedSig: "", + ), + ]; + + List keysets = [ + CahsuKeyset( + mintUrl: "debug", + unit: "test", + active: true, + id: 'test-keyset', + inputFeePPK: 1000, + mintKeyPairs: generateWalletKeyPairs(10).toSet(), + fetchedAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + CahsuKeyset( + mintUrl: "debug", + unit: "test", + active: false, + id: 'other-keyset', + inputFeePPK: 100, + mintKeyPairs: generateWalletKeyPairs(2).toSet(), + fetchedAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ]; + + test('split test - exact', () async { + final exact = CashuProofSelect.selectProofsForSpending( + proofs: myproofs, + keysets: keysets, + targetAmount: 50, + ); + expect(exact.selectedProofs.length, 2); // exact + fees + expect(exact.fees, 2); + expect(exact.selectedProofs.first.amount, 50); + expect(exact.selectedProofs.last.keysetId, "other-keyset"); + + expect(exact.totalSelected, 52); + expect(exact.needsSplit, false); + }); + + test('split test - insufficient', () { + expect( + () => CashuProofSelect.selectProofsForSpending( + proofs: myproofs, targetAmount: 9999999, keysets: keysets), + throwsA(isA())); + }); + + test('split test - combination', () { + const target = 52; + final combination = CashuProofSelect.selectProofsForSpending( + proofs: myproofs, + keysets: keysets, + targetAmount: target, + ); + expect(combination.selectedProofs.length, 2); + expect(combination.fees, 2); + expect(combination.selectedProofs.first.amount, 50); + expect(combination.selectedProofs.last.amount, 4); + + expect(combination.totalSelected - combination.fees, target); + expect(combination.needsSplit, false); + }); + + test('split test - combination - greedy', () { + const target = 103; + final combination = CashuProofSelect.selectProofsForSpending( + proofs: myproofs, + keysets: keysets, + targetAmount: target, + ); + expect(combination.selectedProofs.length, 2); + expect(combination.fees, 2); + expect(combination.totalSelected - combination.fees, target); + expect(combination.needsSplit, false); + }); + + test('split test - combination - split needed', () { + const target = 123; + final combination = CashuProofSelect.selectProofsForSpending( + proofs: myproofs, + keysets: keysets, + targetAmount: target, + ); + expect(combination.needsSplit, true); + expect(combination.totalSelected > target, isTrue); + expect( + combination.totalSelected - + combination.splitAmount - + combination.fees, + target); + }); + + test('fee calculation - mixed keysets', () { + final mixedProofs = [ + CashuProof( + amount: 10, + keysetId: 'test-keyset', + secret: "", + unblindedSig: ""), // 1000 ppk + CashuProof( + amount: 20, + keysetId: 'other-keyset', + secret: "", + unblindedSig: ""), // 100 ppk + CashuProof( + amount: 30, + keysetId: 'test-keyset', + secret: "", + unblindedSig: ""), // 1000 ppk + ]; + + final fees = CashuProofSelect.calculateFees(mixedProofs, keysets); + // 2100 ppk total = 3 sats (rounded up) + expect(fees, 3); + }); + + test('fee calculation - breakdown by keyset', () { + final mixedProofs = [ + CashuProof( + amount: 10, keysetId: 'test-keyset', secret: "", unblindedSig: ""), + CashuProof( + amount: 20, keysetId: 'other-keyset', secret: "", unblindedSig: ""), + CashuProof( + amount: 30, keysetId: 'test-keyset', secret: "", unblindedSig: ""), + ]; + + final breakdown = CashuProofSelect.calculateFeesWithBreakdown( + proofs: mixedProofs, + keysets: keysets, + ); + + expect(breakdown['totalFees'], 3); + expect(breakdown['totalPpk'], 2100); + expect(breakdown['feesByKeyset']['test-keyset'], 2); // 2000 ppk = 2 sats + expect(breakdown['feesByKeyset']['other-keyset'], 1); // 100 ppk = 1 sat + }); + + test('fee calculation - empty proofs', () { + final fees = CashuProofSelect.calculateFees([], keysets); + expect(fees, 0); + + final breakdown = CashuProofSelect.calculateFeesWithBreakdown( + proofs: [], + keysets: keysets, + ); + expect(breakdown['totalFees'], 0); + expect(breakdown['feesByKeyset'], isEmpty); + }); + + test('fee calculation - unknown keyset throws exception', () { + final invalidProofs = [ + CashuProof( + amount: 10, + keysetId: 'unknown-keyset', + secret: "", + unblindedSig: ""), + ]; + + expect( + () => CashuProofSelect.calculateFees(invalidProofs, keysets), + throwsA(isA()), + ); + }); + + test('proof sorting - amount priority', () { + final unsortedProofs = [ + CashuProof( + amount: 10, keysetId: 'test-keyset', secret: "", unblindedSig: ""), + CashuProof( + amount: 50, keysetId: 'test-keyset', secret: "", unblindedSig: ""), + CashuProof( + amount: 25, keysetId: 'test-keyset', secret: "", unblindedSig: ""), + ]; + + final sorted = + CashuProofSelect.sortProofsOptimally(unsortedProofs, keysets); + expect(sorted[0].amount, 50); + expect(sorted[1].amount, 25); + expect(sorted[2].amount, 10); + }); + + test('proof sorting - fee priority when amounts equal', () { + final equalAmountProofs = [ + CashuProof( + amount: 10, + keysetId: 'test-keyset', + secret: "", + unblindedSig: ""), // 1000 ppk + CashuProof( + amount: 10, + keysetId: 'other-keyset', + secret: "", + unblindedSig: ""), // 100 ppk + ]; + + final sorted = + CashuProofSelect.sortProofsOptimally(equalAmountProofs, keysets); + // Lower fee keyset should come first + expect(sorted[0].keysetId, 'other-keyset'); + expect(sorted[1].keysetId, 'test-keyset'); + }); + + test('active keyset selection', () { + final activeKeyset = CashuProofSelect.getActiveKeyset(keysets); + expect(activeKeyset?.id, 'test-keyset'); + expect(activeKeyset?.active, true); + }); + + test('selection with no keysets throws exception', () { + expect( + () => CashuProofSelect.selectProofsForSpending( + proofs: myproofs, + targetAmount: 50, + keysets: [], + ), + throwsA(isA()), + ); + }); + + test('selection prefers cheaper keysets', () { + final cheaperFirst = CashuProofSelect.selectProofsForSpending( + proofs: [ + CashuProof( + amount: 50, + keysetId: 'test-keyset', + secret: "", + unblindedSig: ""), // 1000 ppk + CashuProof( + amount: 50, + keysetId: 'other-keyset', + secret: "", + unblindedSig: ""), // 100 ppk + ], + targetAmount: 49, + keysets: keysets, + ); + + // Should prefer the cheaper keyset when amounts are equal + expect(cheaperFirst.selectedProofs.length, 1); + expect(cheaperFirst.selectedProofs.first.keysetId, 'other-keyset'); + expect(cheaperFirst.fees, 1); // 100 ppk = 1 sat + }); + + test('maximum iterations exceeded', () { + final manySmallProofs = List.generate( + 20, + (i) => CashuProof( + amount: 1, + keysetId: 'test-keyset', + secret: "", + unblindedSig: "")); + + expect( + () => CashuProofSelect.selectProofsForSpending( + proofs: manySmallProofs, + targetAmount: 50, + keysets: keysets, + maxIterations: 3, + ), + throwsA(isA()), + ); + }); + + test('fee breakdown accuracy', () { + final mixedProofs = [ + CashuProof( + amount: 10, + keysetId: 'test-keyset', + secret: "proofSecret10-0", + unblindedSig: ""), // 1000 ppk + CashuProof( + amount: 20, + keysetId: 'test-keyset', + secret: "proofSecret20-0", + unblindedSig: ""), // 1000 ppk + CashuProof( + amount: 30, + keysetId: 'other-keyset', + secret: "proofSecret30-0", + unblindedSig: ""), // 100 ppk + CashuProof( + amount: 40, + keysetId: 'other-keyset', + secret: "proofSecret40-0", + unblindedSig: ""), // 100 ppk + ]; + + final result = CashuProofSelect.selectProofsForSpending( + proofs: mixedProofs, + targetAmount: 90, + keysets: keysets, + ); + + // Total: 2200 ppk = 3 sats + expect(result.fees, 3); + expect(result.feesByKeyset['test-keyset'], 2); // 2000 ppk = 2 sats + expect(result.feesByKeyset['other-keyset'], 1); // 200 ppk = 1 sat + expect(result.totalSelected, 100); + expect(result.needsSplit, true); + expect(result.splitAmount, 7); // 100 - 90 - 3 = 7 + }); + + test('single sat amounts with high fees - impossible', () { + final singleSatProofs = List.generate( + 11, + (i) => CashuProof( + amount: 1, + keysetId: 'test-keyset', + secret: "", + unblindedSig: "")); + + // fee for each is 1 + 1 sat => never enough to spend + + expect( + () => CashuProofSelect.selectProofsForSpending( + proofs: singleSatProofs, + targetAmount: 1, + keysets: keysets, + ), + throwsA(isA())); + }); + }); +} diff --git a/packages/ndk/test/cashu/cashu_receive_test.dart b/packages/ndk/test/cashu/cashu_receive_test.dart new file mode 100644 index 000000000..526d86802 --- /dev/null +++ b/packages/ndk/test/cashu/cashu_receive_test.dart @@ -0,0 +1,117 @@ +import 'package:http/http.dart' as http; +import 'package:ndk/data_layer/data_sources/http_request.dart'; +import 'package:ndk/data_layer/repositories/cashu/cashu_repo_impl.dart'; +import 'package:ndk/entities.dart'; +import 'package:ndk/ndk.dart'; +import 'package:test/test.dart'; + +import 'cashu_test_tools.dart'; + +const devMintUrl = 'https://dev.mint.camelus.app'; +const failingMintUrl = 'https://mint.example.com'; +const mockMintUrl = "https://mock.mint"; + +void main() { + setUp(() {}); + + group('receive tests - exceptions ', () { + test("invalid token", () { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + final rcvStream = ndk.cashu.receive("cashuBinvalidtoken"); + expect( + () async => await rcvStream.last, + throwsA(isA()), + ); + }); + + test("empty token", () async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + final rcvStream = ndk.cashu.receive( + "cashuBo2FteBxodHRwczovL2Rldi5taW50LmNhbWVsdXMuYXBwYXVjc2F0YXSBomFpQGFwgaRhYQBhc2BhY0BhZKNhZUBhc0BhckA"); + + expect( + () async => await rcvStream.last, + throwsA(isA()), + ); + }); + + test("invalid mint", () async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + final rcvStream = ndk.cashu.receive( + "cashuBo2FtdGh0dHBzOi8vbWludC5pbnZhbGlkYXVjc2F0YXSBomFpSABV3vjPJfyNYXCBpGFhAWFzeEBmYmMxYWY4ZTk1YWQyZTVjMGQzY2U3MTMxNjI3MDBkOGNmN2NhNDQ2Njc1ZTE5NTc0NWE5ZWYzMDI1Zjc0NjdhYWNYIQJYTRSL3snLOVtf2OECtcqM_y7kG1VCQnVeWc9BPzP4zGFko2FlWCAlHMDORr2HAR0NNMsV4tB3s09bCB_s35QvHIEVkqed3mFzWCBLAh8gJ0J0uv7WzGkFC9gn4jZc7sFTpZvEgnitZ6ijrGFyWCC9QCslHjMWBU_2TWwnUNXj-rM7-iP6_8RqxiJMsa1Dcg"); + + expect( + () async => await rcvStream.last, + throwsA(isA()), + ); + }); + }); + + group('receive', () { + test("receive integration, double spend", () async { + final cache = MemCacheManager(); + final cache2 = MemCacheManager(); + + final client = HttpRequestDS(http.Client()); + final cashuRepo = CashuRepoImpl(client: client); + final cashuRepo2 = CashuRepoImpl(client: client); + final cashu = Cashu(cashuRepo: cashuRepo, cacheManager: cache); + + final cashu2 = Cashu(cashuRepo: cashuRepo2, cacheManager: cache2); + + const fundAmount = 32; + const fundUnit = "sat"; + + final draftTransaction = await cashu.initiateFund( + mintUrl: devMintUrl, + amount: fundAmount, + unit: fundUnit, + method: "bolt11", + ); + final transactionStream = + cashu.retrieveFunds(draftTransaction: draftTransaction); + + final transaction = await transactionStream.last; + expect(transaction.state, WalletTransactionState.completed); + + final spending = await cashu.initiateSpend( + mintUrl: devMintUrl, + amount: 16, + unit: fundUnit, + ); + final token = spending.token.toV4TokenString(); + + final rcvStream = cashu2.receive(token); + + await expectLater( + rcvStream, + emitsInOrder( + [ + isA().having( + (t) => t.state, 'state', WalletTransactionState.pending), + isA() + .having( + (t) => t.state, 'state', WalletTransactionState.completed) + .having( + (t) => t.transactionDate!, 'transactionDate', isA()), + ], + )); + + final balance = + await cashu2.getBalanceMintUnit(unit: fundUnit, mintUrl: devMintUrl); + + expect(balance, equals(16)); + + // try to double spend the same token + final rcvStream2 = cashu2.receive(token); + + expect( + () async => await rcvStream2.last, + throwsA(isA()), + ); + }); + }); +} diff --git a/packages/ndk/test/cashu/cashu_redeem_test.dart b/packages/ndk/test/cashu/cashu_redeem_test.dart new file mode 100644 index 000000000..bc63c3082 --- /dev/null +++ b/packages/ndk/test/cashu/cashu_redeem_test.dart @@ -0,0 +1,163 @@ +import 'package:ndk/domain_layer/entities/cashu/cashu_quote.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_quote_melt.dart'; +import 'package:ndk/entities.dart'; +import 'package:ndk/ndk.dart'; +import 'package:test/test.dart'; + +import 'cashu_test_tools.dart'; +import 'mocks/cashu_http_client_mock.dart'; + +const devMintUrl = 'https://dev.mint.camelus.app'; +const failingMintUrl = 'https://mint.example.com'; +const mockMintUrl = "https://mock.mint"; + +void main() { + setUp(() {}); + + group('redeem tests - exceptions ', () { + test("invalid mint url", () async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + expect( + () async => await ndk.cashu.initiateRedeem( + mintUrl: failingMintUrl, + request: "request", + unit: "sat", + method: "bolt11", + ), + throwsA(isA()), + ); + }); + + test("malformed melt quote", () async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + final draftTransaction = CashuWalletTransaction( + id: "testId", + walletId: devMintUrl, + changeAmount: -1, + unit: "sat", + walletType: WalletType.CASHU, + state: WalletTransactionState.draft, + mintUrl: devMintUrl, + ); + + final redeemStream = + ndk.cashu.redeem(draftRedeemTransaction: draftTransaction); + + await expectLater( + () async => await redeemStream.last, + throwsA(isA()), + ); + + final dTwithQuote = draftTransaction.copyWith( + qouteMelt: CashuQuoteMelt( + quoteId: '', + amount: 1, + feeReserve: null, + paid: false, + expiry: null, + mintUrl: '', + state: CashuQuoteState.unpaid, + unit: '', + request: '', + ), + ); + final redeemStream2 = + ndk.cashu.redeem(draftRedeemTransaction: dTwithQuote); + + await expectLater( + () async => await redeemStream2.last, + throwsA(isA()), + ); + + // missing request + final dTwithQuoteAndMethod = dTwithQuote.copyWith(method: "bolt11"); + final redeemStream3 = + ndk.cashu.redeem(draftRedeemTransaction: dTwithQuoteAndMethod); + + await expectLater( + () async => await redeemStream3.last, + throwsA(isA()), + ); + + final complete = dTwithQuoteAndMethod.copyWith( + mintUrl: mockMintUrl, + method: "bolt11", + qouteMelt: CashuQuoteMelt( + quoteId: '', + amount: 1, + feeReserve: null, + paid: false, + expiry: null, + mintUrl: devMintUrl, + state: CashuQuoteState.unpaid, + unit: 'sat', + request: 'lnbc1...', + ), + ); + final redeemStream4 = ndk.cashu.redeem(draftRedeemTransaction: complete); + + // no host found (mock.mint) + await expectLater( + () async => await redeemStream4.last, + throwsA(isA()), + ); + }); + }); + + group('redeem', () { + test("redeem mock", () async { + final cache = MemCacheManager(); + + final myHttpMock = MockCashuHttpClient(); + + final mockRequest = "lnbc1..."; + + final cashu = CashuTestTools.mockHttpCashu( + customMockClient: myHttpMock, customCache: cache); + + await cache.saveProofs(proofs: [ + CashuProof( + keysetId: '00c726786980c4d9', + amount: 1, + secret: 'proof-s-1', + unblindedSig: '', + ), + CashuProof( + keysetId: '00c726786980c4d9', + amount: 2, + secret: 'proof-s-2', + unblindedSig: '', + ), + CashuProof( + keysetId: '00c726786980c4d9', + amount: 4, + secret: 'proof-s-4', + unblindedSig: '', + ), + ], mintUrl: mockMintUrl); + + final meltQuoteTransaction = await cashu.initiateRedeem( + mintUrl: mockMintUrl, + request: mockRequest, + unit: "sat", + method: "bolt11", + ); + + final redeemStream = + cashu.redeem(draftRedeemTransaction: meltQuoteTransaction); + + expectLater( + redeemStream, + emitsInOrder( + [ + isA().having( + (p0) => p0.state, 'state', WalletTransactionState.pending), + isA().having( + (p0) => p0.state, 'state', WalletTransactionState.completed), + ], + )); + }); + }); +} diff --git a/packages/ndk/test/cashu/cashu_seed_test.dart b/packages/ndk/test/cashu/cashu_seed_test.dart new file mode 100644 index 000000000..70aaed951 --- /dev/null +++ b/packages/ndk/test/cashu/cashu_seed_test.dart @@ -0,0 +1,85 @@ +import 'package:bip39_mnemonic/bip39_mnemonic.dart'; +import 'package:ndk/domain_layer/usecases/cashu/cashu_seed.dart'; +import 'package:ndk/entities.dart'; +import 'package:test/test.dart'; + +void main() { + setUp(() {}); + + final mnemonic = + "half depart obvious quality work element tank gorilla view sugar picture humble"; + + final keysetId = "009a1f293253e41e"; + + final expectedSecrets = { + "secret_0": + "485875df74771877439ac06339e284c3acfcd9be7abf3bc20b516faeadfe77ae", + "secret_1": + "8f2b39e8e594a4056eb1e6dbb4b0c38ef13b1b2c751f64f810ec04ee35b77270", + "secret_2": + "bc628c79accd2364fd31511216a0fab62afd4a18ff77a20deded7b858c9860c8", + "secret_3": + "59284fd1650ea9fa17db2b3acf59ecd0f2d52ec3261dd4152785813ff27a33bf", + "secret_4": + "576c23393a8b31cc8da6688d9c9a96394ec74b40fdaf1f693a6bb84284334ea0" + }; + + final expectedBlindingFactors = { + "r_0": "ad00d431add9c673e843d4c2bf9a778a5f402b985b8da2d5550bf39cda41d679", + "r_1": "967d5232515e10b81ff226ecf5a9e2e2aff92d66ebc3edf0987eb56357fd6248", + "r_2": "b20f47bb6ae083659f3aa986bfa0435c55c6d93f687d51a01f26862d9b9a4899", + "r_3": "fb5fca398eb0b1deb955a2988b5ac77d32956155f1c002a373535211a2dfdc29", + "r_4": "5f09bfbfe27c439a597719321e061e2e40aad4a36768bb2bcc3de547c9644bf9" + }; + + group('CashuSeedTests', () { + test("keysetIdToInt", () { + final result = CashuSeed.keysetIdToInt("009a1f293253e41e"); + + expect(result, equals(864559728)); + }); + + test('deriveSecret', () async { + final seed = + CashuSeed(userSeedPhrase: CashuUserSeedphrase(seedPhrase: mnemonic)); + for (int i = 0; i < 5; i++) { + final result = seed.deriveSecret(counter: i, keysetId: keysetId); + + expect(result.secretHex, equals(expectedSecrets["secret_$i"])); + expect(result.blindingHex, equals(expectedBlindingFactors["r_$i"])); + } + }); + + test('throw without mnemonic', () async { + final seed = CashuSeed(); + + expect( + () => seed.deriveSecret(counter: 0, keysetId: keysetId), + throwsA(isA()), + ); + }); + + test('setting mnemonic', () async { + final seed = CashuSeed(); + seed.setSeedPhrase(seedPhrase: mnemonic); + + final result = seed.deriveSecret(counter: 0, keysetId: keysetId); + + expect(result.secretHex, equals(expectedSecrets["secret_0"])); + expect(result.blindingHex, equals(expectedBlindingFactors["r_0"])); + }); + + test('generating seedPhrase', () async { + final generated = CashuSeed.generateSeedPhrase( + length: MnemonicLength.words24, + ); + expect(generated.split(' ').length, equals(24)); + + final seed = + CashuSeed(userSeedPhrase: CashuUserSeedphrase(seedPhrase: generated)); + final result = seed.deriveSecret(counter: 0, keysetId: keysetId); + expect(result.secretHex.length, equals(64)); + expect(result.blindingHex.length, equals(64)); + }); + }); +} diff --git a/packages/ndk/test/cashu/cashu_spend_test.dart b/packages/ndk/test/cashu/cashu_spend_test.dart new file mode 100644 index 000000000..edb70f14c --- /dev/null +++ b/packages/ndk/test/cashu/cashu_spend_test.dart @@ -0,0 +1,168 @@ +import 'package:http/http.dart' as http; +import 'package:ndk/data_layer/data_sources/http_request.dart'; +import 'package:ndk/data_layer/repositories/cashu/cashu_repo_impl.dart'; +import 'package:ndk/entities.dart'; +import 'package:ndk/ndk.dart'; +import 'package:test/test.dart'; + +import 'cashu_test_tools.dart'; + +const devMintUrl = 'https://dev.mint.camelus.app'; +const failingMintUrl = 'https://mint.example.com'; +const mockMintUrl = "https://mock.mint"; + +void main() { + setUp(() {}); + + group('spend tests - exceptions ', () { + test("spend - amount", () { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + expect( + () async => await ndk.cashu.initiateSpend( + mintUrl: mockMintUrl, + amount: -54444, + unit: 'sat', + ), + throwsA(isA()), + ); + }); + + test("spend - no unit for mint", () { + final cashu = CashuTestTools.mockHttpCashu(); + + expect( + () async => await cashu.initiateSpend( + mintUrl: mockMintUrl, + amount: 50, + unit: 'voidunit', + ), + throwsA(isA()), + ); + }); + + test("spend - not enouth funds", () async { + final cache = MemCacheManager(); + + await cache.saveKeyset( + CahsuKeyset( + id: 'testKeyset', + mintUrl: mockMintUrl, + unit: 'sat', + active: true, + inputFeePPK: 0, + mintKeyPairs: { + CahsuMintKeyPair( + amount: 1, + pubkey: 'testPubKey-1', + ), + CahsuMintKeyPair( + amount: 2, + pubkey: 'testPubKey-2', + ), + CahsuMintKeyPair( + amount: 4, + pubkey: 'testPubKey-2', + ), + }, + ), + ); + + await cache.saveProofs( + proofs: [ + CashuProof( + keysetId: 'testKeyset', + amount: 1, + secret: 'testSecret-32', + unblindedSig: '', + ) + ], + mintUrl: mockMintUrl, + ); + + final cashu = CashuTestTools.mockHttpCashu(customCache: cache); + + expect( + () async => await cashu.initiateSpend( + mintUrl: mockMintUrl, + amount: 4, + unit: 'sat', + ), + throwsA(isA()), + ); + }); + }); + + group('spend', () { + test("spend - initiateSpend", () async { + final cache = MemCacheManager(); + final cache2 = MemCacheManager(); + + final client = HttpRequestDS(http.Client()); + final cashuRepo = CashuRepoImpl(client: client); + final cashuRepo2 = CashuRepoImpl(client: client); + final cashu = Cashu(cashuRepo: cashuRepo, cacheManager: cache); + + final cashu2 = Cashu(cashuRepo: cashuRepo2, cacheManager: cache2); + + const fundAmount = 32; + const fundUnit = "sat"; + + final draftTransaction = await cashu.initiateFund( + mintUrl: devMintUrl, + amount: fundAmount, + unit: fundUnit, + method: "bolt11", + ); + final transactionStream = + cashu.retrieveFunds(draftTransaction: draftTransaction); + + final transaction = await transactionStream.last; + expect(transaction.state, WalletTransactionState.completed); + + final spendWithoutSplit = await cashu.initiateSpend( + mintUrl: devMintUrl, + amount: 3, + unit: fundUnit, + ); + + final spendwithSplit = await cashu.initiateSpend( + mintUrl: devMintUrl, + amount: 1, + unit: fundUnit, + ); + + expect(spendWithoutSplit.token.toV4TokenString(), isNotEmpty); + expect(spendwithSplit.token.toV4TokenString(), isNotEmpty); + + /// rcv on other party + + final rcvStream = cashu2.receive(spendwithSplit.token.toV4TokenString()); + await rcvStream.last; + + /// check transaction completion on rcv + + final myCompletedTransaction = await cashu.latestTransactions.stream + // first is the funding + .where((transactions) => transactions.length >= 2) + .take(1) + .first; + + expect(myCompletedTransaction, isNotEmpty); + expect( + myCompletedTransaction.last.state, WalletTransactionState.completed); + expect(myCompletedTransaction.last.transactionDate, isNotNull); + + final balance = + await cashu.getBalanceMintUnit(unit: "sat", mintUrl: devMintUrl); + expect(balance, equals(fundAmount - 4)); + + final pendingProofs = + await cache.getProofs(state: CashuProofState.pending); + expect(pendingProofs, isEmpty); + + final spendProofs = await cache.getProofs(state: CashuProofState.spend); + expect(spendProofs, isNotEmpty); + }); + }); +} diff --git a/packages/ndk/test/cashu/cashu_storage_test.dart b/packages/ndk/test/cashu/cashu_storage_test.dart new file mode 100644 index 000000000..234bba4cb --- /dev/null +++ b/packages/ndk/test/cashu/cashu_storage_test.dart @@ -0,0 +1,50 @@ +import 'package:ndk/domain_layer/entities/cashu/cashu_proof.dart'; +import 'package:ndk/ndk.dart'; +import 'package:test/test.dart'; + +void main() { + setUp(() {}); + + group('dev tests', () { + test('proof storage upsert test - memCache', () async { + CacheManager cacheManager = MemCacheManager(); + + final proof1 = CashuProof( + keysetId: 'testKeysetId', + amount: 10, + secret: "secret1", + unblindedSig: 'testSig1', + state: CashuProofState.unspend, + ); + + final proof2 = CashuProof( + keysetId: 'testKeysetId', + amount: 2, + secret: "secret2", + unblindedSig: 'testSig2', + state: CashuProofState.unspend, + ); + + final List proofs = [ + proof1, + proof2, + ]; + + await cacheManager.saveProofs(proofs: proofs, mintUrl: "testmint"); + + proof1.state = CashuProofState.pending; + + await cacheManager.saveProofs(proofs: [proof1], mintUrl: "testmint"); + + final loadedProofs = await cacheManager.getProofs( + mintUrl: "testmint", + state: CashuProofState.unspend, + ); + + expect(loadedProofs.length, equals(1)); + expect(loadedProofs[0].state, equals(CashuProofState.unspend)); + + expect(loadedProofs[0].amount, equals(2)); + }); + }); +} diff --git a/packages/ndk/test/cashu/cashu_test_tools.dart b/packages/ndk/test/cashu/cashu_test_tools.dart new file mode 100644 index 000000000..51394bb21 --- /dev/null +++ b/packages/ndk/test/cashu/cashu_test_tools.dart @@ -0,0 +1,29 @@ +import 'package:ndk/data_layer/data_sources/http_request.dart'; +import 'package:ndk/data_layer/repositories/cashu/cashu_repo_impl.dart'; +import 'package:ndk/domain_layer/repositories/cashu_repo.dart'; +import 'package:ndk/ndk.dart'; + +import 'mocks/cashu_http_client_mock.dart'; + +class CashuTestTools { + static Cashu mockHttpCashu({ + MockCashuHttpClient? customMockClient, + CacheManager? customCache, + }) { + final MockCashuHttpClient mockClient = + customMockClient ?? MockCashuHttpClient(); + final HttpRequestDS httpRequestDS = HttpRequestDS(mockClient); + + final CashuRepo cashuRepo = CashuRepoImpl( + client: httpRequestDS, + ); + + final CacheManager cache = customCache ?? MemCacheManager(); + + final cashu = Cashu( + cashuRepo: cashuRepo, + cacheManager: cache, + ); + return cashu; + } +} diff --git a/packages/ndk/test/cashu/mocks/cashu_http_client_mock.dart b/packages/ndk/test/cashu/mocks/cashu_http_client_mock.dart new file mode 100644 index 000000000..4ae7d91eb --- /dev/null +++ b/packages/ndk/test/cashu/mocks/cashu_http_client_mock.dart @@ -0,0 +1,378 @@ +import 'package:http/http.dart' as http; + +import 'dart:convert'; + +class MockCashuHttpClient extends http.BaseClient { + final Map _responses = {}; + final List capturedRequests = []; + + MockCashuHttpClient() { + _setupDefaultResponses(); + } + + void _setupDefaultResponses() { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + _responses['GET:/v1/info'] = http.Response( + jsonEncode({ + "name": "testmint1", + "version": "cdk-mintd/0.10.1", + "description": "", + "nuts": { + "4": { + "methods": [ + { + "method": "bolt11", + "unit": "sat", + "min_amount": 1, + "max_amount": 500000, + "description": true + } + ], + "disabled": false + }, + "5": { + "methods": [ + { + "method": "bolt11", + "unit": "sat", + "min_amount": 1, + "max_amount": 500000 + } + ], + "disabled": false + }, + "7": {"supported": true}, + "8": {"supported": true}, + "9": {"supported": true}, + "10": {"supported": true}, + "11": {"supported": true}, + "12": {"supported": true}, + "14": {"supported": true}, + "15": { + "methods": [ + {"method": "bolt11", "unit": "sat"}, + ] + }, + "17": { + "supported": [ + { + "method": "bolt11", + "unit": "sat", + "commands": [ + "bolt11_mint_quote", + "bolt11_melt_quote", + "proof_state" + ] + } + ] + }, + "19": { + "ttl": 60, + "cached_endpoints": [ + {"method": "POST", "path": "/v1/mint/bolt11"}, + {"method": "POST", "path": "/v1/melt/bolt11"}, + {"method": "POST", "path": "/v1/swap"} + ] + }, + "20": {"supported": true} + }, + "motd": "Hello world", + "time": 1757162808 + }), + 200, + headers: {'content-type': 'application/json'}, + ); + + // Mock keysets response + _responses['GET:/v1/keysets'] = http.Response( + jsonEncode({ + "keysets": [ + { + "id": "00c726786980c4d9", + "unit": "sat", + "active": true, + "input_fee_ppk": 0 + } + ] + }), + 200, + headers: {'content-type': 'application/json'}, + ); + + // Mock keys response + _responses['GET:/v1/keys'] = http.Response( + jsonEncode({ + "keysets": [ + { + "id": "00c726786980c4d9", + "unit": "sat", + "keys": { + "1": + "02e67dd580169fb31cda6fe475581937a3a04bb0b422c624bfd6eeee1f6da6fa3c", + "1024": + "028fb71ffae7afbb43d04a8992b4ea268b0d9aff0921d0abed85ccd2099821b80a", + "1048576": + "03556358859d99dca7e6672bac4f12afcf88d0feee2c834800e917d0223b4e37a9", + "1073741824": + "031f80815097783a515dbf515578e35542f5675ce92caa158c95db64e68571ad5d", + "128": + "03ac769f6d6d4ca6ef83549bf8535280ff70676f45045df782ac20e80f37f0012f", + "131072": + "03c711b7a810fc4c8bb42f3ae1d2259cd29cde521fbfd1a96053b7828c8b34a22a", + "134217728": + "027e651c25fbcfaf2dc5ef9f6a09286df5d19d07c7c894a2accd2a2a8602f7f6f6", + "16": + "03de6848400b656115c502fcf6bc27266ee372b4e256cb337e10e5ee897380f80a", + "16384": + "03e5ce94bb082f26cb0978f65dab72acababe8f6691a9cf88dbaede223a0221925", + "16777216": + "03572b3fed165a371f54563fadef13f9bc8cfd7841ff200f049a3c57226bd0e0b2", + "2": + "03633147c9a55a7d354ab15960675981e0c879d969643cea7da6889963afb90d3d", + "2048": + "028644efab3ad590188508f7d3c462b57d592932c5a726b9311342003ec52a084b", + "2097152": + "02df9890ef6ecd31146660485a1102979cf6365a80025636ba8c8c1a9a36a0ba89", + "2147483648": + "02cc0f4252dc5f4672b863a261b3f7630bd65bd3201580cfff75de6634807c12b3", + "256": + "03e4b4bb96562a4855a88814a8659eb7f7eab2df7639d7b646086d5dbe3ae0c7e2", + "262144": + "0369d8da14ce6fcd33aa3aff9467b013aad9da5e12515bc6079bebf0da85daea5b", + "268435456": + "03ca1e98639a08c2e3724711bbce00fd6a427c27a9b0b6cf7f327b44918b5c43c6", + "32": + "030f589815a72b4c2fa4fe728e8b38a102228ef1eb490085d92d567d6ec8c97c09", + "32768": + "0353fd1089a6ff7db8c15f82e4c767789e76b0995da1ede24e7243f33b8301d082", + "33554432": + "0247abfe7eddd1f55e6c0f8e01c6160bde9b3fc98ee9413f192817a472e0abfcc8", + "4": + "0393db23532a95b722da09168f39010561babd79c73e63313890ac6fd5e100e6ac", + "4096": + "02718e4fca012601ebb320459fb57607ef9942f901683bf54ab6d6ec2eba2a523d", + "4194304": + "020030f75e5a64e7e09150cba9e2572720504d37d438d00b22b16434c7676617e3", + "512": + "032b95061460afaebdd0d9f3bad6c1d18dbb738b5daacf64a517e131d47133aad1", + "524288": + "02d4ebcf9edec380d52b7190f77d44e6fd7fc195f0f60df656772c74775f2ef653", + "536870912": + "02775c491ed9705b84fc0d1dd3abfec5e75fad5b1109240c67ae655b0024c06d1b", + "64": + "03d3bab9f316f22c6ceebafb73d9506a9d43863c453a2d3bf7940a0b1e8bba0fb9", + "65536": + "03344277ddad3a2cf49c21c5cac9ea620bb24f48fb534abd1539c4a5f3aa620749", + "67108864": + "034824c572029074aa94de30fba1bfaa3e9f090da0d1166888dfd11285f1a7874d", + "8": + "02ce8f0423a31496b3a405ce472a19f270dc526330d767beae8ec43a4812cbe43b", + "8192": + "02e47c542ba5c3664a839e6c5e3968b69916ae9e9387b8eaf973897f4f4ff9de72", + "8388608": + "02c7690d8e9032602cb29f4b0123bf8131dd58f42c0d4f457491b33594181a87c7" + } + } + ] + }), + 200, + headers: {'content-type': 'application/json'}, + ); + + _responses['GET:/v1/keys/00c726786980c4d9'] = http.Response( + jsonEncode({ + "keysets": [ + { + "id": "00c726786980c4d9", + "unit": "sat", + "keys": { + "1": + "02e67dd580169fb31cda6fe475581937a3a04bb0b422c624bfd6eeee1f6da6fa3c", + "1024": + "028fb71ffae7afbb43d04a8992b4ea268b0d9aff0921d0abed85ccd2099821b80a", + "1048576": + "03556358859d99dca7e6672bac4f12afcf88d0feee2c834800e917d0223b4e37a9", + "1073741824": + "031f80815097783a515dbf515578e35542f5675ce92caa158c95db64e68571ad5d", + "128": + "03ac769f6d6d4ca6ef83549bf8535280ff70676f45045df782ac20e80f37f0012f", + "131072": + "03c711b7a810fc4c8bb42f3ae1d2259cd29cde521fbfd1a96053b7828c8b34a22a", + "134217728": + "027e651c25fbcfaf2dc5ef9f6a09286df5d19d07c7c894a2accd2a2a8602f7f6f6", + "16": + "03de6848400b656115c502fcf6bc27266ee372b4e256cb337e10e5ee897380f80a", + "16384": + "03e5ce94bb082f26cb0978f65dab72acababe8f6691a9cf88dbaede223a0221925", + "16777216": + "03572b3fed165a371f54563fadef13f9bc8cfd7841ff200f049a3c57226bd0e0b2", + "2": + "03633147c9a55a7d354ab15960675981e0c879d969643cea7da6889963afb90d3d", + "2048": + "028644efab3ad590188508f7d3c462b57d592932c5a726b9311342003ec52a084b", + "2097152": + "02df9890ef6ecd31146660485a1102979cf6365a80025636ba8c8c1a9a36a0ba89", + "2147483648": + "02cc0f4252dc5f4672b863a261b3f7630bd65bd3201580cfff75de6634807c12b3", + "256": + "03e4b4bb96562a4855a88814a8659eb7f7eab2df7639d7b646086d5dbe3ae0c7e2", + "262144": + "0369d8da14ce6fcd33aa3aff9467b013aad9da5e12515bc6079bebf0da85daea5b", + "268435456": + "03ca1e98639a08c2e3724711bbce00fd6a427c27a9b0b6cf7f327b44918b5c43c6", + "32": + "030f589815a72b4c2fa4fe728e8b38a102228ef1eb490085d92d567d6ec8c97c09", + "32768": + "0353fd1089a6ff7db8c15f82e4c767789e76b0995da1ede24e7243f33b8301d082", + "33554432": + "0247abfe7eddd1f55e6c0f8e01c6160bde9b3fc98ee9413f192817a472e0abfcc8", + "4": + "0393db23532a95b722da09168f39010561babd79c73e63313890ac6fd5e100e6ac", + "4096": + "02718e4fca012601ebb320459fb57607ef9942f901683bf54ab6d6ec2eba2a523d", + "4194304": + "020030f75e5a64e7e09150cba9e2572720504d37d438d00b22b16434c7676617e3", + "512": + "032b95061460afaebdd0d9f3bad6c1d18dbb738b5daacf64a517e131d47133aad1", + "524288": + "02d4ebcf9edec380d52b7190f77d44e6fd7fc195f0f60df656772c74775f2ef653", + "536870912": + "02775c491ed9705b84fc0d1dd3abfec5e75fad5b1109240c67ae655b0024c06d1b", + "64": + "03d3bab9f316f22c6ceebafb73d9506a9d43863c453a2d3bf7940a0b1e8bba0fb9", + "65536": + "03344277ddad3a2cf49c21c5cac9ea620bb24f48fb534abd1539c4a5f3aa620749", + "67108864": + "034824c572029074aa94de30fba1bfaa3e9f090da0d1166888dfd11285f1a7874d", + "8": + "02ce8f0423a31496b3a405ce472a19f270dc526330d767beae8ec43a4812cbe43b", + "8192": + "02e47c542ba5c3664a839e6c5e3968b69916ae9e9387b8eaf973897f4f4ff9de72", + "8388608": + "02c7690d8e9032602cb29f4b0123bf8131dd58f42c0d4f457491b33594181a87c7" + } + } + ] + }), + 200, + headers: {'content-type': 'application/json'}, + ); + + _responses['POST:/v1/mint/quote/bolt11'] = http.Response( + jsonEncode({ + "quote": "d00e6cbc-04c9-4661-8909-e47c19612bf0", + "request": + "lnbc50p1p5tctmqdqqpp5y7jyyyq3ezyu3p4c9dh6qpnjj6znuzrz35ernjjpkmw6lz7y2mxqsp59g4z52329g4z52329g4z52329g4z52329g4z52329g4z52329g4q9qrsgqcqzysl62hzvm9s5nf53gk22v5nqwf9nuy2uh32wn9rfx6grkjh6vr5jmy09mra5cna504azyhkd2ehdel9sm7fm72ns6ws2fk4m8cwc99hdgptq8hv4", + "amount": 5, + "unit": "sat", + "state": "UNPAID", + "expiry": now + 60 + }), + 200, + headers: {'content-type': 'application/json'}, + ); + + _responses[ + 'GET:/v1/mint/quote/bolt11/d00e6cbc-04c9-4661-8909-e47c19612bf0'] = + http.Response( + jsonEncode({ + "quote": "d00e6cbc-04c9-4661-8909-e47c19612bf0", + "request": + "lnbc50p1p5tctmqdqqpp5y7jyyyq3ezyu3p4c9dh6qpnjj6znuzrz35ernjjpkmw6lz7y2mxqsp59g4z52329g4z52329g4z52329g4z52329g4z52329g4z52329g4q9qrsgqcqzysl62hzvm9s5nf53gk22v5nqwf9nuy2uh32wn9rfx6grkjh6vr5jmy09mra5cna504azyhkd2ehdel9sm7fm72ns6ws2fk4m8cwc99hdgptq8hv4", + "amount": 5, + "unit": "sat", + "state": "PAID", + "expiry": now + 60 + }), + 200, + headers: {'content-type': 'application/json'}, + ); + _responses['POST:/v1/mint/bolt11'] = http.Response( + jsonEncode( + {"signatures": []}, + ), + 200, + headers: {'content-type': 'application/json'}, + ); + + _responses['POST:/v1/melt/quote/bolt11'] = http.Response( + jsonEncode( + { + "quote": "ff477714-ae17-4b3a-88f1-d5be3a18bc01", + "amount": 1, + "fee_reserve": 2, + "paid": false, + "state": "UNPAID", + "expiry": now + 60, + "request": "lnbc1...", + "unit": "sat" + }, + ), + 200, + headers: {'content-type': 'application/json'}, + ); + + _responses['POST:/v1/melt/bolt11'] = http.Response( + jsonEncode( + { + "payment_preimage": "mock_preimage_1234567890abcdef", + "state": "PAID", + }, + ), + 200, + headers: {'content-type': 'application/json'}, + ); + } + + void setCustomResponse(String method, String path, http.Response response) { + _responses['$method:$path'] = response; + } + + void setNetworkError(String method, String path) { + _responses['$method:$path'] = 'NETWORK_ERROR'; + } + + @override + Future send(http.BaseRequest request) async { + capturedRequests.add(request as http.Request); + + final key = '${request.method}:${request.url.path}'; + + if (_responses.containsKey(key)) { + final response = _responses[key]; + + if (response == 'NETWORK_ERROR') { + throw Exception('Network error'); + } + + if (response is http.Response) { + return http.StreamedResponse( + Stream.value(utf8.encode(response.body)), + response.statusCode, + headers: response.headers, + ); + } + } + + // default 404 + return http.StreamedResponse( + Stream.value( + utf8.encode(jsonEncode({'error': 'Not found, method: $key'}))), + 404, + headers: {'content-type': 'application/json'}, + ); + } + + void clearCapturedRequests() { + capturedRequests.clear(); + } + + http.Request? getLastRequest() { + return capturedRequests.isNotEmpty ? capturedRequests.last : null; + } + + List getRequestsForPath(String path) { + return capturedRequests.where((req) => req.url.path == path).toList(); + } +} diff --git a/packages/ndk/test/cashu/mocks/cashu_repo_mock.dart b/packages/ndk/test/cashu/mocks/cashu_repo_mock.dart new file mode 100644 index 000000000..bdaa5afd2 --- /dev/null +++ b/packages/ndk/test/cashu/mocks/cashu_repo_mock.dart @@ -0,0 +1,17 @@ +import 'package:ndk/data_layer/repositories/cashu/cashu_repo_impl.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_blinded_message.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_blinded_signature.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_proof.dart'; + +class CashuRepoMock extends CashuRepoImpl { + CashuRepoMock({required super.client}); + + @override + Future> swap({ + required String mintUrl, + required List proofs, + required List outputs, + }) async { + throw Exception("force swap to fail"); + } +} diff --git a/packages/ndk/test/data_layer/cache_manager/mem_cache_manager_test.mocks.dart b/packages/ndk/test/data_layer/cache_manager/mem_cache_manager_test.mocks.dart index 573c9b8f4..540ca831c 100644 --- a/packages/ndk/test/data_layer/cache_manager/mem_cache_manager_test.mocks.dart +++ b/packages/ndk/test/data_layer/cache_manager/mem_cache_manager_test.mocks.dart @@ -1,22 +1,22 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in ndk/test/data_layer/cache_manager/mem_cache_manager_test.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i5; -import 'package:ndk/domain_layer/entities/contact_list.dart' as _i12; -import 'package:ndk/domain_layer/entities/filter.dart' as _i10; -import 'package:ndk/domain_layer/entities/metadata.dart' as _i13; +import 'package:mockito/src/dummies.dart' as _i7; +import 'package:ndk/domain_layer/entities/contact_list.dart' as _i14; +import 'package:ndk/domain_layer/entities/filter.dart' as _i12; +import 'package:ndk/domain_layer/entities/metadata.dart' as _i4; import 'package:ndk/domain_layer/entities/nip_01_event.dart' as _i3; -import 'package:ndk/domain_layer/entities/nip_05.dart' as _i14; +import 'package:ndk/domain_layer/entities/nip_05.dart' as _i5; import 'package:ndk/domain_layer/entities/nip_65.dart' as _i2; -import 'package:ndk/domain_layer/entities/pubkey_mapping.dart' as _i9; -import 'package:ndk/domain_layer/entities/read_write.dart' as _i8; -import 'package:ndk/domain_layer/entities/read_write_marker.dart' as _i6; -import 'package:ndk/domain_layer/entities/relay_set.dart' as _i7; -import 'package:ndk/domain_layer/entities/request_state.dart' as _i11; -import 'package:ndk/domain_layer/entities/user_relay_list.dart' as _i4; +import 'package:ndk/domain_layer/entities/pubkey_mapping.dart' as _i11; +import 'package:ndk/domain_layer/entities/read_write.dart' as _i10; +import 'package:ndk/domain_layer/entities/read_write_marker.dart' as _i8; +import 'package:ndk/domain_layer/entities/relay_set.dart' as _i9; +import 'package:ndk/domain_layer/entities/request_state.dart' as _i13; +import 'package:ndk/domain_layer/entities/user_relay_list.dart' as _i6; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -26,6 +26,7 @@ import 'package:ndk/domain_layer/entities/user_relay_list.dart' as _i4; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types @@ -51,10 +52,30 @@ class _FakeNip01Event_1 extends _i1.SmartFake implements _i3.Nip01Event { ); } +class _FakeMetadata_2 extends _i1.SmartFake implements _i4.Metadata { + _FakeMetadata_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeNip05_3 extends _i1.SmartFake implements _i5.Nip05 { + _FakeNip05_3( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + /// A class which mocks [UserRelayList]. /// /// See the documentation for Mockito's code generation for more information. -class MockUserRelayList extends _i1.Mock implements _i4.UserRelayList { +class MockUserRelayList extends _i1.Mock implements _i6.UserRelayList { MockUserRelayList() { _i1.throwOnMissingStub(this); } @@ -62,12 +83,42 @@ class MockUserRelayList extends _i1.Mock implements _i4.UserRelayList { @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i5.dummyValue( + returnValue: _i7.dummyValue( this, Invocation.getter(#pubKey), ), ) as String); + @override + int get createdAt => (super.noSuchMethod( + Invocation.getter(#createdAt), + returnValue: 0, + ) as int); + + @override + int get refreshedTimestamp => (super.noSuchMethod( + Invocation.getter(#refreshedTimestamp), + returnValue: 0, + ) as int); + + @override + Map get relays => (super.noSuchMethod( + Invocation.getter(#relays), + returnValue: {}, + ) as Map); + + @override + Iterable get urls => (super.noSuchMethod( + Invocation.getter(#urls), + returnValue: [], + ) as Iterable); + + @override + Iterable get readUrls => (super.noSuchMethod( + Invocation.getter(#readUrls), + returnValue: [], + ) as Iterable); + @override set pubKey(String? _pubKey) => super.noSuchMethod( Invocation.setter( @@ -77,12 +128,6 @@ class MockUserRelayList extends _i1.Mock implements _i4.UserRelayList { returnValueForMissingStub: null, ); - @override - int get createdAt => (super.noSuchMethod( - Invocation.getter(#createdAt), - returnValue: 0, - ) as int); - @override set createdAt(int? _createdAt) => super.noSuchMethod( Invocation.setter( @@ -92,12 +137,6 @@ class MockUserRelayList extends _i1.Mock implements _i4.UserRelayList { returnValueForMissingStub: null, ); - @override - int get refreshedTimestamp => (super.noSuchMethod( - Invocation.getter(#refreshedTimestamp), - returnValue: 0, - ) as int); - @override set refreshedTimestamp(int? _refreshedTimestamp) => super.noSuchMethod( Invocation.setter( @@ -108,13 +147,7 @@ class MockUserRelayList extends _i1.Mock implements _i4.UserRelayList { ); @override - Map get relays => (super.noSuchMethod( - Invocation.getter(#relays), - returnValue: {}, - ) as Map); - - @override - set relays(Map? _relays) => super.noSuchMethod( + set relays(Map? _relays) => super.noSuchMethod( Invocation.setter( #relays, _relays, @@ -122,18 +155,6 @@ class MockUserRelayList extends _i1.Mock implements _i4.UserRelayList { returnValueForMissingStub: null, ); - @override - Iterable get urls => (super.noSuchMethod( - Invocation.getter(#urls), - returnValue: [], - ) as Iterable); - - @override - Iterable get readUrls => (super.noSuchMethod( - Invocation.getter(#readUrls), - returnValue: [], - ) as Iterable); - @override _i2.Nip65 toNip65() => (super.noSuchMethod( Invocation.method( @@ -153,7 +174,7 @@ class MockUserRelayList extends _i1.Mock implements _i4.UserRelayList { /// A class which mocks [RelaySet]. /// /// See the documentation for Mockito's code generation for more information. -class MockRelaySet extends _i1.Mock implements _i7.RelaySet { +class MockRelaySet extends _i1.Mock implements _i9.RelaySet { MockRelaySet() { _i1.throwOnMissingStub(this); } @@ -161,12 +182,66 @@ class MockRelaySet extends _i1.Mock implements _i7.RelaySet { @override String get name => (super.noSuchMethod( Invocation.getter(#name), - returnValue: _i5.dummyValue( + returnValue: _i7.dummyValue( this, Invocation.getter(#name), ), ) as String); + @override + String get pubKey => (super.noSuchMethod( + Invocation.getter(#pubKey), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), + ) as String); + + @override + int get relayMinCountPerPubkey => (super.noSuchMethod( + Invocation.getter(#relayMinCountPerPubkey), + returnValue: 0, + ) as int); + + @override + _i10.RelayDirection get direction => (super.noSuchMethod( + Invocation.getter(#direction), + returnValue: _i10.RelayDirection.inbox, + ) as _i10.RelayDirection); + + @override + Map> get relaysMap => (super.noSuchMethod( + Invocation.getter(#relaysMap), + returnValue: >{}, + ) as Map>); + + @override + bool get fallbackToBootstrapRelays => (super.noSuchMethod( + Invocation.getter(#fallbackToBootstrapRelays), + returnValue: false, + ) as bool); + + @override + List<_i9.NotCoveredPubKey> get notCoveredPubkeys => (super.noSuchMethod( + Invocation.getter(#notCoveredPubkeys), + returnValue: <_i9.NotCoveredPubKey>[], + ) as List<_i9.NotCoveredPubKey>); + + @override + String get id => (super.noSuchMethod( + Invocation.getter(#id), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#id), + ), + ) as String); + + @override + Iterable get urls => (super.noSuchMethod( + Invocation.getter(#urls), + returnValue: [], + ) as Iterable); + @override set name(String? _name) => super.noSuchMethod( Invocation.setter( @@ -176,15 +251,6 @@ class MockRelaySet extends _i1.Mock implements _i7.RelaySet { returnValueForMissingStub: null, ); - @override - String get pubKey => (super.noSuchMethod( - Invocation.getter(#pubKey), - returnValue: _i5.dummyValue( - this, - Invocation.getter(#pubKey), - ), - ) as String); - @override set pubKey(String? _pubKey) => super.noSuchMethod( Invocation.setter( @@ -194,12 +260,6 @@ class MockRelaySet extends _i1.Mock implements _i7.RelaySet { returnValueForMissingStub: null, ); - @override - int get relayMinCountPerPubkey => (super.noSuchMethod( - Invocation.getter(#relayMinCountPerPubkey), - returnValue: 0, - ) as int); - @override set relayMinCountPerPubkey(int? _relayMinCountPerPubkey) => super.noSuchMethod( @@ -211,13 +271,7 @@ class MockRelaySet extends _i1.Mock implements _i7.RelaySet { ); @override - _i8.RelayDirection get direction => (super.noSuchMethod( - Invocation.getter(#direction), - returnValue: _i8.RelayDirection.inbox, - ) as _i8.RelayDirection); - - @override - set direction(_i8.RelayDirection? _direction) => super.noSuchMethod( + set direction(_i10.RelayDirection? _direction) => super.noSuchMethod( Invocation.setter( #direction, _direction, @@ -226,13 +280,7 @@ class MockRelaySet extends _i1.Mock implements _i7.RelaySet { ); @override - Map> get relaysMap => (super.noSuchMethod( - Invocation.getter(#relaysMap), - returnValue: >{}, - ) as Map>); - - @override - set relaysMap(Map>? _relaysMap) => + set relaysMap(Map>? _relaysMap) => super.noSuchMethod( Invocation.setter( #relaysMap, @@ -241,12 +289,6 @@ class MockRelaySet extends _i1.Mock implements _i7.RelaySet { returnValueForMissingStub: null, ); - @override - bool get fallbackToBootstrapRelays => (super.noSuchMethod( - Invocation.getter(#fallbackToBootstrapRelays), - returnValue: false, - ) as bool); - @override set fallbackToBootstrapRelays(bool? _fallbackToBootstrapRelays) => super.noSuchMethod( @@ -258,13 +300,7 @@ class MockRelaySet extends _i1.Mock implements _i7.RelaySet { ); @override - List<_i7.NotCoveredPubKey> get notCoveredPubkeys => (super.noSuchMethod( - Invocation.getter(#notCoveredPubkeys), - returnValue: <_i7.NotCoveredPubKey>[], - ) as List<_i7.NotCoveredPubKey>); - - @override - set notCoveredPubkeys(List<_i7.NotCoveredPubKey>? _notCoveredPubkeys) => + set notCoveredPubkeys(List<_i9.NotCoveredPubKey>? _notCoveredPubkeys) => super.noSuchMethod( Invocation.setter( #notCoveredPubkeys, @@ -273,25 +309,10 @@ class MockRelaySet extends _i1.Mock implements _i7.RelaySet { returnValueForMissingStub: null, ); - @override - String get id => (super.noSuchMethod( - Invocation.getter(#id), - returnValue: _i5.dummyValue( - this, - Invocation.getter(#id), - ), - ) as String); - - @override - Iterable get urls => (super.noSuchMethod( - Invocation.getter(#urls), - returnValue: [], - ) as Iterable); - @override void splitIntoRequests( - _i10.Filter? filter, - _i11.RequestState? groupRequest, + _i12.Filter? filter, + _i13.RequestState? groupRequest, ) => super.noSuchMethod( Invocation.method( @@ -305,7 +326,7 @@ class MockRelaySet extends _i1.Mock implements _i7.RelaySet { ); @override - void addMoreRelays(Map>? more) => + void addMoreRelays(Map>? more) => super.noSuchMethod( Invocation.method( #addMoreRelays, @@ -318,7 +339,7 @@ class MockRelaySet extends _i1.Mock implements _i7.RelaySet { /// A class which mocks [ContactList]. /// /// See the documentation for Mockito's code generation for more information. -class MockContactList extends _i1.Mock implements _i12.ContactList { +class MockContactList extends _i1.Mock implements _i14.ContactList { MockContactList() { _i1.throwOnMissingStub(this); } @@ -326,12 +347,60 @@ class MockContactList extends _i1.Mock implements _i12.ContactList { @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i5.dummyValue( + returnValue: _i7.dummyValue( this, Invocation.getter(#pubKey), ), ) as String); + @override + List get contacts => (super.noSuchMethod( + Invocation.getter(#contacts), + returnValue: [], + ) as List); + + @override + List get contactRelays => (super.noSuchMethod( + Invocation.getter(#contactRelays), + returnValue: [], + ) as List); + + @override + List get petnames => (super.noSuchMethod( + Invocation.getter(#petnames), + returnValue: [], + ) as List); + + @override + List get followedTags => (super.noSuchMethod( + Invocation.getter(#followedTags), + returnValue: [], + ) as List); + + @override + List get followedCommunities => (super.noSuchMethod( + Invocation.getter(#followedCommunities), + returnValue: [], + ) as List); + + @override + List get followedEvents => (super.noSuchMethod( + Invocation.getter(#followedEvents), + returnValue: [], + ) as List); + + @override + int get createdAt => (super.noSuchMethod( + Invocation.getter(#createdAt), + returnValue: 0, + ) as int); + + @override + List get sources => (super.noSuchMethod( + Invocation.getter(#sources), + returnValue: [], + ) as List); + @override set pubKey(String? _pubKey) => super.noSuchMethod( Invocation.setter( @@ -341,12 +410,6 @@ class MockContactList extends _i1.Mock implements _i12.ContactList { returnValueForMissingStub: null, ); - @override - List get contacts => (super.noSuchMethod( - Invocation.getter(#contacts), - returnValue: [], - ) as List); - @override set contacts(List? _contacts) => super.noSuchMethod( Invocation.setter( @@ -356,12 +419,6 @@ class MockContactList extends _i1.Mock implements _i12.ContactList { returnValueForMissingStub: null, ); - @override - List get contactRelays => (super.noSuchMethod( - Invocation.getter(#contactRelays), - returnValue: [], - ) as List); - @override set contactRelays(List? _contactRelays) => super.noSuchMethod( Invocation.setter( @@ -371,12 +428,6 @@ class MockContactList extends _i1.Mock implements _i12.ContactList { returnValueForMissingStub: null, ); - @override - List get petnames => (super.noSuchMethod( - Invocation.getter(#petnames), - returnValue: [], - ) as List); - @override set petnames(List? _petnames) => super.noSuchMethod( Invocation.setter( @@ -386,12 +437,6 @@ class MockContactList extends _i1.Mock implements _i12.ContactList { returnValueForMissingStub: null, ); - @override - List get followedTags => (super.noSuchMethod( - Invocation.getter(#followedTags), - returnValue: [], - ) as List); - @override set followedTags(List? _followedTags) => super.noSuchMethod( Invocation.setter( @@ -401,12 +446,6 @@ class MockContactList extends _i1.Mock implements _i12.ContactList { returnValueForMissingStub: null, ); - @override - List get followedCommunities => (super.noSuchMethod( - Invocation.getter(#followedCommunities), - returnValue: [], - ) as List); - @override set followedCommunities(List? _followedCommunities) => super.noSuchMethod( @@ -417,12 +456,6 @@ class MockContactList extends _i1.Mock implements _i12.ContactList { returnValueForMissingStub: null, ); - @override - List get followedEvents => (super.noSuchMethod( - Invocation.getter(#followedEvents), - returnValue: [], - ) as List); - @override set followedEvents(List? _followedEvents) => super.noSuchMethod( Invocation.setter( @@ -432,12 +465,6 @@ class MockContactList extends _i1.Mock implements _i12.ContactList { returnValueForMissingStub: null, ); - @override - int get createdAt => (super.noSuchMethod( - Invocation.getter(#createdAt), - returnValue: 0, - ) as int); - @override set createdAt(int? _createdAt) => super.noSuchMethod( Invocation.setter( @@ -456,12 +483,6 @@ class MockContactList extends _i1.Mock implements _i12.ContactList { returnValueForMissingStub: null, ); - @override - List get sources => (super.noSuchMethod( - Invocation.getter(#sources), - returnValue: [], - ) as List); - @override set sources(List? _sources) => super.noSuchMethod( Invocation.setter( @@ -515,7 +536,7 @@ class MockContactList extends _i1.Mock implements _i12.ContactList { /// A class which mocks [Metadata]. /// /// See the documentation for Mockito's code generation for more information. -class MockMetadata extends _i1.Mock implements _i13.Metadata { +class MockMetadata extends _i1.Mock implements _i4.Metadata { MockMetadata() { _i1.throwOnMissingStub(this); } @@ -523,12 +544,18 @@ class MockMetadata extends _i1.Mock implements _i13.Metadata { @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i5.dummyValue( + returnValue: _i7.dummyValue( this, Invocation.getter(#pubKey), ), ) as String); + @override + List get sources => (super.noSuchMethod( + Invocation.getter(#sources), + returnValue: [], + ) as List); + @override set pubKey(String? _pubKey) => super.noSuchMethod( Invocation.setter( @@ -637,6 +664,15 @@ class MockMetadata extends _i1.Mock implements _i13.Metadata { returnValueForMissingStub: null, ); + @override + set sources(List? _sources) => super.noSuchMethod( + Invocation.setter( + #sources, + _sources, + ), + returnValueForMissingStub: null, + ); + @override Map toFullJson() => (super.noSuchMethod( Invocation.method( @@ -676,7 +712,7 @@ class MockMetadata extends _i1.Mock implements _i13.Metadata { #getName, [], ), - returnValue: _i5.dummyValue( + returnValue: _i7.dummyValue( this, Invocation.method( #getName, @@ -693,6 +729,66 @@ class MockMetadata extends _i1.Mock implements _i13.Metadata { ), returnValue: false, ) as bool); + + @override + _i4.Metadata copyWith({ + String? pubKey, + String? name, + String? displayName, + String? picture, + String? banner, + String? website, + String? about, + String? nip05, + String? lud16, + String? lud06, + int? updatedAt, + int? refreshedTimestamp, + List? sources, + }) => + (super.noSuchMethod( + Invocation.method( + #copyWith, + [], + { + #pubKey: pubKey, + #name: name, + #displayName: displayName, + #picture: picture, + #banner: banner, + #website: website, + #about: about, + #nip05: nip05, + #lud16: lud16, + #lud06: lud06, + #updatedAt: updatedAt, + #refreshedTimestamp: refreshedTimestamp, + #sources: sources, + }, + ), + returnValue: _FakeMetadata_2( + this, + Invocation.method( + #copyWith, + [], + { + #pubKey: pubKey, + #name: name, + #displayName: displayName, + #picture: picture, + #banner: banner, + #website: website, + #about: about, + #nip05: nip05, + #lud16: lud16, + #lud06: lud06, + #updatedAt: updatedAt, + #refreshedTimestamp: refreshedTimestamp, + #sources: sources, + }, + ), + ), + ) as _i4.Metadata); } /// A class which mocks [Nip01Event]. @@ -706,25 +802,16 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { @override String get id => (super.noSuchMethod( Invocation.getter(#id), - returnValue: _i5.dummyValue( + returnValue: _i7.dummyValue( this, Invocation.getter(#id), ), ) as String); - @override - set id(String? _id) => super.noSuchMethod( - Invocation.setter( - #id, - _id, - ), - returnValueForMissingStub: null, - ); - @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i5.dummyValue( + returnValue: _i7.dummyValue( this, Invocation.getter(#pubKey), ), @@ -736,15 +823,6 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { returnValue: 0, ) as int); - @override - set createdAt(int? _createdAt) => super.noSuchMethod( - Invocation.setter( - #createdAt, - _createdAt, - ), - returnValueForMissingStub: null, - ); - @override int get kind => (super.noSuchMethod( Invocation.getter(#kind), @@ -757,75 +835,30 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { returnValue: >[], ) as List>); - @override - set tags(List>? _tags) => super.noSuchMethod( - Invocation.setter( - #tags, - _tags, - ), - returnValueForMissingStub: null, - ); - @override String get content => (super.noSuchMethod( Invocation.getter(#content), - returnValue: _i5.dummyValue( + returnValue: _i7.dummyValue( this, Invocation.getter(#content), ), ) as String); - @override - set content(String? _content) => super.noSuchMethod( - Invocation.setter( - #content, - _content, - ), - returnValueForMissingStub: null, - ); - @override String get sig => (super.noSuchMethod( Invocation.getter(#sig), - returnValue: _i5.dummyValue( + returnValue: _i7.dummyValue( this, Invocation.getter(#sig), ), ) as String); - @override - set sig(String? _sig) => super.noSuchMethod( - Invocation.setter( - #sig, - _sig, - ), - returnValueForMissingStub: null, - ); - - @override - set validSig(bool? _validSig) => super.noSuchMethod( - Invocation.setter( - #validSig, - _validSig, - ), - returnValueForMissingStub: null, - ); - @override List get sources => (super.noSuchMethod( Invocation.getter(#sources), returnValue: [], ) as List); - @override - set sources(List? _sources) => super.noSuchMethod( - Invocation.setter( - #sources, - _sources, - ), - returnValueForMissingStub: null, - ); - @override bool get isIdValid => (super.noSuchMethod( Invocation.getter(#isIdValid), @@ -850,6 +883,69 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { returnValue: [], ) as List); + @override + set id(String? _id) => super.noSuchMethod( + Invocation.setter( + #id, + _id, + ), + returnValueForMissingStub: null, + ); + + @override + set createdAt(int? _createdAt) => super.noSuchMethod( + Invocation.setter( + #createdAt, + _createdAt, + ), + returnValueForMissingStub: null, + ); + + @override + set tags(List>? _tags) => super.noSuchMethod( + Invocation.setter( + #tags, + _tags, + ), + returnValueForMissingStub: null, + ); + + @override + set content(String? _content) => super.noSuchMethod( + Invocation.setter( + #content, + _content, + ), + returnValueForMissingStub: null, + ); + + @override + set sig(String? _sig) => super.noSuchMethod( + Invocation.setter( + #sig, + _sig, + ), + returnValueForMissingStub: null, + ); + + @override + set validSig(bool? _validSig) => super.noSuchMethod( + Invocation.setter( + #validSig, + _validSig, + ), + returnValueForMissingStub: null, + ); + + @override + set sources(List? _sources) => super.noSuchMethod( + Invocation.setter( + #sources, + _sources, + ), + returnValueForMissingStub: null, + ); + @override Map toJson() => (super.noSuchMethod( Invocation.method( @@ -859,6 +955,21 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { returnValue: {}, ) as Map); + @override + String toBase64() => (super.noSuchMethod( + Invocation.method( + #toBase64, + [], + ), + returnValue: _i7.dummyValue( + this, + Invocation.method( + #toBase64, + [], + ), + ), + ) as String); + @override void sign(String? privateKey) => super.noSuchMethod( Invocation.method( @@ -868,17 +979,68 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { returnValueForMissingStub: null, ); + @override + List getTags(String? tag) => (super.noSuchMethod( + Invocation.method( + #getTags, + [tag], + ), + returnValue: [], + ) as List); + @override String? getFirstTag(String? name) => (super.noSuchMethod(Invocation.method( #getFirstTag, [name], )) as String?); + + @override + _i3.Nip01Event copyWith({ + String? pubKey, + int? createdAt, + int? kind, + List>? tags, + String? content, + String? sig, + List? sources, + }) => + (super.noSuchMethod( + Invocation.method( + #copyWith, + [], + { + #pubKey: pubKey, + #createdAt: createdAt, + #kind: kind, + #tags: tags, + #content: content, + #sig: sig, + #sources: sources, + }, + ), + returnValue: _FakeNip01Event_1( + this, + Invocation.method( + #copyWith, + [], + { + #pubKey: pubKey, + #createdAt: createdAt, + #kind: kind, + #tags: tags, + #content: content, + #sig: sig, + #sources: sources, + }, + ), + ), + ) as _i3.Nip01Event); } /// A class which mocks [Nip05]. /// /// See the documentation for Mockito's code generation for more information. -class MockNip05 extends _i1.Mock implements _i14.Nip05 { +class MockNip05 extends _i1.Mock implements _i5.Nip05 { MockNip05() { _i1.throwOnMissingStub(this); } @@ -886,12 +1048,27 @@ class MockNip05 extends _i1.Mock implements _i14.Nip05 { @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i5.dummyValue( + returnValue: _i7.dummyValue( this, Invocation.getter(#pubKey), ), ) as String); + @override + String get nip05 => (super.noSuchMethod( + Invocation.getter(#nip05), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#nip05), + ), + ) as String); + + @override + bool get valid => (super.noSuchMethod( + Invocation.getter(#valid), + returnValue: false, + ) as bool); + @override set pubKey(String? _pubKey) => super.noSuchMethod( Invocation.setter( @@ -901,15 +1078,6 @@ class MockNip05 extends _i1.Mock implements _i14.Nip05 { returnValueForMissingStub: null, ); - @override - String get nip05 => (super.noSuchMethod( - Invocation.getter(#nip05), - returnValue: _i5.dummyValue( - this, - Invocation.getter(#nip05), - ), - ) as String); - @override set nip05(String? _nip05) => super.noSuchMethod( Invocation.setter( @@ -919,12 +1087,6 @@ class MockNip05 extends _i1.Mock implements _i14.Nip05 { returnValueForMissingStub: null, ); - @override - bool get valid => (super.noSuchMethod( - Invocation.getter(#valid), - returnValue: false, - ) as bool); - @override set valid(bool? _valid) => super.noSuchMethod( Invocation.setter( @@ -951,4 +1113,40 @@ class MockNip05 extends _i1.Mock implements _i14.Nip05 { ), returnValueForMissingStub: null, ); + + @override + _i5.Nip05 copyWith({ + String? pubKey, + String? nip05, + bool? valid, + int? networkFetchTime, + List? relays, + }) => + (super.noSuchMethod( + Invocation.method( + #copyWith, + [], + { + #pubKey: pubKey, + #nip05: nip05, + #valid: valid, + #networkFetchTime: networkFetchTime, + #relays: relays, + }, + ), + returnValue: _FakeNip05_3( + this, + Invocation.method( + #copyWith, + [], + { + #pubKey: pubKey, + #nip05: nip05, + #valid: valid, + #networkFetchTime: networkFetchTime, + #relays: relays, + }, + ), + ), + ) as _i5.Nip05); } diff --git a/packages/ndk/test/data_layer/nostr_transport/websocket_nostr_transport_test.mocks.dart b/packages/ndk/test/data_layer/nostr_transport/websocket_nostr_transport_test.mocks.dart index 8a73bf301..d733bec25 100644 --- a/packages/ndk/test/data_layer/nostr_transport/websocket_nostr_transport_test.mocks.dart +++ b/packages/ndk/test/data_layer/nostr_transport/websocket_nostr_transport_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in ndk/test/data_layer/nostr_transport/websocket_nostr_transport_test.dart. // Do not manually edit this file. @@ -17,6 +17,7 @@ import 'package:web_socket_channel/web_socket_channel.dart' as _i2; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types diff --git a/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart index 2921e0557..46393a8ed 100644 --- a/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart +++ b/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in ndk/test/usecases/lnurl/lnurl_test.dart. // Do not manually edit this file. @@ -19,6 +19,7 @@ import 'package:mockito/src/dummies.dart' as _i5; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types diff --git a/packages/ndk/test/usecases/nip05/nip05_network_test.mocks.dart b/packages/ndk/test/usecases/nip05/nip05_network_test.mocks.dart index f8872d7dc..1c1000eae 100644 --- a/packages/ndk/test/usecases/nip05/nip05_network_test.mocks.dart +++ b/packages/ndk/test/usecases/nip05/nip05_network_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in ndk/test/usecases/nip05/nip05_network_test.dart. // Do not manually edit this file. @@ -19,6 +19,7 @@ import 'package:mockito/src/dummies.dart' as _i5; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types diff --git a/packages/ndk/test/usecases/zaps/zap_receipt_test.mocks.dart b/packages/ndk/test/usecases/zaps/zap_receipt_test.mocks.dart index 4ea34cff5..3c4b4ee83 100644 --- a/packages/ndk/test/usecases/zaps/zap_receipt_test.mocks.dart +++ b/packages/ndk/test/usecases/zaps/zap_receipt_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in ndk/test/usecases/zaps/zap_receipt_test.dart. // Do not manually edit this file. @@ -15,11 +15,22 @@ import 'package:ndk/domain_layer/entities/nip_01_event.dart' as _i2; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +class _FakeNip01Event_0 extends _i1.SmartFake implements _i2.Nip01Event { + _FakeNip01Event_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + /// A class which mocks [Nip01Event]. /// /// See the documentation for Mockito's code generation for more information. @@ -37,15 +48,6 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { ), ) as String); - @override - set id(String? _id) => super.noSuchMethod( - Invocation.setter( - #id, - _id, - ), - returnValueForMissingStub: null, - ); - @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), @@ -61,15 +63,6 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { returnValue: 0, ) as int); - @override - set createdAt(int? _createdAt) => super.noSuchMethod( - Invocation.setter( - #createdAt, - _createdAt, - ), - returnValueForMissingStub: null, - ); - @override int get kind => (super.noSuchMethod( Invocation.getter(#kind), @@ -82,15 +75,6 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { returnValue: >[], ) as List>); - @override - set tags(List>? _tags) => super.noSuchMethod( - Invocation.setter( - #tags, - _tags, - ), - returnValueForMissingStub: null, - ); - @override String get content => (super.noSuchMethod( Invocation.getter(#content), @@ -100,15 +84,6 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { ), ) as String); - @override - set content(String? _content) => super.noSuchMethod( - Invocation.setter( - #content, - _content, - ), - returnValueForMissingStub: null, - ); - @override String get sig => (super.noSuchMethod( Invocation.getter(#sig), @@ -118,39 +93,12 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { ), ) as String); - @override - set sig(String? _sig) => super.noSuchMethod( - Invocation.setter( - #sig, - _sig, - ), - returnValueForMissingStub: null, - ); - - @override - set validSig(bool? _validSig) => super.noSuchMethod( - Invocation.setter( - #validSig, - _validSig, - ), - returnValueForMissingStub: null, - ); - @override List get sources => (super.noSuchMethod( Invocation.getter(#sources), returnValue: [], ) as List); - @override - set sources(List? _sources) => super.noSuchMethod( - Invocation.setter( - #sources, - _sources, - ), - returnValueForMissingStub: null, - ); - @override bool get isIdValid => (super.noSuchMethod( Invocation.getter(#isIdValid), @@ -175,6 +123,69 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { returnValue: [], ) as List); + @override + set id(String? _id) => super.noSuchMethod( + Invocation.setter( + #id, + _id, + ), + returnValueForMissingStub: null, + ); + + @override + set createdAt(int? _createdAt) => super.noSuchMethod( + Invocation.setter( + #createdAt, + _createdAt, + ), + returnValueForMissingStub: null, + ); + + @override + set tags(List>? _tags) => super.noSuchMethod( + Invocation.setter( + #tags, + _tags, + ), + returnValueForMissingStub: null, + ); + + @override + set content(String? _content) => super.noSuchMethod( + Invocation.setter( + #content, + _content, + ), + returnValueForMissingStub: null, + ); + + @override + set sig(String? _sig) => super.noSuchMethod( + Invocation.setter( + #sig, + _sig, + ), + returnValueForMissingStub: null, + ); + + @override + set validSig(bool? _validSig) => super.noSuchMethod( + Invocation.setter( + #validSig, + _validSig, + ), + returnValueForMissingStub: null, + ); + + @override + set sources(List? _sources) => super.noSuchMethod( + Invocation.setter( + #sources, + _sources, + ), + returnValueForMissingStub: null, + ); + @override Map toJson() => (super.noSuchMethod( Invocation.method( @@ -184,6 +195,21 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { returnValue: {}, ) as Map); + @override + String toBase64() => (super.noSuchMethod( + Invocation.method( + #toBase64, + [], + ), + returnValue: _i3.dummyValue( + this, + Invocation.method( + #toBase64, + [], + ), + ), + ) as String); + @override void sign(String? privateKey) => super.noSuchMethod( Invocation.method( @@ -193,9 +219,60 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { returnValueForMissingStub: null, ); + @override + List getTags(String? tag) => (super.noSuchMethod( + Invocation.method( + #getTags, + [tag], + ), + returnValue: [], + ) as List); + @override String? getFirstTag(String? name) => (super.noSuchMethod(Invocation.method( #getFirstTag, [name], )) as String?); + + @override + _i2.Nip01Event copyWith({ + String? pubKey, + int? createdAt, + int? kind, + List>? tags, + String? content, + String? sig, + List? sources, + }) => + (super.noSuchMethod( + Invocation.method( + #copyWith, + [], + { + #pubKey: pubKey, + #createdAt: createdAt, + #kind: kind, + #tags: tags, + #content: content, + #sig: sig, + #sources: sources, + }, + ), + returnValue: _FakeNip01Event_0( + this, + Invocation.method( + #copyWith, + [], + { + #pubKey: pubKey, + #createdAt: createdAt, + #kind: kind, + #tags: tags, + #content: content, + #sig: sig, + #sources: sources, + }, + ), + ), + ) as _i2.Nip01Event); } diff --git a/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart b/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart index 3cfe3c00a..e405594f2 100644 --- a/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart +++ b/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in ndk/test/usecases/zaps/zaps_test.dart. // Do not manually edit this file. @@ -19,6 +19,7 @@ import 'package:mockito/src/dummies.dart' as _i5; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types diff --git a/packages/objectbox/lib/data_layer/db/object_box/db_object_box.dart b/packages/objectbox/lib/data_layer/db/object_box/db_object_box.dart index 7f932212b..eef8505c7 100644 --- a/packages/objectbox/lib/data_layer/db/object_box/db_object_box.dart +++ b/packages/objectbox/lib/data_layer/db/object_box/db_object_box.dart @@ -3,13 +3,21 @@ import 'dart:async'; import 'package:ndk/entities.dart'; import 'package:ndk/ndk.dart'; +import 'package:ndk_objectbox/data_layer/db/object_box/schema/db_nip_05.dart'; + import '../../../objectbox.g.dart'; import 'db_init_object_box.dart'; +import 'schema/db_cashu_keyset.dart'; +import 'schema/db_cashu_mint_info.dart'; +import 'schema/db_cashu_proof.dart'; +import 'schema/db_cashu_secret_counter.dart'; import 'schema/db_contact_list.dart'; import 'schema/db_metadata.dart'; import 'schema/db_nip_01_event.dart'; import 'schema/db_nip_05.dart'; import 'schema/db_user_relay_list.dart'; +import 'schema/db_wallet.dart'; +import 'schema/db_wallet_transaction.dart'; class DbObjectBox implements CacheManager { final Completer _initCompleter = Completer(); @@ -579,4 +587,357 @@ class DbObjectBox implements CacheManager { return filteredResults.map((dbEvent) => dbEvent.toNdk()).take(limit); } + + @override + Future> getKeysets({String? mintUrl}) async { + await dbRdy; + if (mintUrl == null || mintUrl.isEmpty) { + // return all keysets if no mintUrl + return _objectBox.store + .box() + .getAll() + .map((dbKeyset) => dbKeyset.toNdk()) + .toList(); + } + + return _objectBox.store + .box() + .query(DbWalletCahsuKeyset_.mintUrl.equals(mintUrl)) + .build() + .find() + .map((dbKeyset) => dbKeyset.toNdk()) + .toList(); + } + + @override + Future> getProofs({ + String? mintUrl, + String? keysetId, + CashuProofState state = CashuProofState.unspend, + }) async { + /// returns all proofs if no filters are applied + await dbRdy; + + final proofBox = _objectBox.store.box(); + + // Build conditions + Condition condition; + + /// filter spend state + + condition = DbWalletCashuProof_.state.equals(state.toString()); + + /// specify keysetId + if (keysetId != null && keysetId.isNotEmpty) { + final keysetCondition = DbWalletCashuProof_.keysetId.equals(keysetId); + condition = condition.and(keysetCondition); + } + + if (mintUrl != null && mintUrl.isNotEmpty) { + /// get all keysets for the mintUrl + /// and filter proofs by keysetId + /// + final keysets = await getKeysets(mintUrl: mintUrl); + if (keysets.isNotEmpty) { + final keysetIds = keysets.map((k) => k.id).toList(); + final mintUrlCondition = DbWalletCashuProof_.keysetId.oneOf(keysetIds); + + condition = condition.and(mintUrlCondition); + } else { + // If no keysets found for the mintUrl, return empty list + return []; + } + } + + QueryBuilder queryBuilder; + + queryBuilder = proofBox.query(condition); + + // Apply sorting + queryBuilder.order(DbWalletCashuProof_.amount); + + // Build and execute the query + final query = queryBuilder.build(); + + final results = query.find(); + return results.map((dbProof) => dbProof.toNdk()).toList(); + } + + @override + Future removeProofs({ + required List proofs, + required String mintUrl, + }) async { + await dbRdy; + final proofBox = _objectBox.store.box(); + + // find all proofs, ignoring mintUrl + final proofSecrets = proofs.map((p) => p.secret).toList(); + final existingProofs = proofBox + .query(DbWalletCashuProof_.secret.oneOf(proofSecrets)) + .build() + .find(); + + // remove them + if (existingProofs.isNotEmpty) { + proofBox.removeMany(existingProofs.map((p) => p.dbId).toList()); + } + } + + @override + Future saveKeyset(CahsuKeyset keyset) async { + _objectBox.store.box().put( + DbWalletCahsuKeyset.fromNdk(keyset), + ); + return Future.value(); + } + + @override + Future saveProofs({ + required List proofs, + required String mintUrl, + }) async { + await dbRdy; + + /// upsert logic: + + final store = _objectBox.store; + store.runInTransaction(TxMode.write, () { + final box = store.box(); + + final dbTokens = + proofs.map((t) => DbWalletCashuProof.fromNdk(t)).toList(); + + // find existing proofs by secret + final secretsToCheck = dbTokens.map((t) => t.secret).toList(); + final query = + box.query(DbWalletCashuProof_.secret.oneOf(secretsToCheck)).build(); + + try { + final existing = query.find(); + + if (existing.isNotEmpty) { + box.removeMany(existing.map((t) => t.dbId).toList()); + } + + // insert + box.putMany(dbTokens); + } finally { + query.close(); + } + }); + } + + @override + Future> getTransactions({ + int? limit, + int? offset, + String? walletId, + String? unit, + WalletType? walletType, + }) async { + await dbRdy; + + final transactionBox = _objectBox.store.box(); + + Condition? condition; + if (walletId != null && walletId.isNotEmpty) { + condition = DbWalletTransaction_.walletId.equals(walletId); + } + if (unit != null && unit.isNotEmpty) { + final unitCondition = DbWalletTransaction_.unit.equals(unit); + condition = + (condition == null) ? unitCondition : condition.and(unitCondition); + } + if (walletType != null) { + final typeCondition = + DbWalletTransaction_.walletType.equals(walletType.toString()); + condition = + (condition == null) ? typeCondition : condition.and(typeCondition); + } + QueryBuilder queryBuilder; + if (condition != null) { + queryBuilder = transactionBox.query(condition); + } else { + queryBuilder = transactionBox.query(); + } + + // sort + queryBuilder.order(DbWalletTransaction_.transactionDate, + flags: Order.descending); + + final query = queryBuilder.build(); + // limit + if (limit != null) { + query..limit = limit; + } + + // offset + if (offset != null) { + query..offset = offset; + } + + final results = query.find(); + return results.map((dbTransaction) => dbTransaction.toNdk()).toList(); + } + + Future saveTransactions({ + required List transactions, + }) async { + await dbRdy; + + final store = _objectBox.store; + + store.runInTransaction(TxMode.write, () { + final box = store.box(); + final dbTransactions = + transactions.map((t) => DbWalletTransaction.fromNdk(t)).toList(); + + // find existing transactions by id + final idsToCheck = dbTransactions.map((t) => t.id).toList(); + + final query = + box.query(DbWalletTransaction_.id.oneOf(idsToCheck)).build(); + + try { + final existing = query.find(); + + if (existing.isNotEmpty) { + box.removeMany(existing.map((t) => t.dbId).toList()); + } + + // insert + box.putMany(dbTransactions); + } finally { + query.close(); + } + }); + } + + @override + Future?> getWallets({List? ids}) async { + await dbRdy; + + return Future.value( + _objectBox.store.box().getAll().map((dbWallet) { + return dbWallet.toNdk(); + }).where((wallet) { + if (ids == null || ids.isEmpty) { + return true; // return all wallets + } + return ids.contains(wallet.id); + }).toList(), + ); + } + + @override + Future removeWallet(String walletId) async { + await dbRdy; + // find wallet by id + final walletBox = _objectBox.store.box(); + final existingWallet = await walletBox + .query(DbWallet_.id.equals(walletId)) + .build() + .findFirst(); + if (existingWallet != null) { + await walletBox.remove(existingWallet.dbId); + } + return Future.value(); + } + + @override + Future saveWallet(Wallet wallet) async { + await dbRdy; + await _objectBox.store.box().put(DbWallet.fromNdk(wallet)); + return Future.value(); + } + + @override + Future?> getMintInfos({List? mintUrls}) async { + await dbRdy; + + final box = _objectBox.store.box(); + + // return all if no filters provided + if (mintUrls == null || mintUrls.isEmpty) { + return box.getAll().map((e) => e.toNdk()).toList(); + } + + // build OR condition + Condition? cond; + for (final url in mintUrls) { + final c = DbCashuMintInfo_.urls.containsElement(url); + cond = (cond == null) ? c : (cond | c); + } + + final query = box.query(cond).build(); + try { + return query.find().map((e) => e.toNdk()).toList(); + } finally { + query.close(); + } + } + + @override + Future saveMintInfo({required CashuMintInfo mintInfo}) async { + await dbRdy; + + final box = _objectBox.store.box(); + + /// upsert logic: + final existingMintInfo = box + .query(DbCashuMintInfo_.urls.containsElement(mintInfo.urls.first)) + .build() + .findFirst(); + + if (existingMintInfo != null) { + box.remove(existingMintInfo.dbId); + } + + box.put(DbCashuMintInfo.fromNdk(mintInfo)); + } + + @override + Future getCashuSecretCounter({ + required String mintUrl, + required String keysetId, + }) async { + await dbRdy; + final box = _objectBox.store.box(); + final existing = box + .query(DbCashuSecretCounter_.mintUrl + .equals(mintUrl) + .and(DbCashuSecretCounter_.keysetId.equals(keysetId))) + .build() + .findFirst(); + if (existing == null) { + return 0; + } + return existing.counter; + } + + @override + Future setCashuSecretCounter({ + required String mintUrl, + required String keysetId, + required int counter, + }) async { + await dbRdy; + final box = _objectBox.store.box(); + final existing = box + .query(DbCashuSecretCounter_.mintUrl + .equals(mintUrl) + .and(DbCashuSecretCounter_.keysetId.equals(keysetId))) + .build() + .findFirst(); + if (existing != null) { + box.remove(existing.dbId); + } + box.put(DbCashuSecretCounter( + mintUrl: mintUrl, + keysetId: keysetId, + counter: counter, + )); + return Future.value(); + } } diff --git a/packages/objectbox/lib/data_layer/db/object_box/schema/db_cashu_keyset.dart b/packages/objectbox/lib/data_layer/db/object_box/schema/db_cashu_keyset.dart new file mode 100644 index 000000000..926de0071 --- /dev/null +++ b/packages/objectbox/lib/data_layer/db/object_box/schema/db_cashu_keyset.dart @@ -0,0 +1,75 @@ +import 'package:objectbox/objectbox.dart'; +import 'package:ndk/entities.dart' as ndk_entities; + +@Entity() +class DbWalletCahsuKeyset { + @Id() + int dbId = 0; + + @Property() + String id; + + @Property() + String mintUrl; + + @Property() + String unit; + @Property() + bool active; + + @Property() + int inputFeePPK; + + @Property() + List mintKeyPairs; + + @Property() + int? fetchedAt; + + DbWalletCahsuKeyset({ + required this.id, + required this.mintUrl, + required this.unit, + required this.active, + required this.inputFeePPK, + required this.mintKeyPairs, + this.fetchedAt, + }); + + factory DbWalletCahsuKeyset.fromNdk(ndk_entities.CahsuKeyset ndkM) { + final dbM = DbWalletCahsuKeyset( + id: ndkM.id, + mintUrl: ndkM.mintUrl, + unit: ndkM.unit, + active: ndkM.active, + inputFeePPK: ndkM.inputFeePPK, + mintKeyPairs: ndkM.mintKeyPairs + .map((pair) => '${pair.amount}:${pair.pubkey}') + .toList(), + fetchedAt: + ndkM.fetchedAt ?? DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + + return dbM; + } + + ndk_entities.CahsuKeyset toNdk() { + final ndkM = ndk_entities.CahsuKeyset( + id: id, + mintUrl: mintUrl, + unit: unit, + active: active, + inputFeePPK: inputFeePPK, + mintKeyPairs: mintKeyPairs.map((pair) { + final parts = pair.split(':'); + return ndk_entities.CahsuMintKeyPair( + amount: int.parse(parts[0]), + pubkey: parts[1], + ); + }).toSet(), + fetchedAt: fetchedAt, + ); + + return ndkM; + } +} diff --git a/packages/objectbox/lib/data_layer/db/object_box/schema/db_cashu_mint_info.dart b/packages/objectbox/lib/data_layer/db/object_box/schema/db_cashu_mint_info.dart new file mode 100644 index 000000000..a97a7ad32 --- /dev/null +++ b/packages/objectbox/lib/data_layer/db/object_box/schema/db_cashu_mint_info.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; + +import 'package:objectbox/objectbox.dart'; +import 'package:ndk/entities.dart' as ndk_entities; + +@Entity() +class DbCashuMintInfo { + @Id() + int dbId = 0; + + @Property() + String? name; + + @Property() + String? version; + + @Property() + String? description; + + @Property() + String? descriptionLong; + + @Property() + String contactJson; + + @Property() + String? motd; + + @Property() + String? iconUrl; + + @Property() + List urls; + + @Property() + int? time; + + @Property() + String? tosUrl; + + @Property() + String nutsJson; + + DbCashuMintInfo({ + this.name, + this.version, + this.description, + this.descriptionLong, + required this.contactJson, + this.motd, + this.iconUrl, + required this.urls, + this.time, + this.tosUrl, + required this.nutsJson, + }); + + factory DbCashuMintInfo.fromNdk(ndk_entities.CashuMintInfo ndkM) { + final dbM = DbCashuMintInfo( + name: ndkM.name, + version: ndkM.version, + description: ndkM.description, + descriptionLong: ndkM.descriptionLong, + contactJson: jsonEncode( + ndkM.contact.map((c) => c.toJson()).toList(), + ), + motd: ndkM.motd, + iconUrl: ndkM.iconUrl, + urls: ndkM.urls, + time: ndkM.time, + tosUrl: ndkM.tosUrl, + nutsJson: jsonEncode( + ndkM.nuts.map((k, v) => MapEntry(k.toString(), v.toJson())), + ), + ); + return dbM; + } + + ndk_entities.CashuMintInfo toNdk() { + final decodedContact = (jsonDecode(contactJson) as List) + .map((e) => ndk_entities.CashuMintContact.fromJson( + Map.from(e as Map), + )) + .toList(); + + final decodedNutsRaw = Map.from( + jsonDecode(nutsJson) as Map, + ); + final decodedNuts = decodedNutsRaw.map( + (key, value) => MapEntry( + int.parse(key), + ndk_entities.CashuMintNut.fromJson( + Map.from(value as Map), + ), + ), + ); + + final ndkM = ndk_entities.CashuMintInfo( + name: name, + version: version, + description: description, + descriptionLong: descriptionLong, + contact: decodedContact, + motd: motd, + iconUrl: iconUrl, + urls: urls, + time: time, + tosUrl: tosUrl, + nuts: decodedNuts, + ); + + return ndkM; + } +} diff --git a/packages/objectbox/lib/data_layer/db/object_box/schema/db_cashu_proof.dart b/packages/objectbox/lib/data_layer/db/object_box/schema/db_cashu_proof.dart new file mode 100644 index 000000000..cc5fe7fd3 --- /dev/null +++ b/packages/objectbox/lib/data_layer/db/object_box/schema/db_cashu_proof.dart @@ -0,0 +1,54 @@ +import 'package:objectbox/objectbox.dart'; +import 'package:ndk/entities.dart' as ndk_entities; + +@Entity() +class DbWalletCashuProof { + @Id() + int dbId = 0; + + @Property() + String keysetId; + @Property() + int amount; + + @Property() + String secret; + + @Property() + String unblindedSig; + + @Property() + String state; + + DbWalletCashuProof({ + required this.keysetId, + required this.amount, + required this.secret, + required this.unblindedSig, + required this.state, + }); + + factory DbWalletCashuProof.fromNdk(ndk_entities.CashuProof ndkM) { + final dbM = DbWalletCashuProof( + keysetId: ndkM.keysetId, + amount: ndkM.amount, + secret: ndkM.secret, + unblindedSig: ndkM.unblindedSig, + state: ndkM.state.toString(), + ); + + return dbM; + } + + ndk_entities.CashuProof toNdk() { + final ndkM = ndk_entities.CashuProof( + keysetId: keysetId, + amount: amount, + secret: secret, + unblindedSig: unblindedSig, + state: ndk_entities.CashuProofState.fromValue(state), + ); + + return ndkM; + } +} diff --git a/packages/objectbox/lib/data_layer/db/object_box/schema/db_cashu_secret_counter.dart b/packages/objectbox/lib/data_layer/db/object_box/schema/db_cashu_secret_counter.dart new file mode 100644 index 000000000..4f89af000 --- /dev/null +++ b/packages/objectbox/lib/data_layer/db/object_box/schema/db_cashu_secret_counter.dart @@ -0,0 +1,26 @@ +import 'package:objectbox/objectbox.dart'; + +@Entity() +class DbCashuSecretCounter { + @Id() + int dbId = 0; + + @Unique() + @Index() + @Property() + final String mintUrl; + + @Unique() + @Index() + @Property() + final String keysetId; + + @Property(signed: true) + final int counter; + + DbCashuSecretCounter({ + required this.mintUrl, + required this.keysetId, + required this.counter, + }); +} diff --git a/packages/objectbox/lib/data_layer/db/object_box/schema/db_wallet.dart b/packages/objectbox/lib/data_layer/db/object_box/schema/db_wallet.dart new file mode 100644 index 000000000..4641584ea --- /dev/null +++ b/packages/objectbox/lib/data_layer/db/object_box/schema/db_wallet.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:objectbox/objectbox.dart'; +import 'package:ndk/entities.dart' as ndk_entities; + +@Entity() +class DbWallet { + @Id() + int dbId = 0; + + @Property() + String id; + + @Property() + String type; + + @Property() + List supportedUnits; + + @Property() + String name; + + @Property() + String metadataJsonString; + + DbWallet({ + required this.id, + required this.type, + required this.supportedUnits, + required this.name, + required this.metadataJsonString, + }); + + factory DbWallet.fromNdk(ndk_entities.Wallet ndkM) { + final dbM = DbWallet( + id: ndkM.id, + type: ndkM.type.toString(), + supportedUnits: ndkM.supportedUnits.toList(), + name: ndkM.name, + metadataJsonString: jsonEncode(ndkM.metadata), + ); + + return dbM; + } + + ndk_entities.Wallet toNdk() { + final ndkM = ndk_entities.Wallet.toWalletType( + id: id, + name: name, + type: ndk_entities.WalletType.fromValue(type), + supportedUnits: supportedUnits.toSet(), + metadata: jsonDecode(metadataJsonString), + ); + + return ndkM; + } +} diff --git a/packages/objectbox/lib/data_layer/db/object_box/schema/db_wallet_transaction.dart b/packages/objectbox/lib/data_layer/db/object_box/schema/db_wallet_transaction.dart new file mode 100644 index 000000000..280cff054 --- /dev/null +++ b/packages/objectbox/lib/data_layer/db/object_box/schema/db_wallet_transaction.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:objectbox/objectbox.dart'; +import 'package:ndk/entities.dart' as ndk_entities; + +@Entity() +class DbWalletTransaction { + @Id() + int dbId = 0; + + @Property() + String id; + + @Property() + String walletId; + + @Property() + int changeAmount; + + @Property() + String unit; + + @Property() + String walletType; + + @Property() + String state; + + @Property() + String? completionMsg; + + @Property() + int? transactionDate; + @Property() + int? initiatedDate; + + @Property() + String metadataJsonString; + + DbWalletTransaction({ + required this.id, + required this.walletId, + required this.changeAmount, + required this.unit, + required this.walletType, + required this.state, + this.completionMsg, + this.transactionDate, + this.initiatedDate, + required this.metadataJsonString, + }); + + factory DbWalletTransaction.fromNdk(ndk_entities.WalletTransaction ndkM) { + final dbM = DbWalletTransaction( + id: ndkM.id, + walletId: ndkM.walletId, + changeAmount: ndkM.changeAmount, + unit: ndkM.unit, + walletType: ndkM.walletType.toString(), + state: ndkM.state.toString(), + completionMsg: ndkM.completionMsg, + transactionDate: ndkM.transactionDate, + initiatedDate: ndkM.initiatedDate, + // Note: metadata is stored as a JSON string + metadataJsonString: jsonEncode(ndkM.metadata)); + + return dbM; + } + + ndk_entities.WalletTransaction toNdk() { + final ndkM = ndk_entities.WalletTransaction.toTransactionType( + id: id, + walletId: walletId, + changeAmount: changeAmount, + unit: unit, + walletType: ndk_entities.WalletType.fromValue(walletType), + state: ndk_entities.WalletTransactionState.fromValue(state), + completionMsg: completionMsg, + transactionDate: transactionDate, + initiatedDate: initiatedDate, + metadata: jsonDecode(metadataJsonString) as Map, + ); + + return ndkM; + } +} diff --git a/packages/objectbox/lib/objectbox-model.json b/packages/objectbox/lib/objectbox-model.json index 184c0d03b..15668cdf9 100644 --- a/packages/objectbox/lib/objectbox-model.json +++ b/packages/objectbox/lib/objectbox-model.json @@ -311,10 +311,303 @@ } ], "relations": [] + }, + { + "id": "7:698131493043166579", + "lastPropertyId": "8:7773840598494674606", + "name": "DbWalletCahsuKeyset", + "properties": [ + { + "id": "1:7970259218906410302", + "name": "dbId", + "type": 6, + "flags": 1 + }, + { + "id": "2:7668438476871712459", + "name": "id", + "type": 9 + }, + { + "id": "3:7282319240912033654", + "name": "mintUrl", + "type": 9 + }, + { + "id": "4:3585001127367148469", + "name": "unit", + "type": 9 + }, + { + "id": "5:6312611343850591444", + "name": "active", + "type": 1 + }, + { + "id": "6:4301618904982221632", + "name": "inputFeePPK", + "type": 6 + }, + { + "id": "7:890317222791666643", + "name": "mintKeyPairs", + "type": 30 + }, + { + "id": "8:7773840598494674606", + "name": "fetchedAt", + "type": 6 + } + ], + "relations": [] + }, + { + "id": "8:7203634548589534122", + "lastPropertyId": "6:6868293576511935265", + "name": "DbWalletCashuProof", + "properties": [ + { + "id": "1:4429724214335608001", + "name": "dbId", + "type": 6, + "flags": 1 + }, + { + "id": "2:1857417425121530563", + "name": "keysetId", + "type": 9 + }, + { + "id": "3:5749621417965180854", + "name": "amount", + "type": 6 + }, + { + "id": "4:4446254104193824225", + "name": "secret", + "type": 9 + }, + { + "id": "5:6294656987537494684", + "name": "unblindedSig", + "type": 9 + }, + { + "id": "6:6868293576511935265", + "name": "state", + "type": 9 + } + ], + "relations": [] + }, + { + "id": "9:4843976069028406780", + "lastPropertyId": "7:4491916464072815310", + "name": "DbWallet", + "properties": [ + { + "id": "1:2803460968575242300", + "name": "dbId", + "type": 6, + "flags": 1 + }, + { + "id": "2:5928177294242770624", + "name": "id", + "type": 9 + }, + { + "id": "3:4931878169587094779", + "name": "type", + "type": 9 + }, + { + "id": "4:5124323927135367950", + "name": "name", + "type": 9 + }, + { + "id": "5:3582256459096368032", + "name": "supportedUnits", + "type": 30 + }, + { + "id": "7:4491916464072815310", + "name": "metadataJsonString", + "type": 9 + } + ], + "relations": [] + }, + { + "id": "10:2707870737906176084", + "lastPropertyId": "11:3092540118635965720", + "name": "DbWalletTransaction", + "properties": [ + { + "id": "1:4964215471626448888", + "name": "dbId", + "type": 6, + "flags": 1 + }, + { + "id": "2:2268171515152137881", + "name": "id", + "type": 9 + }, + { + "id": "3:3815553497512932992", + "name": "walletId", + "type": 9 + }, + { + "id": "4:1284545487035067139", + "name": "changeAmount", + "type": 6 + }, + { + "id": "5:8089333082593198860", + "name": "unit", + "type": 9 + }, + { + "id": "6:8810607312851014784", + "name": "walletType", + "type": 9 + }, + { + "id": "7:662120020381927996", + "name": "state", + "type": 9 + }, + { + "id": "8:7366719423178014878", + "name": "completionMsg", + "type": 9 + }, + { + "id": "9:6641644784457917076", + "name": "transactionDate", + "type": 6 + }, + { + "id": "10:2365111131666685128", + "name": "initiatedDate", + "type": 6 + }, + { + "id": "11:3092540118635965720", + "name": "metadataJsonString", + "type": 9 + } + ], + "relations": [] + }, + { + "id": "11:7685475375528365740", + "lastPropertyId": "12:5248840734946600657", + "name": "DbCashuMintInfo", + "properties": [ + { + "id": "1:3986862540339028259", + "name": "dbId", + "type": 6, + "flags": 1 + }, + { + "id": "2:782681889938728815", + "name": "name", + "type": 9 + }, + { + "id": "3:6446557892342141409", + "name": "version", + "type": 9 + }, + { + "id": "4:5222801161447007866", + "name": "description", + "type": 9 + }, + { + "id": "5:1315350861179276877", + "name": "descriptionLong", + "type": 9 + }, + { + "id": "6:7239671510889020768", + "name": "contactJson", + "type": 9 + }, + { + "id": "7:4128030923595712840", + "name": "motd", + "type": 9 + }, + { + "id": "8:5203040174417421240", + "name": "iconUrl", + "type": 9 + }, + { + "id": "9:3724773223411020767", + "name": "urls", + "type": 30 + }, + { + "id": "10:6472773197434133274", + "name": "time", + "type": 6 + }, + { + "id": "11:987809007158169655", + "name": "tosUrl", + "type": 9 + }, + { + "id": "12:5248840734946600657", + "name": "nutsJson", + "type": 9 + } + ], + "relations": [] + }, + { + "id": "12:8398773073536877334", + "lastPropertyId": "4:7325103958591855882", + "name": "DbCashuSecretCounter", + "properties": [ + { + "id": "1:2717389289624160416", + "name": "dbId", + "type": 6, + "flags": 1 + }, + { + "id": "2:5432997359665615471", + "name": "mintUrl", + "type": 9, + "flags": 2080, + "indexId": "1:8004850869961138997" + }, + { + "id": "3:2389988473428542746", + "name": "keysetId", + "type": 9, + "flags": 2080, + "indexId": "2:6502113043226887563" + }, + { + "id": "4:7325103958591855882", + "name": "counter", + "type": 6 + } + ], + "relations": [] } ], - "lastEntityId": "6:263734506821907740", - "lastIndexId": "0:0", + "lastEntityId": "12:8398773073536877334", + "lastIndexId": "2:6502113043226887563", "lastRelationId": "0:0", "lastSequenceId": "0:0", "modelVersion": 5, @@ -322,7 +615,8 @@ "retiredEntityUids": [], "retiredIndexUids": [], "retiredPropertyUids": [ - 4248118904091022656 + 4248118904091022656, + 693682162918032185 ], "retiredRelationUids": [], "version": 1 diff --git a/packages/objectbox/lib/objectbox.g.dart b/packages/objectbox/lib/objectbox.g.dart index 2e559156a..461c5b645 100644 --- a/packages/objectbox/lib/objectbox.g.dart +++ b/packages/objectbox/lib/objectbox.g.dart @@ -14,324 +14,731 @@ import 'package:objectbox/internal.dart' import 'package:objectbox/objectbox.dart' as obx; import 'package:objectbox_flutter_libs/objectbox_flutter_libs.dart'; +import 'data_layer/db/object_box/schema/db_cashu_keyset.dart'; +import 'data_layer/db/object_box/schema/db_cashu_mint_info.dart'; +import 'data_layer/db/object_box/schema/db_cashu_proof.dart'; +import 'data_layer/db/object_box/schema/db_cashu_secret_counter.dart'; import 'data_layer/db/object_box/schema/db_contact_list.dart'; import 'data_layer/db/object_box/schema/db_metadata.dart'; import 'data_layer/db/object_box/schema/db_nip_01_event.dart'; import 'data_layer/db/object_box/schema/db_nip_05.dart'; import 'data_layer/db/object_box/schema/db_user_relay_list.dart'; +import 'data_layer/db/object_box/schema/db_wallet.dart'; +import 'data_layer/db/object_box/schema/db_wallet_transaction.dart'; export 'package:objectbox/objectbox.dart'; // so that callers only have to import this file final _entities = [ obx_int.ModelEntity( - id: const obx_int.IdUid(1, 7267168510261043026), - name: 'DbContactList', - lastPropertyId: const obx_int.IdUid(11, 1117239018948887115), - flags: 0, - properties: [ - obx_int.ModelProperty( - id: const obx_int.IdUid(1, 6986744434432699288), - name: 'dbId', - type: 6, - flags: 1), - obx_int.ModelProperty( - id: const obx_int.IdUid(2, 1357400473715190005), - name: 'pubKey', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(3, 5247455000660751531), - name: 'contacts', - type: 30, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(4, 7259358756009996880), - name: 'contactRelays', - type: 30, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(5, 4273369131818739468), - name: 'petnames', - type: 30, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(6, 2282344906672001423), - name: 'followedTags', - type: 30, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(7, 4195168652292271612), - name: 'followedCommunities', - type: 30, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(8, 3312722063406963894), - name: 'followedEvents', - type: 30, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(9, 1828915220935170355), - name: 'createdAt', - type: 6, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(10, 662334951917934052), - name: 'loadedTimestamp', - type: 6, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(11, 1117239018948887115), - name: 'sources', - type: 30, - flags: 0) - ], - relations: [], - backlinks: []), + id: const obx_int.IdUid(1, 7267168510261043026), + name: 'DbContactList', + lastPropertyId: const obx_int.IdUid(11, 1117239018948887115), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 6986744434432699288), + name: 'dbId', + type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 1357400473715190005), + name: 'pubKey', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 5247455000660751531), + name: 'contacts', + type: 30, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 7259358756009996880), + name: 'contactRelays', + type: 30, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 4273369131818739468), + name: 'petnames', + type: 30, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 2282344906672001423), + name: 'followedTags', + type: 30, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 4195168652292271612), + name: 'followedCommunities', + type: 30, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(8, 3312722063406963894), + name: 'followedEvents', + type: 30, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(9, 1828915220935170355), + name: 'createdAt', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(10, 662334951917934052), + name: 'loadedTimestamp', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(11, 1117239018948887115), + name: 'sources', + type: 30, + flags: 0, + ), + ], + relations: [], + backlinks: [], + ), obx_int.ModelEntity( - id: const obx_int.IdUid(2, 530428573583615038), - name: 'DbMetadata', - lastPropertyId: const obx_int.IdUid(15, 3659729329624536988), - flags: 0, - properties: [ - obx_int.ModelProperty( - id: const obx_int.IdUid(1, 6311528020388961921), - name: 'dbId', - type: 6, - flags: 1), - obx_int.ModelProperty( - id: const obx_int.IdUid(2, 7481035913984486655), - name: 'pubKey', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(3, 9172997580341748819), - name: 'name', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(4, 3546373795758858754), - name: 'displayName', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(5, 3230539604094051327), - name: 'picture', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(6, 3084473881747351979), - name: 'banner', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(7, 2993268374284627402), - name: 'website', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(8, 2895930692931049587), - name: 'about', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(9, 2125436107011149884), - name: 'nip05', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(10, 1537952694209901022), - name: 'lud16', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(11, 4250356651761253102), - name: 'lud06', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(12, 4824960073052435758), - name: 'updatedAt', - type: 6, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(13, 1741276455180885874), - name: 'refreshedTimestamp', - type: 6, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(14, 1086893591781785038), - name: 'splitDisplayNameWords', - type: 30, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(15, 3659729329624536988), - name: 'splitNameWords', - type: 30, - flags: 0) - ], - relations: [], - backlinks: []), + id: const obx_int.IdUid(2, 530428573583615038), + name: 'DbMetadata', + lastPropertyId: const obx_int.IdUid(15, 3659729329624536988), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 6311528020388961921), + name: 'dbId', + type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 7481035913984486655), + name: 'pubKey', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 9172997580341748819), + name: 'name', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 3546373795758858754), + name: 'displayName', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 3230539604094051327), + name: 'picture', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 3084473881747351979), + name: 'banner', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 2993268374284627402), + name: 'website', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(8, 2895930692931049587), + name: 'about', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(9, 2125436107011149884), + name: 'nip05', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(10, 1537952694209901022), + name: 'lud16', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(11, 4250356651761253102), + name: 'lud06', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(12, 4824960073052435758), + name: 'updatedAt', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(13, 1741276455180885874), + name: 'refreshedTimestamp', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(14, 1086893591781785038), + name: 'splitDisplayNameWords', + type: 30, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(15, 3659729329624536988), + name: 'splitNameWords', + type: 30, + flags: 0, + ), + ], + relations: [], + backlinks: [], + ), obx_int.ModelEntity( - id: const obx_int.IdUid(3, 7160354677947505848), - name: 'DbNip01Event', - lastPropertyId: const obx_int.IdUid(10, 6188110795031782335), - flags: 0, - properties: [ - obx_int.ModelProperty( - id: const obx_int.IdUid(1, 1845247413054177411), - name: 'dbId', - type: 6, - flags: 1), - obx_int.ModelProperty( - id: const obx_int.IdUid(2, 3881479899267615466), - name: 'nostrId', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(3, 906982549236467078), - name: 'pubKey', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(4, 4024855326378855057), - name: 'createdAt', - type: 6, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(5, 8369487538579223995), - name: 'kind', - type: 6, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(6, 4676453295471475548), - name: 'content', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(7, 9113759858694952977), - name: 'sig', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(8, 2027711114854456160), - name: 'validSig', - type: 1, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(9, 7564063012610719918), - name: 'sources', - type: 30, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(10, 6188110795031782335), - name: 'dbTags', - type: 30, - flags: 0) - ], - relations: [], - backlinks: []), + id: const obx_int.IdUid(3, 7160354677947505848), + name: 'DbNip01Event', + lastPropertyId: const obx_int.IdUid(10, 6188110795031782335), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 1845247413054177411), + name: 'dbId', + type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 3881479899267615466), + name: 'nostrId', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 906982549236467078), + name: 'pubKey', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 4024855326378855057), + name: 'createdAt', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 8369487538579223995), + name: 'kind', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 4676453295471475548), + name: 'content', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 9113759858694952977), + name: 'sig', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(8, 2027711114854456160), + name: 'validSig', + type: 1, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(9, 7564063012610719918), + name: 'sources', + type: 30, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(10, 6188110795031782335), + name: 'dbTags', + type: 30, + flags: 0, + ), + ], + relations: [], + backlinks: [], + ), obx_int.ModelEntity( - id: const obx_int.IdUid(4, 3637320921488077827), - name: 'DbTag', - lastPropertyId: const obx_int.IdUid(4, 1024563472021235903), - flags: 0, - properties: [ - obx_int.ModelProperty( - id: const obx_int.IdUid(1, 2662554970568175356), - name: 'id', - type: 6, - flags: 1), - obx_int.ModelProperty( - id: const obx_int.IdUid(2, 7256594753475161899), - name: 'key', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(3, 7261401074391147060), - name: 'value', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(4, 1024563472021235903), - name: 'marker', - type: 9, - flags: 0) - ], - relations: [], - backlinks: []), + id: const obx_int.IdUid(4, 3637320921488077827), + name: 'DbTag', + lastPropertyId: const obx_int.IdUid(4, 1024563472021235903), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 2662554970568175356), + name: 'id', + type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 7256594753475161899), + name: 'key', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 7261401074391147060), + name: 'value', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 1024563472021235903), + name: 'marker', + type: 9, + flags: 0, + ), + ], + relations: [], + backlinks: [], + ), obx_int.ModelEntity( - id: const obx_int.IdUid(5, 1189162834702422075), - name: 'DbNip05', - lastPropertyId: const obx_int.IdUid(7, 8942013022024139638), - flags: 0, - properties: [ - obx_int.ModelProperty( - id: const obx_int.IdUid(1, 7969165770416025296), - name: 'dbId', - type: 6, - flags: 1), - obx_int.ModelProperty( - id: const obx_int.IdUid(2, 7645157164222799699), - name: 'pubKey', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(3, 7879974338560469443), - name: 'nip05', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(4, 5481522983626357888), - name: 'valid', - type: 1, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(6, 5240456446636403236), - name: 'networkFetchTime', - type: 6, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(7, 8942013022024139638), - name: 'relays', - type: 30, - flags: 0) - ], - relations: [], - backlinks: []), + id: const obx_int.IdUid(5, 1189162834702422075), + name: 'DbNip05', + lastPropertyId: const obx_int.IdUid(7, 8942013022024139638), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 7969165770416025296), + name: 'dbId', + type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 7645157164222799699), + name: 'pubKey', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 7879974338560469443), + name: 'nip05', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 5481522983626357888), + name: 'valid', + type: 1, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 5240456446636403236), + name: 'networkFetchTime', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 8942013022024139638), + name: 'relays', + type: 30, + flags: 0, + ), + ], + relations: [], + backlinks: [], + ), obx_int.ModelEntity( - id: const obx_int.IdUid(6, 263734506821907740), - name: 'DbUserRelayList', - lastPropertyId: const obx_int.IdUid(5, 745081192237571667), - flags: 0, - properties: [ - obx_int.ModelProperty( - id: const obx_int.IdUid(1, 1592738392109903014), - name: 'dbId', - type: 6, - flags: 1), - obx_int.ModelProperty( - id: const obx_int.IdUid(2, 4136737139372327801), - name: 'pubKey', - type: 9, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(3, 3907673358109208090), - name: 'createdAt', - type: 6, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(4, 6745786677378578982), - name: 'refreshedTimestamp', - type: 6, - flags: 0), - obx_int.ModelProperty( - id: const obx_int.IdUid(5, 745081192237571667), - name: 'relaysJson', - type: 9, - flags: 0) - ], - relations: [], - backlinks: []) + id: const obx_int.IdUid(6, 263734506821907740), + name: 'DbUserRelayList', + lastPropertyId: const obx_int.IdUid(5, 745081192237571667), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 1592738392109903014), + name: 'dbId', + type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 4136737139372327801), + name: 'pubKey', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 3907673358109208090), + name: 'createdAt', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 6745786677378578982), + name: 'refreshedTimestamp', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 745081192237571667), + name: 'relaysJson', + type: 9, + flags: 0, + ), + ], + relations: [], + backlinks: [], + ), + obx_int.ModelEntity( + id: const obx_int.IdUid(7, 698131493043166579), + name: 'DbWalletCahsuKeyset', + lastPropertyId: const obx_int.IdUid(8, 7773840598494674606), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 7970259218906410302), + name: 'dbId', + type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 7668438476871712459), + name: 'id', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 7282319240912033654), + name: 'mintUrl', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 3585001127367148469), + name: 'unit', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 6312611343850591444), + name: 'active', + type: 1, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 4301618904982221632), + name: 'inputFeePPK', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 890317222791666643), + name: 'mintKeyPairs', + type: 30, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(8, 7773840598494674606), + name: 'fetchedAt', + type: 6, + flags: 0, + ), + ], + relations: [], + backlinks: [], + ), + obx_int.ModelEntity( + id: const obx_int.IdUid(8, 7203634548589534122), + name: 'DbWalletCashuProof', + lastPropertyId: const obx_int.IdUid(6, 6868293576511935265), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 4429724214335608001), + name: 'dbId', + type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 1857417425121530563), + name: 'keysetId', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 5749621417965180854), + name: 'amount', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 4446254104193824225), + name: 'secret', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 6294656987537494684), + name: 'unblindedSig', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 6868293576511935265), + name: 'state', + type: 9, + flags: 0, + ), + ], + relations: [], + backlinks: [], + ), + obx_int.ModelEntity( + id: const obx_int.IdUid(9, 4843976069028406780), + name: 'DbWallet', + lastPropertyId: const obx_int.IdUid(7, 4491916464072815310), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 2803460968575242300), + name: 'dbId', + type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 5928177294242770624), + name: 'id', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 4931878169587094779), + name: 'type', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 5124323927135367950), + name: 'name', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 3582256459096368032), + name: 'supportedUnits', + type: 30, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 4491916464072815310), + name: 'metadataJsonString', + type: 9, + flags: 0, + ), + ], + relations: [], + backlinks: [], + ), + obx_int.ModelEntity( + id: const obx_int.IdUid(10, 2707870737906176084), + name: 'DbWalletTransaction', + lastPropertyId: const obx_int.IdUid(11, 3092540118635965720), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 4964215471626448888), + name: 'dbId', + type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 2268171515152137881), + name: 'id', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 3815553497512932992), + name: 'walletId', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 1284545487035067139), + name: 'changeAmount', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 8089333082593198860), + name: 'unit', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 8810607312851014784), + name: 'walletType', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 662120020381927996), + name: 'state', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(8, 7366719423178014878), + name: 'completionMsg', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(9, 6641644784457917076), + name: 'transactionDate', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(10, 2365111131666685128), + name: 'initiatedDate', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(11, 3092540118635965720), + name: 'metadataJsonString', + type: 9, + flags: 0, + ), + ], + relations: [], + backlinks: [], + ), + obx_int.ModelEntity( + id: const obx_int.IdUid(11, 7685475375528365740), + name: 'DbCashuMintInfo', + lastPropertyId: const obx_int.IdUid(12, 5248840734946600657), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 3986862540339028259), + name: 'dbId', + type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 782681889938728815), + name: 'name', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 6446557892342141409), + name: 'version', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 5222801161447007866), + name: 'description', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 1315350861179276877), + name: 'descriptionLong', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 7239671510889020768), + name: 'contactJson', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 4128030923595712840), + name: 'motd', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(8, 5203040174417421240), + name: 'iconUrl', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(9, 3724773223411020767), + name: 'urls', + type: 30, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(10, 6472773197434133274), + name: 'time', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(11, 987809007158169655), + name: 'tosUrl', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(12, 5248840734946600657), + name: 'nutsJson', + type: 9, + flags: 0, + ), + ], + relations: [], + backlinks: [], + ), + obx_int.ModelEntity( + id: const obx_int.IdUid(12, 8398773073536877334), + name: 'DbCashuSecretCounter', + lastPropertyId: const obx_int.IdUid(4, 7325103958591855882), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 2717389289624160416), + name: 'dbId', + type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 5432997359665615471), + name: 'mintUrl', + type: 9, + flags: 2080, + indexId: const obx_int.IdUid(1, 8004850869961138997), + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 2389988473428542746), + name: 'keysetId', + type: 9, + flags: 2080, + indexId: const obx_int.IdUid(2, 6502113043226887563), + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 7325103958591855882), + name: 'counter', + type: 6, + flags: 0, + ), + ], + relations: [], + backlinks: [], + ), ]; /// Shortcut for [obx.Store.new] that passes [getObjectBoxModel] and for Flutter @@ -345,433 +752,961 @@ final _entities = [ /// For Flutter apps, also calls `loadObjectBoxLibraryAndroidCompat()` from /// the ObjectBox Flutter library to fix loading the native ObjectBox library /// on Android 6 and older. -Future openStore( - {String? directory, - int? maxDBSizeInKB, - int? maxDataSizeInKB, - int? fileMode, - int? maxReaders, - bool queriesCaseSensitiveDefault = true, - String? macosApplicationGroup}) async { +Future openStore({ + String? directory, + int? maxDBSizeInKB, + int? maxDataSizeInKB, + int? fileMode, + int? maxReaders, + bool queriesCaseSensitiveDefault = true, + String? macosApplicationGroup, +}) async { await loadObjectBoxLibraryAndroidCompat(); - return obx.Store(getObjectBoxModel(), - directory: directory ?? (await defaultStoreDirectory()).path, - maxDBSizeInKB: maxDBSizeInKB, - maxDataSizeInKB: maxDataSizeInKB, - fileMode: fileMode, - maxReaders: maxReaders, - queriesCaseSensitiveDefault: queriesCaseSensitiveDefault, - macosApplicationGroup: macosApplicationGroup); + return obx.Store( + getObjectBoxModel(), + directory: directory ?? (await defaultStoreDirectory()).path, + maxDBSizeInKB: maxDBSizeInKB, + maxDataSizeInKB: maxDataSizeInKB, + fileMode: fileMode, + maxReaders: maxReaders, + queriesCaseSensitiveDefault: queriesCaseSensitiveDefault, + macosApplicationGroup: macosApplicationGroup, + ); } /// Returns the ObjectBox model definition for this project for use with /// [obx.Store.new]. obx_int.ModelDefinition getObjectBoxModel() { final model = obx_int.ModelInfo( - entities: _entities, - lastEntityId: const obx_int.IdUid(6, 263734506821907740), - lastIndexId: const obx_int.IdUid(0, 0), - lastRelationId: const obx_int.IdUid(0, 0), - lastSequenceId: const obx_int.IdUid(0, 0), - retiredEntityUids: const [], - retiredIndexUids: const [], - retiredPropertyUids: const [4248118904091022656], - retiredRelationUids: const [], - modelVersion: 5, - modelVersionParserMinimum: 5, - version: 1); + entities: _entities, + lastEntityId: const obx_int.IdUid(12, 8398773073536877334), + lastIndexId: const obx_int.IdUid(2, 6502113043226887563), + lastRelationId: const obx_int.IdUid(0, 0), + lastSequenceId: const obx_int.IdUid(0, 0), + retiredEntityUids: const [], + retiredIndexUids: const [], + retiredPropertyUids: const [4248118904091022656, 693682162918032185], + retiredRelationUids: const [], + modelVersion: 5, + modelVersionParserMinimum: 5, + version: 1, + ); final bindings = { DbContactList: obx_int.EntityDefinition( - model: _entities[0], - toOneRelations: (DbContactList object) => [], - toManyRelations: (DbContactList object) => {}, - getId: (DbContactList object) => object.dbId, - setId: (DbContactList object, int id) { - object.dbId = id; - }, - objectToFB: (DbContactList object, fb.Builder fbb) { - final pubKeyOffset = fbb.writeString(object.pubKey); - final contactsOffset = fbb.writeList( - object.contacts.map(fbb.writeString).toList(growable: false)); - final contactRelaysOffset = fbb.writeList(object.contactRelays - .map(fbb.writeString) - .toList(growable: false)); - final petnamesOffset = fbb.writeList( - object.petnames.map(fbb.writeString).toList(growable: false)); - final followedTagsOffset = fbb.writeList( - object.followedTags.map(fbb.writeString).toList(growable: false)); - final followedCommunitiesOffset = fbb.writeList(object - .followedCommunities - .map(fbb.writeString) - .toList(growable: false)); - final followedEventsOffset = fbb.writeList(object.followedEvents + model: _entities[0], + toOneRelations: (DbContactList object) => [], + toManyRelations: (DbContactList object) => {}, + getId: (DbContactList object) => object.dbId, + setId: (DbContactList object, int id) { + object.dbId = id; + }, + objectToFB: (DbContactList object, fb.Builder fbb) { + final pubKeyOffset = fbb.writeString(object.pubKey); + final contactsOffset = fbb.writeList( + object.contacts.map(fbb.writeString).toList(growable: false), + ); + final contactRelaysOffset = fbb.writeList( + object.contactRelays.map(fbb.writeString).toList(growable: false), + ); + final petnamesOffset = fbb.writeList( + object.petnames.map(fbb.writeString).toList(growable: false), + ); + final followedTagsOffset = fbb.writeList( + object.followedTags.map(fbb.writeString).toList(growable: false), + ); + final followedCommunitiesOffset = fbb.writeList( + object.followedCommunities .map(fbb.writeString) - .toList(growable: false)); - final sourcesOffset = fbb.writeList( - object.sources.map(fbb.writeString).toList(growable: false)); - fbb.startTable(12); - fbb.addInt64(0, object.dbId); - fbb.addOffset(1, pubKeyOffset); - fbb.addOffset(2, contactsOffset); - fbb.addOffset(3, contactRelaysOffset); - fbb.addOffset(4, petnamesOffset); - fbb.addOffset(5, followedTagsOffset); - fbb.addOffset(6, followedCommunitiesOffset); - fbb.addOffset(7, followedEventsOffset); - fbb.addInt64(8, object.createdAt); - fbb.addInt64(9, object.loadedTimestamp); - fbb.addOffset(10, sourcesOffset); - fbb.finish(fbb.endTable()); - return object.dbId; - }, - objectFromFB: (obx.Store store, ByteData fbData) { - final buffer = fb.BufferContext(fbData); - final rootOffset = buffer.derefObject(0); - final pubKeyParam = const fb.StringReader(asciiOptimization: true) - .vTableGet(buffer, rootOffset, 6, ''); - final contactsParam = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false) - .vTableGet(buffer, rootOffset, 8, []); - final object = DbContactList( - pubKey: pubKeyParam, contacts: contactsParam) - ..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0) - ..contactRelays = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false) - .vTableGet(buffer, rootOffset, 10, []) - ..petnames = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false) - .vTableGet(buffer, rootOffset, 12, []) - ..followedTags = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false) - .vTableGet(buffer, rootOffset, 14, []) - ..followedCommunities = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false) - .vTableGet(buffer, rootOffset, 16, []) - ..followedEvents = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false) - .vTableGet(buffer, rootOffset, 18, []) - ..createdAt = - const fb.Int64Reader().vTableGet(buffer, rootOffset, 20, 0) - ..loadedTimestamp = - const fb.Int64Reader().vTableGetNullable(buffer, rootOffset, 22) - ..sources = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false) - .vTableGet(buffer, rootOffset, 24, []); - - return object; - }), + .toList(growable: false), + ); + final followedEventsOffset = fbb.writeList( + object.followedEvents.map(fbb.writeString).toList(growable: false), + ); + final sourcesOffset = fbb.writeList( + object.sources.map(fbb.writeString).toList(growable: false), + ); + fbb.startTable(12); + fbb.addInt64(0, object.dbId); + fbb.addOffset(1, pubKeyOffset); + fbb.addOffset(2, contactsOffset); + fbb.addOffset(3, contactRelaysOffset); + fbb.addOffset(4, petnamesOffset); + fbb.addOffset(5, followedTagsOffset); + fbb.addOffset(6, followedCommunitiesOffset); + fbb.addOffset(7, followedEventsOffset); + fbb.addInt64(8, object.createdAt); + fbb.addInt64(9, object.loadedTimestamp); + fbb.addOffset(10, sourcesOffset); + fbb.finish(fbb.endTable()); + return object.dbId; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final pubKeyParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 6, ''); + final contactsParam = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGet(buffer, rootOffset, 8, []); + final object = + DbContactList(pubKey: pubKeyParam, contacts: contactsParam) + ..dbId = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 4, + 0, + ) + ..contactRelays = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGet(buffer, rootOffset, 10, []) + ..petnames = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGet(buffer, rootOffset, 12, []) + ..followedTags = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGet(buffer, rootOffset, 14, []) + ..followedCommunities = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGet(buffer, rootOffset, 16, []) + ..followedEvents = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGet(buffer, rootOffset, 18, []) + ..createdAt = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 20, + 0, + ) + ..loadedTimestamp = const fb.Int64Reader().vTableGetNullable( + buffer, + rootOffset, + 22, + ) + ..sources = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGet(buffer, rootOffset, 24, []); + + return object; + }, + ), DbMetadata: obx_int.EntityDefinition( - model: _entities[1], - toOneRelations: (DbMetadata object) => [], - toManyRelations: (DbMetadata object) => {}, - getId: (DbMetadata object) => object.dbId, - setId: (DbMetadata object, int id) { - object.dbId = id; - }, - objectToFB: (DbMetadata object, fb.Builder fbb) { - final pubKeyOffset = fbb.writeString(object.pubKey); - final nameOffset = - object.name == null ? null : fbb.writeString(object.name!); - final displayNameOffset = object.displayName == null - ? null - : fbb.writeString(object.displayName!); - final pictureOffset = - object.picture == null ? null : fbb.writeString(object.picture!); - final bannerOffset = - object.banner == null ? null : fbb.writeString(object.banner!); - final websiteOffset = - object.website == null ? null : fbb.writeString(object.website!); - final aboutOffset = - object.about == null ? null : fbb.writeString(object.about!); - final nip05Offset = - object.nip05 == null ? null : fbb.writeString(object.nip05!); - final lud16Offset = - object.lud16 == null ? null : fbb.writeString(object.lud16!); - final lud06Offset = - object.lud06 == null ? null : fbb.writeString(object.lud06!); - final splitDisplayNameWordsOffset = - object.splitDisplayNameWords == null - ? null - : fbb.writeList(object.splitDisplayNameWords! - .map(fbb.writeString) - .toList(growable: false)); - final splitNameWordsOffset = object.splitNameWords == null - ? null - : fbb.writeList(object.splitNameWords! - .map(fbb.writeString) - .toList(growable: false)); - fbb.startTable(16); - fbb.addInt64(0, object.dbId); - fbb.addOffset(1, pubKeyOffset); - fbb.addOffset(2, nameOffset); - fbb.addOffset(3, displayNameOffset); - fbb.addOffset(4, pictureOffset); - fbb.addOffset(5, bannerOffset); - fbb.addOffset(6, websiteOffset); - fbb.addOffset(7, aboutOffset); - fbb.addOffset(8, nip05Offset); - fbb.addOffset(9, lud16Offset); - fbb.addOffset(10, lud06Offset); - fbb.addInt64(11, object.updatedAt); - fbb.addInt64(12, object.refreshedTimestamp); - fbb.addOffset(13, splitDisplayNameWordsOffset); - fbb.addOffset(14, splitNameWordsOffset); - fbb.finish(fbb.endTable()); - return object.dbId; - }, - objectFromFB: (obx.Store store, ByteData fbData) { - final buffer = fb.BufferContext(fbData); - final rootOffset = buffer.derefObject(0); - final pubKeyParam = const fb.StringReader(asciiOptimization: true) - .vTableGet(buffer, rootOffset, 6, ''); - final nameParam = const fb.StringReader(asciiOptimization: true) - .vTableGetNullable(buffer, rootOffset, 8); - final displayNameParam = - const fb.StringReader(asciiOptimization: true) - .vTableGetNullable(buffer, rootOffset, 10); - final splitNameWordsParam = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false) - .vTableGetNullable(buffer, rootOffset, 32); - final splitDisplayNameWordsParam = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false) - .vTableGetNullable(buffer, rootOffset, 30); - final pictureParam = const fb.StringReader(asciiOptimization: true) - .vTableGetNullable(buffer, rootOffset, 12); - final bannerParam = const fb.StringReader(asciiOptimization: true) - .vTableGetNullable(buffer, rootOffset, 14); - final websiteParam = const fb.StringReader(asciiOptimization: true) - .vTableGetNullable(buffer, rootOffset, 16); - final aboutParam = const fb.StringReader(asciiOptimization: true) - .vTableGetNullable(buffer, rootOffset, 18); - final nip05Param = const fb.StringReader(asciiOptimization: true) - .vTableGetNullable(buffer, rootOffset, 20); - final lud16Param = const fb.StringReader(asciiOptimization: true) - .vTableGetNullable(buffer, rootOffset, 22); - final lud06Param = const fb.StringReader(asciiOptimization: true) - .vTableGetNullable(buffer, rootOffset, 24); - final updatedAtParam = - const fb.Int64Reader().vTableGetNullable(buffer, rootOffset, 26); - final refreshedTimestampParam = - const fb.Int64Reader().vTableGetNullable(buffer, rootOffset, 28); - final object = DbMetadata( - pubKey: pubKeyParam, - name: nameParam, - displayName: displayNameParam, - splitNameWords: splitNameWordsParam, - splitDisplayNameWords: splitDisplayNameWordsParam, - picture: pictureParam, - banner: bannerParam, - website: websiteParam, - about: aboutParam, - nip05: nip05Param, - lud16: lud16Param, - lud06: lud06Param, - updatedAt: updatedAtParam, - refreshedTimestamp: refreshedTimestampParam) - ..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); - - return object; - }), + model: _entities[1], + toOneRelations: (DbMetadata object) => [], + toManyRelations: (DbMetadata object) => {}, + getId: (DbMetadata object) => object.dbId, + setId: (DbMetadata object, int id) { + object.dbId = id; + }, + objectToFB: (DbMetadata object, fb.Builder fbb) { + final pubKeyOffset = fbb.writeString(object.pubKey); + final nameOffset = object.name == null + ? null + : fbb.writeString(object.name!); + final displayNameOffset = object.displayName == null + ? null + : fbb.writeString(object.displayName!); + final pictureOffset = object.picture == null + ? null + : fbb.writeString(object.picture!); + final bannerOffset = object.banner == null + ? null + : fbb.writeString(object.banner!); + final websiteOffset = object.website == null + ? null + : fbb.writeString(object.website!); + final aboutOffset = object.about == null + ? null + : fbb.writeString(object.about!); + final nip05Offset = object.nip05 == null + ? null + : fbb.writeString(object.nip05!); + final lud16Offset = object.lud16 == null + ? null + : fbb.writeString(object.lud16!); + final lud06Offset = object.lud06 == null + ? null + : fbb.writeString(object.lud06!); + final splitDisplayNameWordsOffset = object.splitDisplayNameWords == null + ? null + : fbb.writeList( + object.splitDisplayNameWords! + .map(fbb.writeString) + .toList(growable: false), + ); + final splitNameWordsOffset = object.splitNameWords == null + ? null + : fbb.writeList( + object.splitNameWords! + .map(fbb.writeString) + .toList(growable: false), + ); + fbb.startTable(16); + fbb.addInt64(0, object.dbId); + fbb.addOffset(1, pubKeyOffset); + fbb.addOffset(2, nameOffset); + fbb.addOffset(3, displayNameOffset); + fbb.addOffset(4, pictureOffset); + fbb.addOffset(5, bannerOffset); + fbb.addOffset(6, websiteOffset); + fbb.addOffset(7, aboutOffset); + fbb.addOffset(8, nip05Offset); + fbb.addOffset(9, lud16Offset); + fbb.addOffset(10, lud06Offset); + fbb.addInt64(11, object.updatedAt); + fbb.addInt64(12, object.refreshedTimestamp); + fbb.addOffset(13, splitDisplayNameWordsOffset); + fbb.addOffset(14, splitNameWordsOffset); + fbb.finish(fbb.endTable()); + return object.dbId; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final pubKeyParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 6, ''); + final nameParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 8); + final displayNameParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 10); + final splitNameWordsParam = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGetNullable(buffer, rootOffset, 32); + final splitDisplayNameWordsParam = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGetNullable(buffer, rootOffset, 30); + final pictureParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 12); + final bannerParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 14); + final websiteParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 16); + final aboutParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 18); + final nip05Param = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 20); + final lud16Param = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 22); + final lud06Param = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 24); + final updatedAtParam = const fb.Int64Reader().vTableGetNullable( + buffer, + rootOffset, + 26, + ); + final refreshedTimestampParam = const fb.Int64Reader() + .vTableGetNullable(buffer, rootOffset, 28); + final object = DbMetadata( + pubKey: pubKeyParam, + name: nameParam, + displayName: displayNameParam, + splitNameWords: splitNameWordsParam, + splitDisplayNameWords: splitDisplayNameWordsParam, + picture: pictureParam, + banner: bannerParam, + website: websiteParam, + about: aboutParam, + nip05: nip05Param, + lud16: lud16Param, + lud06: lud06Param, + updatedAt: updatedAtParam, + refreshedTimestamp: refreshedTimestampParam, + )..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }, + ), DbNip01Event: obx_int.EntityDefinition( - model: _entities[2], - toOneRelations: (DbNip01Event object) => [], - toManyRelations: (DbNip01Event object) => {}, - getId: (DbNip01Event object) => object.dbId, - setId: (DbNip01Event object, int id) { - object.dbId = id; - }, - objectToFB: (DbNip01Event object, fb.Builder fbb) { - final nostrIdOffset = fbb.writeString(object.nostrId); - final pubKeyOffset = fbb.writeString(object.pubKey); - final contentOffset = fbb.writeString(object.content); - final sigOffset = fbb.writeString(object.sig); - final sourcesOffset = fbb.writeList( - object.sources.map(fbb.writeString).toList(growable: false)); - final dbTagsOffset = fbb.writeList( - object.dbTags.map(fbb.writeString).toList(growable: false)); - fbb.startTable(11); - fbb.addInt64(0, object.dbId); - fbb.addOffset(1, nostrIdOffset); - fbb.addOffset(2, pubKeyOffset); - fbb.addInt64(3, object.createdAt); - fbb.addInt64(4, object.kind); - fbb.addOffset(5, contentOffset); - fbb.addOffset(6, sigOffset); - fbb.addBool(7, object.validSig); - fbb.addOffset(8, sourcesOffset); - fbb.addOffset(9, dbTagsOffset); - fbb.finish(fbb.endTable()); - return object.dbId; - }, - objectFromFB: (obx.Store store, ByteData fbData) { - final buffer = fb.BufferContext(fbData); - final rootOffset = buffer.derefObject(0); - final pubKeyParam = const fb.StringReader(asciiOptimization: true) - .vTableGet(buffer, rootOffset, 8, ''); - final kindParam = - const fb.Int64Reader().vTableGet(buffer, rootOffset, 12, 0); - final dbTagsParam = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false) - .vTableGet(buffer, rootOffset, 22, []); - final contentParam = const fb.StringReader(asciiOptimization: true) - .vTableGet(buffer, rootOffset, 14, ''); - final createdAtParam = - const fb.Int64Reader().vTableGet(buffer, rootOffset, 10, 0); - final object = DbNip01Event( - pubKey: pubKeyParam, - kind: kindParam, - dbTags: dbTagsParam, - content: contentParam, - createdAt: createdAtParam) - ..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0) - ..nostrId = const fb.StringReader(asciiOptimization: true) - .vTableGet(buffer, rootOffset, 6, '') - ..sig = const fb.StringReader(asciiOptimization: true) - .vTableGet(buffer, rootOffset, 16, '') - ..validSig = - const fb.BoolReader().vTableGetNullable(buffer, rootOffset, 18) - ..sources = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false) - .vTableGet(buffer, rootOffset, 20, []); - - return object; - }), + model: _entities[2], + toOneRelations: (DbNip01Event object) => [], + toManyRelations: (DbNip01Event object) => {}, + getId: (DbNip01Event object) => object.dbId, + setId: (DbNip01Event object, int id) { + object.dbId = id; + }, + objectToFB: (DbNip01Event object, fb.Builder fbb) { + final nostrIdOffset = fbb.writeString(object.nostrId); + final pubKeyOffset = fbb.writeString(object.pubKey); + final contentOffset = fbb.writeString(object.content); + final sigOffset = fbb.writeString(object.sig); + final sourcesOffset = fbb.writeList( + object.sources.map(fbb.writeString).toList(growable: false), + ); + final dbTagsOffset = fbb.writeList( + object.dbTags.map(fbb.writeString).toList(growable: false), + ); + fbb.startTable(11); + fbb.addInt64(0, object.dbId); + fbb.addOffset(1, nostrIdOffset); + fbb.addOffset(2, pubKeyOffset); + fbb.addInt64(3, object.createdAt); + fbb.addInt64(4, object.kind); + fbb.addOffset(5, contentOffset); + fbb.addOffset(6, sigOffset); + fbb.addBool(7, object.validSig); + fbb.addOffset(8, sourcesOffset); + fbb.addOffset(9, dbTagsOffset); + fbb.finish(fbb.endTable()); + return object.dbId; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final pubKeyParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 8, ''); + final kindParam = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 12, + 0, + ); + final dbTagsParam = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGet(buffer, rootOffset, 22, []); + final contentParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 14, ''); + final createdAtParam = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 10, + 0, + ); + final object = + DbNip01Event( + pubKey: pubKeyParam, + kind: kindParam, + dbTags: dbTagsParam, + content: contentParam, + createdAt: createdAtParam, + ) + ..dbId = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 4, + 0, + ) + ..nostrId = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 6, '') + ..sig = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 16, '') + ..validSig = const fb.BoolReader().vTableGetNullable( + buffer, + rootOffset, + 18, + ) + ..sources = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGet(buffer, rootOffset, 20, []); + + return object; + }, + ), DbTag: obx_int.EntityDefinition( - model: _entities[3], - toOneRelations: (DbTag object) => [], - toManyRelations: (DbTag object) => {}, - getId: (DbTag object) => object.id, - setId: (DbTag object, int id) { - object.id = id; - }, - objectToFB: (DbTag object, fb.Builder fbb) { - final keyOffset = fbb.writeString(object.key); - final valueOffset = fbb.writeString(object.value); - final markerOffset = - object.marker == null ? null : fbb.writeString(object.marker!); - fbb.startTable(5); - fbb.addInt64(0, object.id); - fbb.addOffset(1, keyOffset); - fbb.addOffset(2, valueOffset); - fbb.addOffset(3, markerOffset); - fbb.finish(fbb.endTable()); - return object.id; - }, - objectFromFB: (obx.Store store, ByteData fbData) { - final buffer = fb.BufferContext(fbData); - final rootOffset = buffer.derefObject(0); - final keyParam = const fb.StringReader(asciiOptimization: true) - .vTableGet(buffer, rootOffset, 6, ''); - final valueParam = const fb.StringReader(asciiOptimization: true) - .vTableGet(buffer, rootOffset, 8, ''); - final markerParam = const fb.StringReader(asciiOptimization: true) - .vTableGetNullable(buffer, rootOffset, 10); - final object = DbTag( - key: keyParam, value: valueParam, marker: markerParam) - ..id = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); - - return object; - }), + model: _entities[3], + toOneRelations: (DbTag object) => [], + toManyRelations: (DbTag object) => {}, + getId: (DbTag object) => object.id, + setId: (DbTag object, int id) { + object.id = id; + }, + objectToFB: (DbTag object, fb.Builder fbb) { + final keyOffset = fbb.writeString(object.key); + final valueOffset = fbb.writeString(object.value); + final markerOffset = object.marker == null + ? null + : fbb.writeString(object.marker!); + fbb.startTable(5); + fbb.addInt64(0, object.id); + fbb.addOffset(1, keyOffset); + fbb.addOffset(2, valueOffset); + fbb.addOffset(3, markerOffset); + fbb.finish(fbb.endTable()); + return object.id; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final keyParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 6, ''); + final valueParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 8, ''); + final markerParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 10); + final object = DbTag( + key: keyParam, + value: valueParam, + marker: markerParam, + )..id = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }, + ), DbNip05: obx_int.EntityDefinition( - model: _entities[4], - toOneRelations: (DbNip05 object) => [], - toManyRelations: (DbNip05 object) => {}, - getId: (DbNip05 object) => object.dbId, - setId: (DbNip05 object, int id) { - object.dbId = id; - }, - objectToFB: (DbNip05 object, fb.Builder fbb) { - final pubKeyOffset = fbb.writeString(object.pubKey); - final nip05Offset = fbb.writeString(object.nip05); - final relaysOffset = fbb.writeList( - object.relays.map(fbb.writeString).toList(growable: false)); - fbb.startTable(8); - fbb.addInt64(0, object.dbId); - fbb.addOffset(1, pubKeyOffset); - fbb.addOffset(2, nip05Offset); - fbb.addBool(3, object.valid); - fbb.addInt64(5, object.networkFetchTime); - fbb.addOffset(6, relaysOffset); - fbb.finish(fbb.endTable()); - return object.dbId; - }, - objectFromFB: (obx.Store store, ByteData fbData) { - final buffer = fb.BufferContext(fbData); - final rootOffset = buffer.derefObject(0); - final pubKeyParam = const fb.StringReader(asciiOptimization: true) - .vTableGet(buffer, rootOffset, 6, ''); - final nip05Param = const fb.StringReader(asciiOptimization: true) - .vTableGet(buffer, rootOffset, 8, ''); - final validParam = - const fb.BoolReader().vTableGet(buffer, rootOffset, 10, false); - final networkFetchTimeParam = - const fb.Int64Reader().vTableGetNullable(buffer, rootOffset, 14); - final relaysParam = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false) - .vTableGet(buffer, rootOffset, 16, []); - final object = DbNip05( - pubKey: pubKeyParam, - nip05: nip05Param, - valid: validParam, - networkFetchTime: networkFetchTimeParam, - relays: relaysParam) - ..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); - - return object; - }), + model: _entities[4], + toOneRelations: (DbNip05 object) => [], + toManyRelations: (DbNip05 object) => {}, + getId: (DbNip05 object) => object.dbId, + setId: (DbNip05 object, int id) { + object.dbId = id; + }, + objectToFB: (DbNip05 object, fb.Builder fbb) { + final pubKeyOffset = fbb.writeString(object.pubKey); + final nip05Offset = fbb.writeString(object.nip05); + final relaysOffset = fbb.writeList( + object.relays.map(fbb.writeString).toList(growable: false), + ); + fbb.startTable(8); + fbb.addInt64(0, object.dbId); + fbb.addOffset(1, pubKeyOffset); + fbb.addOffset(2, nip05Offset); + fbb.addBool(3, object.valid); + fbb.addInt64(5, object.networkFetchTime); + fbb.addOffset(6, relaysOffset); + fbb.finish(fbb.endTable()); + return object.dbId; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final pubKeyParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 6, ''); + final nip05Param = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 8, ''); + final validParam = const fb.BoolReader().vTableGet( + buffer, + rootOffset, + 10, + false, + ); + final networkFetchTimeParam = const fb.Int64Reader().vTableGetNullable( + buffer, + rootOffset, + 14, + ); + final relaysParam = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGet(buffer, rootOffset, 16, []); + final object = DbNip05( + pubKey: pubKeyParam, + nip05: nip05Param, + valid: validParam, + networkFetchTime: networkFetchTimeParam, + relays: relaysParam, + )..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }, + ), DbUserRelayList: obx_int.EntityDefinition( - model: _entities[5], - toOneRelations: (DbUserRelayList object) => [], - toManyRelations: (DbUserRelayList object) => {}, - getId: (DbUserRelayList object) => object.dbId, - setId: (DbUserRelayList object, int id) { - object.dbId = id; - }, - objectToFB: (DbUserRelayList object, fb.Builder fbb) { - final pubKeyOffset = fbb.writeString(object.pubKey); - final relaysJsonOffset = fbb.writeString(object.relaysJson); - fbb.startTable(6); - fbb.addInt64(0, object.dbId); - fbb.addOffset(1, pubKeyOffset); - fbb.addInt64(2, object.createdAt); - fbb.addInt64(3, object.refreshedTimestamp); - fbb.addOffset(4, relaysJsonOffset); - fbb.finish(fbb.endTable()); - return object.dbId; - }, - objectFromFB: (obx.Store store, ByteData fbData) { - final buffer = fb.BufferContext(fbData); - final rootOffset = buffer.derefObject(0); - final pubKeyParam = const fb.StringReader(asciiOptimization: true) - .vTableGet(buffer, rootOffset, 6, ''); - final relaysJsonParam = const fb.StringReader(asciiOptimization: true) - .vTableGet(buffer, rootOffset, 12, ''); - final createdAtParam = - const fb.Int64Reader().vTableGet(buffer, rootOffset, 8, 0); - final refreshedTimestampParam = - const fb.Int64Reader().vTableGet(buffer, rootOffset, 10, 0); - final object = DbUserRelayList( - pubKey: pubKeyParam, - relaysJson: relaysJsonParam, - createdAt: createdAtParam, - refreshedTimestamp: refreshedTimestampParam) - ..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); - - return object; - }) + model: _entities[5], + toOneRelations: (DbUserRelayList object) => [], + toManyRelations: (DbUserRelayList object) => {}, + getId: (DbUserRelayList object) => object.dbId, + setId: (DbUserRelayList object, int id) { + object.dbId = id; + }, + objectToFB: (DbUserRelayList object, fb.Builder fbb) { + final pubKeyOffset = fbb.writeString(object.pubKey); + final relaysJsonOffset = fbb.writeString(object.relaysJson); + fbb.startTable(6); + fbb.addInt64(0, object.dbId); + fbb.addOffset(1, pubKeyOffset); + fbb.addInt64(2, object.createdAt); + fbb.addInt64(3, object.refreshedTimestamp); + fbb.addOffset(4, relaysJsonOffset); + fbb.finish(fbb.endTable()); + return object.dbId; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final pubKeyParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 6, ''); + final relaysJsonParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 12, ''); + final createdAtParam = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 8, + 0, + ); + final refreshedTimestampParam = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 10, + 0, + ); + final object = DbUserRelayList( + pubKey: pubKeyParam, + relaysJson: relaysJsonParam, + createdAt: createdAtParam, + refreshedTimestamp: refreshedTimestampParam, + )..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }, + ), + DbWalletCahsuKeyset: obx_int.EntityDefinition( + model: _entities[6], + toOneRelations: (DbWalletCahsuKeyset object) => [], + toManyRelations: (DbWalletCahsuKeyset object) => {}, + getId: (DbWalletCahsuKeyset object) => object.dbId, + setId: (DbWalletCahsuKeyset object, int id) { + object.dbId = id; + }, + objectToFB: (DbWalletCahsuKeyset object, fb.Builder fbb) { + final idOffset = fbb.writeString(object.id); + final mintUrlOffset = fbb.writeString(object.mintUrl); + final unitOffset = fbb.writeString(object.unit); + final mintKeyPairsOffset = fbb.writeList( + object.mintKeyPairs.map(fbb.writeString).toList(growable: false), + ); + fbb.startTable(9); + fbb.addInt64(0, object.dbId); + fbb.addOffset(1, idOffset); + fbb.addOffset(2, mintUrlOffset); + fbb.addOffset(3, unitOffset); + fbb.addBool(4, object.active); + fbb.addInt64(5, object.inputFeePPK); + fbb.addOffset(6, mintKeyPairsOffset); + fbb.addInt64(7, object.fetchedAt); + fbb.finish(fbb.endTable()); + return object.dbId; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final idParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 6, ''); + final mintUrlParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 8, ''); + final unitParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 10, ''); + final activeParam = const fb.BoolReader().vTableGet( + buffer, + rootOffset, + 12, + false, + ); + final inputFeePPKParam = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 14, + 0, + ); + final mintKeyPairsParam = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGet(buffer, rootOffset, 16, []); + final fetchedAtParam = const fb.Int64Reader().vTableGetNullable( + buffer, + rootOffset, + 18, + ); + final object = DbWalletCahsuKeyset( + id: idParam, + mintUrl: mintUrlParam, + unit: unitParam, + active: activeParam, + inputFeePPK: inputFeePPKParam, + mintKeyPairs: mintKeyPairsParam, + fetchedAt: fetchedAtParam, + )..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }, + ), + DbWalletCashuProof: obx_int.EntityDefinition( + model: _entities[7], + toOneRelations: (DbWalletCashuProof object) => [], + toManyRelations: (DbWalletCashuProof object) => {}, + getId: (DbWalletCashuProof object) => object.dbId, + setId: (DbWalletCashuProof object, int id) { + object.dbId = id; + }, + objectToFB: (DbWalletCashuProof object, fb.Builder fbb) { + final keysetIdOffset = fbb.writeString(object.keysetId); + final secretOffset = fbb.writeString(object.secret); + final unblindedSigOffset = fbb.writeString(object.unblindedSig); + final stateOffset = fbb.writeString(object.state); + fbb.startTable(7); + fbb.addInt64(0, object.dbId); + fbb.addOffset(1, keysetIdOffset); + fbb.addInt64(2, object.amount); + fbb.addOffset(3, secretOffset); + fbb.addOffset(4, unblindedSigOffset); + fbb.addOffset(5, stateOffset); + fbb.finish(fbb.endTable()); + return object.dbId; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final keysetIdParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 6, ''); + final amountParam = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 8, + 0, + ); + final secretParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 10, ''); + final unblindedSigParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 12, ''); + final stateParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 14, ''); + final object = DbWalletCashuProof( + keysetId: keysetIdParam, + amount: amountParam, + secret: secretParam, + unblindedSig: unblindedSigParam, + state: stateParam, + )..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }, + ), + DbWallet: obx_int.EntityDefinition( + model: _entities[8], + toOneRelations: (DbWallet object) => [], + toManyRelations: (DbWallet object) => {}, + getId: (DbWallet object) => object.dbId, + setId: (DbWallet object, int id) { + object.dbId = id; + }, + objectToFB: (DbWallet object, fb.Builder fbb) { + final idOffset = fbb.writeString(object.id); + final typeOffset = fbb.writeString(object.type); + final nameOffset = fbb.writeString(object.name); + final supportedUnitsOffset = fbb.writeList( + object.supportedUnits.map(fbb.writeString).toList(growable: false), + ); + final metadataJsonStringOffset = fbb.writeString( + object.metadataJsonString, + ); + fbb.startTable(8); + fbb.addInt64(0, object.dbId); + fbb.addOffset(1, idOffset); + fbb.addOffset(2, typeOffset); + fbb.addOffset(3, nameOffset); + fbb.addOffset(4, supportedUnitsOffset); + fbb.addOffset(6, metadataJsonStringOffset); + fbb.finish(fbb.endTable()); + return object.dbId; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final idParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 6, ''); + final typeParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 8, ''); + final supportedUnitsParam = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGet(buffer, rootOffset, 12, []); + final nameParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 10, ''); + final metadataJsonStringParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 16, ''); + final object = DbWallet( + id: idParam, + type: typeParam, + supportedUnits: supportedUnitsParam, + name: nameParam, + metadataJsonString: metadataJsonStringParam, + )..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }, + ), + DbWalletTransaction: obx_int.EntityDefinition( + model: _entities[9], + toOneRelations: (DbWalletTransaction object) => [], + toManyRelations: (DbWalletTransaction object) => {}, + getId: (DbWalletTransaction object) => object.dbId, + setId: (DbWalletTransaction object, int id) { + object.dbId = id; + }, + objectToFB: (DbWalletTransaction object, fb.Builder fbb) { + final idOffset = fbb.writeString(object.id); + final walletIdOffset = fbb.writeString(object.walletId); + final unitOffset = fbb.writeString(object.unit); + final walletTypeOffset = fbb.writeString(object.walletType); + final stateOffset = fbb.writeString(object.state); + final completionMsgOffset = object.completionMsg == null + ? null + : fbb.writeString(object.completionMsg!); + final metadataJsonStringOffset = fbb.writeString( + object.metadataJsonString, + ); + fbb.startTable(12); + fbb.addInt64(0, object.dbId); + fbb.addOffset(1, idOffset); + fbb.addOffset(2, walletIdOffset); + fbb.addInt64(3, object.changeAmount); + fbb.addOffset(4, unitOffset); + fbb.addOffset(5, walletTypeOffset); + fbb.addOffset(6, stateOffset); + fbb.addOffset(7, completionMsgOffset); + fbb.addInt64(8, object.transactionDate); + fbb.addInt64(9, object.initiatedDate); + fbb.addOffset(10, metadataJsonStringOffset); + fbb.finish(fbb.endTable()); + return object.dbId; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final idParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 6, ''); + final walletIdParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 8, ''); + final changeAmountParam = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 10, + 0, + ); + final unitParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 12, ''); + final walletTypeParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 14, ''); + final stateParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 16, ''); + final completionMsgParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 18); + final transactionDateParam = const fb.Int64Reader().vTableGetNullable( + buffer, + rootOffset, + 20, + ); + final initiatedDateParam = const fb.Int64Reader().vTableGetNullable( + buffer, + rootOffset, + 22, + ); + final metadataJsonStringParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 24, ''); + final object = DbWalletTransaction( + id: idParam, + walletId: walletIdParam, + changeAmount: changeAmountParam, + unit: unitParam, + walletType: walletTypeParam, + state: stateParam, + completionMsg: completionMsgParam, + transactionDate: transactionDateParam, + initiatedDate: initiatedDateParam, + metadataJsonString: metadataJsonStringParam, + )..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }, + ), + DbCashuMintInfo: obx_int.EntityDefinition( + model: _entities[10], + toOneRelations: (DbCashuMintInfo object) => [], + toManyRelations: (DbCashuMintInfo object) => {}, + getId: (DbCashuMintInfo object) => object.dbId, + setId: (DbCashuMintInfo object, int id) { + object.dbId = id; + }, + objectToFB: (DbCashuMintInfo object, fb.Builder fbb) { + final nameOffset = object.name == null + ? null + : fbb.writeString(object.name!); + final versionOffset = object.version == null + ? null + : fbb.writeString(object.version!); + final descriptionOffset = object.description == null + ? null + : fbb.writeString(object.description!); + final descriptionLongOffset = object.descriptionLong == null + ? null + : fbb.writeString(object.descriptionLong!); + final contactJsonOffset = fbb.writeString(object.contactJson); + final motdOffset = object.motd == null + ? null + : fbb.writeString(object.motd!); + final iconUrlOffset = object.iconUrl == null + ? null + : fbb.writeString(object.iconUrl!); + final urlsOffset = fbb.writeList( + object.urls.map(fbb.writeString).toList(growable: false), + ); + final tosUrlOffset = object.tosUrl == null + ? null + : fbb.writeString(object.tosUrl!); + final nutsJsonOffset = fbb.writeString(object.nutsJson); + fbb.startTable(13); + fbb.addInt64(0, object.dbId); + fbb.addOffset(1, nameOffset); + fbb.addOffset(2, versionOffset); + fbb.addOffset(3, descriptionOffset); + fbb.addOffset(4, descriptionLongOffset); + fbb.addOffset(5, contactJsonOffset); + fbb.addOffset(6, motdOffset); + fbb.addOffset(7, iconUrlOffset); + fbb.addOffset(8, urlsOffset); + fbb.addInt64(9, object.time); + fbb.addOffset(10, tosUrlOffset); + fbb.addOffset(11, nutsJsonOffset); + fbb.finish(fbb.endTable()); + return object.dbId; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final nameParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 6); + final versionParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 8); + final descriptionParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 10); + final descriptionLongParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 12); + final contactJsonParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 14, ''); + final motdParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 16); + final iconUrlParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 18); + final urlsParam = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGet(buffer, rootOffset, 20, []); + final timeParam = const fb.Int64Reader().vTableGetNullable( + buffer, + rootOffset, + 22, + ); + final tosUrlParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 24); + final nutsJsonParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 26, ''); + final object = DbCashuMintInfo( + name: nameParam, + version: versionParam, + description: descriptionParam, + descriptionLong: descriptionLongParam, + contactJson: contactJsonParam, + motd: motdParam, + iconUrl: iconUrlParam, + urls: urlsParam, + time: timeParam, + tosUrl: tosUrlParam, + nutsJson: nutsJsonParam, + )..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }, + ), + DbCashuSecretCounter: obx_int.EntityDefinition( + model: _entities[11], + toOneRelations: (DbCashuSecretCounter object) => [], + toManyRelations: (DbCashuSecretCounter object) => {}, + getId: (DbCashuSecretCounter object) => object.dbId, + setId: (DbCashuSecretCounter object, int id) { + object.dbId = id; + }, + objectToFB: (DbCashuSecretCounter object, fb.Builder fbb) { + final mintUrlOffset = fbb.writeString(object.mintUrl); + final keysetIdOffset = fbb.writeString(object.keysetId); + fbb.startTable(5); + fbb.addInt64(0, object.dbId); + fbb.addOffset(1, mintUrlOffset); + fbb.addOffset(2, keysetIdOffset); + fbb.addInt64(3, object.counter); + fbb.finish(fbb.endTable()); + return object.dbId; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final mintUrlParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 6, ''); + final keysetIdParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 8, ''); + final counterParam = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 10, + 0, + ); + final object = DbCashuSecretCounter( + mintUrl: mintUrlParam, + keysetId: keysetIdParam, + counter: counterParam, + )..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }, + ), }; return obx_int.ModelDefinition(model, bindings); @@ -780,154 +1715,188 @@ obx_int.ModelDefinition getObjectBoxModel() { /// [DbContactList] entity fields to define ObjectBox queries. class DbContactList_ { /// See [DbContactList.dbId]. - static final dbId = - obx.QueryIntegerProperty(_entities[0].properties[0]); + static final dbId = obx.QueryIntegerProperty( + _entities[0].properties[0], + ); /// See [DbContactList.pubKey]. - static final pubKey = - obx.QueryStringProperty(_entities[0].properties[1]); + static final pubKey = obx.QueryStringProperty( + _entities[0].properties[1], + ); /// See [DbContactList.contacts]. - static final contacts = - obx.QueryStringVectorProperty(_entities[0].properties[2]); + static final contacts = obx.QueryStringVectorProperty( + _entities[0].properties[2], + ); /// See [DbContactList.contactRelays]. - static final contactRelays = - obx.QueryStringVectorProperty(_entities[0].properties[3]); + static final contactRelays = obx.QueryStringVectorProperty( + _entities[0].properties[3], + ); /// See [DbContactList.petnames]. - static final petnames = - obx.QueryStringVectorProperty(_entities[0].properties[4]); + static final petnames = obx.QueryStringVectorProperty( + _entities[0].properties[4], + ); /// See [DbContactList.followedTags]. - static final followedTags = - obx.QueryStringVectorProperty(_entities[0].properties[5]); + static final followedTags = obx.QueryStringVectorProperty( + _entities[0].properties[5], + ); /// See [DbContactList.followedCommunities]. static final followedCommunities = obx.QueryStringVectorProperty(_entities[0].properties[6]); /// See [DbContactList.followedEvents]. - static final followedEvents = - obx.QueryStringVectorProperty(_entities[0].properties[7]); + static final followedEvents = obx.QueryStringVectorProperty( + _entities[0].properties[7], + ); /// See [DbContactList.createdAt]. - static final createdAt = - obx.QueryIntegerProperty(_entities[0].properties[8]); + static final createdAt = obx.QueryIntegerProperty( + _entities[0].properties[8], + ); /// See [DbContactList.loadedTimestamp]. - static final loadedTimestamp = - obx.QueryIntegerProperty(_entities[0].properties[9]); + static final loadedTimestamp = obx.QueryIntegerProperty( + _entities[0].properties[9], + ); /// See [DbContactList.sources]. - static final sources = - obx.QueryStringVectorProperty(_entities[0].properties[10]); + static final sources = obx.QueryStringVectorProperty( + _entities[0].properties[10], + ); } /// [DbMetadata] entity fields to define ObjectBox queries. class DbMetadata_ { /// See [DbMetadata.dbId]. - static final dbId = - obx.QueryIntegerProperty(_entities[1].properties[0]); + static final dbId = obx.QueryIntegerProperty( + _entities[1].properties[0], + ); /// See [DbMetadata.pubKey]. - static final pubKey = - obx.QueryStringProperty(_entities[1].properties[1]); + static final pubKey = obx.QueryStringProperty( + _entities[1].properties[1], + ); /// See [DbMetadata.name]. - static final name = - obx.QueryStringProperty(_entities[1].properties[2]); + static final name = obx.QueryStringProperty( + _entities[1].properties[2], + ); /// See [DbMetadata.displayName]. - static final displayName = - obx.QueryStringProperty(_entities[1].properties[3]); + static final displayName = obx.QueryStringProperty( + _entities[1].properties[3], + ); /// See [DbMetadata.picture]. - static final picture = - obx.QueryStringProperty(_entities[1].properties[4]); + static final picture = obx.QueryStringProperty( + _entities[1].properties[4], + ); /// See [DbMetadata.banner]. - static final banner = - obx.QueryStringProperty(_entities[1].properties[5]); + static final banner = obx.QueryStringProperty( + _entities[1].properties[5], + ); /// See [DbMetadata.website]. - static final website = - obx.QueryStringProperty(_entities[1].properties[6]); + static final website = obx.QueryStringProperty( + _entities[1].properties[6], + ); /// See [DbMetadata.about]. - static final about = - obx.QueryStringProperty(_entities[1].properties[7]); + static final about = obx.QueryStringProperty( + _entities[1].properties[7], + ); /// See [DbMetadata.nip05]. - static final nip05 = - obx.QueryStringProperty(_entities[1].properties[8]); + static final nip05 = obx.QueryStringProperty( + _entities[1].properties[8], + ); /// See [DbMetadata.lud16]. - static final lud16 = - obx.QueryStringProperty(_entities[1].properties[9]); + static final lud16 = obx.QueryStringProperty( + _entities[1].properties[9], + ); /// See [DbMetadata.lud06]. - static final lud06 = - obx.QueryStringProperty(_entities[1].properties[10]); + static final lud06 = obx.QueryStringProperty( + _entities[1].properties[10], + ); /// See [DbMetadata.updatedAt]. - static final updatedAt = - obx.QueryIntegerProperty(_entities[1].properties[11]); + static final updatedAt = obx.QueryIntegerProperty( + _entities[1].properties[11], + ); /// See [DbMetadata.refreshedTimestamp]. - static final refreshedTimestamp = - obx.QueryIntegerProperty(_entities[1].properties[12]); + static final refreshedTimestamp = obx.QueryIntegerProperty( + _entities[1].properties[12], + ); /// See [DbMetadata.splitDisplayNameWords]. static final splitDisplayNameWords = obx.QueryStringVectorProperty(_entities[1].properties[13]); /// See [DbMetadata.splitNameWords]. - static final splitNameWords = - obx.QueryStringVectorProperty(_entities[1].properties[14]); + static final splitNameWords = obx.QueryStringVectorProperty( + _entities[1].properties[14], + ); } /// [DbNip01Event] entity fields to define ObjectBox queries. class DbNip01Event_ { /// See [DbNip01Event.dbId]. - static final dbId = - obx.QueryIntegerProperty(_entities[2].properties[0]); + static final dbId = obx.QueryIntegerProperty( + _entities[2].properties[0], + ); /// See [DbNip01Event.nostrId]. - static final nostrId = - obx.QueryStringProperty(_entities[2].properties[1]); + static final nostrId = obx.QueryStringProperty( + _entities[2].properties[1], + ); /// See [DbNip01Event.pubKey]. - static final pubKey = - obx.QueryStringProperty(_entities[2].properties[2]); + static final pubKey = obx.QueryStringProperty( + _entities[2].properties[2], + ); /// See [DbNip01Event.createdAt]. - static final createdAt = - obx.QueryIntegerProperty(_entities[2].properties[3]); + static final createdAt = obx.QueryIntegerProperty( + _entities[2].properties[3], + ); /// See [DbNip01Event.kind]. - static final kind = - obx.QueryIntegerProperty(_entities[2].properties[4]); + static final kind = obx.QueryIntegerProperty( + _entities[2].properties[4], + ); /// See [DbNip01Event.content]. - static final content = - obx.QueryStringProperty(_entities[2].properties[5]); + static final content = obx.QueryStringProperty( + _entities[2].properties[5], + ); /// See [DbNip01Event.sig]. - static final sig = - obx.QueryStringProperty(_entities[2].properties[6]); + static final sig = obx.QueryStringProperty( + _entities[2].properties[6], + ); /// See [DbNip01Event.validSig]. - static final validSig = - obx.QueryBooleanProperty(_entities[2].properties[7]); + static final validSig = obx.QueryBooleanProperty( + _entities[2].properties[7], + ); /// See [DbNip01Event.sources]. - static final sources = - obx.QueryStringVectorProperty(_entities[2].properties[8]); + static final sources = obx.QueryStringVectorProperty( + _entities[2].properties[8], + ); /// See [DbNip01Event.dbTags]. - static final dbTags = - obx.QueryStringVectorProperty(_entities[2].properties[9]); + static final dbTags = obx.QueryStringVectorProperty( + _entities[2].properties[9], + ); } /// [DbTag] entity fields to define ObjectBox queries. @@ -939,60 +1908,326 @@ class DbTag_ { static final key = obx.QueryStringProperty(_entities[3].properties[1]); /// See [DbTag.value]. - static final value = - obx.QueryStringProperty(_entities[3].properties[2]); + static final value = obx.QueryStringProperty( + _entities[3].properties[2], + ); /// See [DbTag.marker]. - static final marker = - obx.QueryStringProperty(_entities[3].properties[3]); + static final marker = obx.QueryStringProperty( + _entities[3].properties[3], + ); } /// [DbNip05] entity fields to define ObjectBox queries. class DbNip05_ { /// See [DbNip05.dbId]. - static final dbId = - obx.QueryIntegerProperty(_entities[4].properties[0]); + static final dbId = obx.QueryIntegerProperty( + _entities[4].properties[0], + ); /// See [DbNip05.pubKey]. - static final pubKey = - obx.QueryStringProperty(_entities[4].properties[1]); + static final pubKey = obx.QueryStringProperty( + _entities[4].properties[1], + ); /// See [DbNip05.nip05]. - static final nip05 = - obx.QueryStringProperty(_entities[4].properties[2]); + static final nip05 = obx.QueryStringProperty( + _entities[4].properties[2], + ); /// See [DbNip05.valid]. - static final valid = - obx.QueryBooleanProperty(_entities[4].properties[3]); + static final valid = obx.QueryBooleanProperty( + _entities[4].properties[3], + ); /// See [DbNip05.networkFetchTime]. - static final networkFetchTime = - obx.QueryIntegerProperty(_entities[4].properties[4]); + static final networkFetchTime = obx.QueryIntegerProperty( + _entities[4].properties[4], + ); /// See [DbNip05.relays]. - static final relays = - obx.QueryStringVectorProperty(_entities[4].properties[5]); + static final relays = obx.QueryStringVectorProperty( + _entities[4].properties[5], + ); } /// [DbUserRelayList] entity fields to define ObjectBox queries. class DbUserRelayList_ { /// See [DbUserRelayList.dbId]. - static final dbId = - obx.QueryIntegerProperty(_entities[5].properties[0]); + static final dbId = obx.QueryIntegerProperty( + _entities[5].properties[0], + ); /// See [DbUserRelayList.pubKey]. - static final pubKey = - obx.QueryStringProperty(_entities[5].properties[1]); + static final pubKey = obx.QueryStringProperty( + _entities[5].properties[1], + ); /// See [DbUserRelayList.createdAt]. - static final createdAt = - obx.QueryIntegerProperty(_entities[5].properties[2]); + static final createdAt = obx.QueryIntegerProperty( + _entities[5].properties[2], + ); /// See [DbUserRelayList.refreshedTimestamp]. - static final refreshedTimestamp = - obx.QueryIntegerProperty(_entities[5].properties[3]); + static final refreshedTimestamp = obx.QueryIntegerProperty( + _entities[5].properties[3], + ); /// See [DbUserRelayList.relaysJson]. - static final relaysJson = - obx.QueryStringProperty(_entities[5].properties[4]); + static final relaysJson = obx.QueryStringProperty( + _entities[5].properties[4], + ); +} + +/// [DbWalletCahsuKeyset] entity fields to define ObjectBox queries. +class DbWalletCahsuKeyset_ { + /// See [DbWalletCahsuKeyset.dbId]. + static final dbId = obx.QueryIntegerProperty( + _entities[6].properties[0], + ); + + /// See [DbWalletCahsuKeyset.id]. + static final id = obx.QueryStringProperty( + _entities[6].properties[1], + ); + + /// See [DbWalletCahsuKeyset.mintUrl]. + static final mintUrl = obx.QueryStringProperty( + _entities[6].properties[2], + ); + + /// See [DbWalletCahsuKeyset.unit]. + static final unit = obx.QueryStringProperty( + _entities[6].properties[3], + ); + + /// See [DbWalletCahsuKeyset.active]. + static final active = obx.QueryBooleanProperty( + _entities[6].properties[4], + ); + + /// See [DbWalletCahsuKeyset.inputFeePPK]. + static final inputFeePPK = obx.QueryIntegerProperty( + _entities[6].properties[5], + ); + + /// See [DbWalletCahsuKeyset.mintKeyPairs]. + static final mintKeyPairs = + obx.QueryStringVectorProperty( + _entities[6].properties[6], + ); + + /// See [DbWalletCahsuKeyset.fetchedAt]. + static final fetchedAt = obx.QueryIntegerProperty( + _entities[6].properties[7], + ); +} + +/// [DbWalletCashuProof] entity fields to define ObjectBox queries. +class DbWalletCashuProof_ { + /// See [DbWalletCashuProof.dbId]. + static final dbId = obx.QueryIntegerProperty( + _entities[7].properties[0], + ); + + /// See [DbWalletCashuProof.keysetId]. + static final keysetId = obx.QueryStringProperty( + _entities[7].properties[1], + ); + + /// See [DbWalletCashuProof.amount]. + static final amount = obx.QueryIntegerProperty( + _entities[7].properties[2], + ); + + /// See [DbWalletCashuProof.secret]. + static final secret = obx.QueryStringProperty( + _entities[7].properties[3], + ); + + /// See [DbWalletCashuProof.unblindedSig]. + static final unblindedSig = obx.QueryStringProperty( + _entities[7].properties[4], + ); + + /// See [DbWalletCashuProof.state]. + static final state = obx.QueryStringProperty( + _entities[7].properties[5], + ); +} + +/// [DbWallet] entity fields to define ObjectBox queries. +class DbWallet_ { + /// See [DbWallet.dbId]. + static final dbId = obx.QueryIntegerProperty( + _entities[8].properties[0], + ); + + /// See [DbWallet.id]. + static final id = obx.QueryStringProperty( + _entities[8].properties[1], + ); + + /// See [DbWallet.type]. + static final type = obx.QueryStringProperty( + _entities[8].properties[2], + ); + + /// See [DbWallet.name]. + static final name = obx.QueryStringProperty( + _entities[8].properties[3], + ); + + /// See [DbWallet.supportedUnits]. + static final supportedUnits = obx.QueryStringVectorProperty( + _entities[8].properties[4], + ); + + /// See [DbWallet.metadataJsonString]. + static final metadataJsonString = obx.QueryStringProperty( + _entities[8].properties[5], + ); +} + +/// [DbWalletTransaction] entity fields to define ObjectBox queries. +class DbWalletTransaction_ { + /// See [DbWalletTransaction.dbId]. + static final dbId = obx.QueryIntegerProperty( + _entities[9].properties[0], + ); + + /// See [DbWalletTransaction.id]. + static final id = obx.QueryStringProperty( + _entities[9].properties[1], + ); + + /// See [DbWalletTransaction.walletId]. + static final walletId = obx.QueryStringProperty( + _entities[9].properties[2], + ); + + /// See [DbWalletTransaction.changeAmount]. + static final changeAmount = obx.QueryIntegerProperty( + _entities[9].properties[3], + ); + + /// See [DbWalletTransaction.unit]. + static final unit = obx.QueryStringProperty( + _entities[9].properties[4], + ); + + /// See [DbWalletTransaction.walletType]. + static final walletType = obx.QueryStringProperty( + _entities[9].properties[5], + ); + + /// See [DbWalletTransaction.state]. + static final state = obx.QueryStringProperty( + _entities[9].properties[6], + ); + + /// See [DbWalletTransaction.completionMsg]. + static final completionMsg = obx.QueryStringProperty( + _entities[9].properties[7], + ); + + /// See [DbWalletTransaction.transactionDate]. + static final transactionDate = obx.QueryIntegerProperty( + _entities[9].properties[8], + ); + + /// See [DbWalletTransaction.initiatedDate]. + static final initiatedDate = obx.QueryIntegerProperty( + _entities[9].properties[9], + ); + + /// See [DbWalletTransaction.metadataJsonString]. + static final metadataJsonString = + obx.QueryStringProperty(_entities[9].properties[10]); +} + +/// [DbCashuMintInfo] entity fields to define ObjectBox queries. +class DbCashuMintInfo_ { + /// See [DbCashuMintInfo.dbId]. + static final dbId = obx.QueryIntegerProperty( + _entities[10].properties[0], + ); + + /// See [DbCashuMintInfo.name]. + static final name = obx.QueryStringProperty( + _entities[10].properties[1], + ); + + /// See [DbCashuMintInfo.version]. + static final version = obx.QueryStringProperty( + _entities[10].properties[2], + ); + + /// See [DbCashuMintInfo.description]. + static final description = obx.QueryStringProperty( + _entities[10].properties[3], + ); + + /// See [DbCashuMintInfo.descriptionLong]. + static final descriptionLong = obx.QueryStringProperty( + _entities[10].properties[4], + ); + + /// See [DbCashuMintInfo.contactJson]. + static final contactJson = obx.QueryStringProperty( + _entities[10].properties[5], + ); + + /// See [DbCashuMintInfo.motd]. + static final motd = obx.QueryStringProperty( + _entities[10].properties[6], + ); + + /// See [DbCashuMintInfo.iconUrl]. + static final iconUrl = obx.QueryStringProperty( + _entities[10].properties[7], + ); + + /// See [DbCashuMintInfo.urls]. + static final urls = obx.QueryStringVectorProperty( + _entities[10].properties[8], + ); + + /// See [DbCashuMintInfo.time]. + static final time = obx.QueryIntegerProperty( + _entities[10].properties[9], + ); + + /// See [DbCashuMintInfo.tosUrl]. + static final tosUrl = obx.QueryStringProperty( + _entities[10].properties[10], + ); + + /// See [DbCashuMintInfo.nutsJson]. + static final nutsJson = obx.QueryStringProperty( + _entities[10].properties[11], + ); +} + +/// [DbCashuSecretCounter] entity fields to define ObjectBox queries. +class DbCashuSecretCounter_ { + /// See [DbCashuSecretCounter.dbId]. + static final dbId = obx.QueryIntegerProperty( + _entities[11].properties[0], + ); + + /// See [DbCashuSecretCounter.mintUrl]. + static final mintUrl = obx.QueryStringProperty( + _entities[11].properties[1], + ); + + /// See [DbCashuSecretCounter.keysetId]. + static final keysetId = obx.QueryStringProperty( + _entities[11].properties[2], + ); + + /// See [DbCashuSecretCounter.counter]. + static final counter = obx.QueryIntegerProperty( + _entities[11].properties[3], + ); } diff --git a/packages/objectbox/pubspec.lock b/packages/objectbox/pubspec.lock index d56acc4a2..3924c8f57 100644 --- a/packages/objectbox/pubspec.lock +++ b/packages/objectbox/pubspec.lock @@ -41,6 +41,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + bip32_keys: + dependency: transitive + description: + path: "." + ref: HEAD + resolved-ref: b5a0342220e7ee5aaf64d489a589bdee6ef8de22 + url: "https://github.com/1-leo/dart-bip32-keys" + source: git + version: "3.1.2" bip340: dependency: transitive description: @@ -49,6 +58,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + bip39_mnemonic: + dependency: transitive + description: + name: bip39_mnemonic + sha256: dd6bdfc2547d986b2c00f99bba209c69c0b6fa5c1a185e1f728998282f1249d5 + url: "https://pub.dev" + source: hosted + version: "4.0.1" boolean_selector: dependency: transitive description: @@ -57,6 +74,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + bs58check: + dependency: transitive + description: + name: bs58check + sha256: c4a164d42b25c2f6bc88a8beccb9fc7d01440f3c60ba23663a20a70faf484ea9 + url: "https://pub.dev" + source: hosted + version: "1.0.2" build: dependency: transitive description: @@ -121,6 +146,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.11.1" + cbor: + dependency: transitive + description: + name: cbor + sha256: f5239dd6b6ad24df67d1449e87d7180727d6f43b87b3c9402e6398c7a2d9609b + url: "https://pub.dev" + source: hosted + version: "6.3.7" characters: dependency: transitive description: @@ -294,6 +327,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + ieee754: + dependency: transitive + description: + name: ieee754 + sha256: "7d87451c164a56c156180d34a4e93779372edd191d2c219206100b976203128c" + url: "https://pub.dev" + source: hosted + version: "1.0.3" io: dependency: transitive description: @@ -674,6 +715,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unorm_dart: + dependency: transitive + description: + name: unorm_dart + sha256: "8e3870a1caa60bde8352f9597dd3535d8068613269444f8e35ea8925ec84c1f5" + url: "https://pub.dev" + source: hosted + version: "0.3.1+1" vector_math: dependency: transitive description: diff --git a/packages/rust_verifier/pubspec.lock b/packages/rust_verifier/pubspec.lock index cc0bc72ee..96b7ea312 100644 --- a/packages/rust_verifier/pubspec.lock +++ b/packages/rust_verifier/pubspec.lock @@ -41,6 +41,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + bip32_keys: + dependency: transitive + description: + path: "." + ref: HEAD + resolved-ref: b5a0342220e7ee5aaf64d489a589bdee6ef8de22 + url: "https://github.com/1-leo/dart-bip32-keys" + source: git + version: "3.1.2" bip340: dependency: transitive description: @@ -49,6 +58,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + bip39_mnemonic: + dependency: transitive + description: + name: bip39_mnemonic + sha256: dd6bdfc2547d986b2c00f99bba209c69c0b6fa5c1a185e1f728998282f1249d5 + url: "https://pub.dev" + source: hosted + version: "4.0.1" boolean_selector: dependency: transitive description: @@ -57,6 +74,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + bs58check: + dependency: transitive + description: + name: bs58check + sha256: c4a164d42b25c2f6bc88a8beccb9fc7d01440f3c60ba23663a20a70faf484ea9 + url: "https://pub.dev" + source: hosted + version: "1.0.2" build: dependency: transitive description: @@ -113,6 +138,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.10.1" + cbor: + dependency: transitive + description: + name: cbor + sha256: f5239dd6b6ad24df67d1449e87d7180727d6f43b87b3c9402e6398c7a2d9609b + url: "https://pub.dev" + source: hosted + version: "6.3.7" characters: dependency: transitive description: @@ -309,6 +342,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + ieee754: + dependency: transitive + description: + name: ieee754 + sha256: "7d87451c164a56c156180d34a4e93779372edd191d2c219206100b976203128c" + url: "https://pub.dev" + source: hosted + version: "1.0.3" integration_test: dependency: "direct dev" description: flutter @@ -597,6 +638,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unorm_dart: + dependency: transitive + description: + name: unorm_dart + sha256: "8e3870a1caa60bde8352f9597dd3535d8068613269444f8e35ea8925ec84c1f5" + url: "https://pub.dev" + source: hosted + version: "0.3.1+1" vector_math: dependency: transitive description: diff --git a/packages/sample-app/lib/demo_app_config.dart b/packages/sample-app/lib/demo_app_config.dart new file mode 100644 index 000000000..6e9463635 --- /dev/null +++ b/packages/sample-app/lib/demo_app_config.dart @@ -0,0 +1,7 @@ +class DemoAppConfig { + static const String appName = 'Nostr Developer Kit Demo'; + + /// in production store the user seed phrase securely! (e.g. in secure storage) + static const String cashuSeedPhrase = + "slender horror knee exclude couch oil picture tone steel dinosaur arrow culture"; +} diff --git a/packages/sample-app/lib/main.dart b/packages/sample-app/lib/main.dart index 735c297f5..3a891f5ee 100644 --- a/packages/sample-app/lib/main.dart +++ b/packages/sample-app/lib/main.dart @@ -1,12 +1,15 @@ import 'package:amberflutter/amberflutter.dart'; import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; +import 'package:ndk/entities.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk_demo/accounts_page.dart'; import 'package:ndk_demo/blossom_page.dart'; +import 'package:ndk_demo/demo_app_config.dart'; import 'package:ndk_demo/nwc_page.dart'; import 'package:ndk_demo/query_performance.dart'; import 'package:ndk_demo/relays_page.dart'; +import 'package:ndk_demo/wallets.dart'; import 'package:ndk_demo/verifiers_performance.dart'; import 'package:ndk_demo/zaps_page.dart'; import 'package:protocol_handler/protocol_handler.dart'; @@ -20,6 +23,9 @@ final ndk = Ndk( eventVerifier: Bip340EventVerifier(), cache: MemCacheManager(), logLevel: Logger.logLevels.trace, + cashuUserSeedphrase: CashuUserSeedphrase( + seedPhrase: DemoAppConfig.cashuSeedPhrase, + ), ), ); @@ -79,7 +85,7 @@ class MyApp extends StatelessWidget { // ); return MaterialApp( - title: 'Nostr Developer Kit Demo', + title: DemoAppConfig.appName, theme: ThemeData( primarySwatch: Colors.blue, ), @@ -137,6 +143,7 @@ class _MyHomePageState extends State const Tab(text: "Blossom"), const Tab(text: 'Verifiers'), const Tab(text: 'Query Performance'), + const Tab(text: "Wallets"), // Conditionally add Amber tab if it's part of the design // For a fixed length of 6, ensure this list matches. // Example: if Amber is the 6th tab: @@ -160,7 +167,7 @@ class _MyHomePageState extends State // The main change is how _tabPages is constructed in build() to pass the callback. _tabController = TabController( - length: 7, + length: _tabs.length, vsync: this); // Fixed length to 5 (Accounts, Metadata, Relays, NWC, Blossom) _tabController.addListener(() { @@ -249,6 +256,7 @@ class _MyHomePageState extends State const Tab(text: "Blossom"), const Tab(text: 'Verifiers'), const Tab(text: 'Query Performance'), + const Tab(text: "Wallets"), // Amber tab removed ]; @@ -260,6 +268,9 @@ class _MyHomePageState extends State BlossomMediaPage(ndk: ndk), VerifiersPerformancePage(ndk: ndk), QueryPerformancePage(ndk: ndk), + WalletsPage( + ndk: ndk, + ), // AmberPage removed ]; diff --git a/packages/sample-app/lib/wallets.dart b/packages/sample-app/lib/wallets.dart new file mode 100644 index 000000000..ab1f08333 --- /dev/null +++ b/packages/sample-app/lib/wallets.dart @@ -0,0 +1,490 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:ndk/entities.dart'; +import 'package:ndk/presentation_layer/ndk.dart'; + +const String mintUrl = "https://dev.mint.camelus.app"; + +class WalletsPage extends StatefulWidget { + final Ndk ndk; + const WalletsPage({super.key, required this.ndk}); + + @override + State createState() => _WalletsPageState(); +} + +class _WalletsPageState extends State { + String cashuIn = ""; + TextEditingController cashuInController = TextEditingController(); + + displayError(String error) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error), + backgroundColor: Colors.red, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 120, + child: WalletsList(ndk: widget.ndk), + ), + + const SizedBox(height: 16), + // CASHU Section + Text("CASHU", style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 8), + + // CASHU Controls + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + TextButton( + onPressed: () async { + final draftTransaction = + await widget.ndk.cashu.initiateFund( + mintUrl: mintUrl, + amount: 10, + unit: "sat", + method: "bolt11", + ); + final tStream = widget.ndk.cashu + .retrieveFunds(draftTransaction: draftTransaction); + await tStream.last; + }, + child: const Text("mint 10 sat"), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () async { + try { + final spendingResult = + await widget.ndk.cashu.initiateSpend( + mintUrl: mintUrl, + amount: 10, + unit: "sat", + ); + final cashuString = + spendingResult.token.toV4TokenString(); + + Clipboard.setData(ClipboardData(text: cashuString)); + } catch (e) { + displayError(e.toString()); + } + }, + child: const Text("send 10 sat"), + ), + const SizedBox(width: 8), + SizedBox( + width: 200, + child: TextField( + controller: cashuInController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'CASHU Token', + ), + onChanged: (value) { + cashuIn = value; + }, + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () async { + try { + final rcvStream = widget.ndk.cashu.receive(cashuIn); + await rcvStream.last; + setState(() { + cashuIn = ""; + cashuInController.text = ""; + }); + } catch (e) { + displayError(e.toString()); + } + }, + child: const Text("receive"), + ), + TextButton( + onPressed: () async { + try { + final draftTransaction = + await widget.ndk.cashu.initiateRedeem( + unit: "sat", + method: "bolt11", + request: "lnbc", + mintUrl: mintUrl, + ); + final redeemStream = widget.ndk.cashu + .redeem(draftRedeemTransaction: draftTransaction); + await redeemStream.last; + } catch (e) { + displayError(e.toString()); + } + }, + child: const Text("melt"), + ), + ], + ), + ), + const SizedBox(height: 16), + Text("NWC", style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 8), + TextButton( + onPressed: () async { + _showAddNwcWalletDialog(context); + }, + child: const Text("Add New NWC Wallet"), + ), + const SizedBox(height: 16), + + // Wallets Balance Section + Text("Wallets Balance", + style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 8), + SizedBox( + height: 140, + child: Balances(ndk: widget.ndk), + ), + + const SizedBox(height: 16), + + // Pending Transactions Section (conditional) + PendingTransactionsSection(ndk: widget.ndk), + + Text("Recent transactions", + style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 8), + SizedBox( + height: 150, + child: RecentTransactions(ndk: widget.ndk), + ), + ], + ), + ), + )); + } + + void _showAddNwcWalletDialog(BuildContext context) { + final _nwcUriController = TextEditingController(); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Add New NWC Wallet'), + content: TextField( + controller: _nwcUriController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'NWC Connection URI', + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + try { + final walletId = + DateTime.now().millisecondsSinceEpoch.toString(); + final nwcWallet = NwcWallet( + id: walletId, + name: + "NWC Wallet ${DateTime.now().toString().split(' ')[1].substring(0, 5)}", + supportedUnits: {'sat'}, + nwcUrl: _nwcUriController.text, + ); + await widget.ndk.wallets.addWallet(nwcWallet); + widget.ndk.wallets.getBalance(walletId, "sat"); + widget.ndk.wallets.getRecentTransactionsStream(walletId); + widget.ndk.wallets.getPendingTransactionsStream(walletId); + Navigator.of(context).pop(); + } catch (e) { + displayError(e.toString()); + } + }, + child: const Text('Add Wallet'), + ), + ], + ); + }, + ); + } +} + +class Balances extends StatelessWidget { + final Ndk ndk; + const Balances({super.key, required this.ndk}); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: ndk.wallets.combinedBalances, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } else if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Center(child: Text('No balances available')); + } else { + final balances = snapshot.data!; + return ListView.builder( + itemCount: balances.length, + itemBuilder: (context, index) { + final balance = balances[index]; + return Card( + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + dense: true, + title: Text('${balance.unit}: ${balance.amount}'), + ), + ); + }, + ); + } + }); + } +} + +class PendingTransactionsSection extends StatelessWidget { + final Ndk ndk; + const PendingTransactionsSection({super.key, required this.ndk}); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: ndk.wallets.combinedPendingTransactions, + builder: (context, snapshot) { + // Hide section if no data or empty + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const SizedBox.shrink(); + } + + if (snapshot.hasError) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Pending transactions", + style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 8), + SizedBox( + height: 150, + child: Center(child: Text('Error: ${snapshot.error}')), + ), + const SizedBox(height: 16), + ], + ); + } + + final transactions = snapshot.data!; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Pending transactions", + style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 8), + SizedBox( + height: 150, + child: ListView.builder( + reverse: true, + itemCount: transactions.length, + itemBuilder: (context, index) { + final transaction = transactions[index]; + return Card( + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + dense: true, + title: Text( + '${transaction.changeAmount} ${transaction.unit} type: ${transaction.walletType}'), + onTap: () { + if (transaction is CashuWalletTransaction) { + Clipboard.setData( + ClipboardData(text: transaction.token ?? '')); + + const snackBar = SnackBar( + content: Text('Copied to clipboard'), + duration: Duration(seconds: 1), + ); + + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + }, + ), + ); + }, + ), + ), + const SizedBox(height: 16), + ], + ); + }, + ); + } +} + +class RecentTransactions extends StatelessWidget { + final Ndk ndk; + const RecentTransactions({super.key, required this.ndk}); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: ndk.wallets.combinedRecentTransactions, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } else if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Center(child: Text('No recent transactions')); + } else { + final transactions = snapshot.data!; + return ListView.builder( + reverse: true, + itemCount: transactions.length, + itemBuilder: (context, index) { + final transaction = transactions[index]; + return Card( + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + dense: true, + title: Text( + '${transaction.changeAmount} ${transaction.unit} type: ${transaction.walletType}, state: ${transaction.state}'), + ), + ); + }, + ); + } + }, + ); + } +} + +class WalletsList extends StatefulWidget { + final Ndk ndk; + const WalletsList({super.key, required this.ndk}); + + @override + State createState() => _WalletsListState(); +} + +class _WalletsListState extends State { + void _showRemoveWalletDialog( + BuildContext context, String walletId, String walletType) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Remove Wallet'), + content: + Text('Are you sure you want to remove this $walletType wallet?'), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + try { + await widget.ndk.wallets.removeWallet(walletId); + if (mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error removing wallet: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + }, + child: const Text('Remove', style: TextStyle(color: Colors.red)), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + stream: widget.ndk.wallets.walletsStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } + + final wallets = snapshot.data ?? []; + + if (wallets.isEmpty) { + return const Center(child: Text('No wallets added yet')); + } + + return ListView.builder( + itemCount: wallets.length, + itemBuilder: (context, index) { + final wallet = wallets[index]; + String walletType = 'Unknown'; + String walletInfo = ''; + + if (wallet is CashuWallet) { + walletType = 'Cashu'; + walletInfo = wallet.mintUrl; + } else if (wallet is NwcWallet) { + walletType = 'NWC'; + walletInfo = wallet.name; + } + + return Card( + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + dense: true, + leading: Icon( + walletType == 'Cashu' + ? Icons.account_balance_wallet + : Icons.cloud, + size: 20, + ), + title: Text('$walletType Wallet'), + subtitle: Text( + walletInfo, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + icon: const Icon(Icons.delete, size: 20), + color: Colors.red, + onPressed: () { + _showRemoveWalletDialog(context, wallet.id, walletType); + }, + tooltip: 'Remove wallet', + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/packages/sample-app/pubspec.lock b/packages/sample-app/pubspec.lock index fcf0adce3..c36af3b42 100644 --- a/packages/sample-app/pubspec.lock +++ b/packages/sample-app/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + ascii_qr: + dependency: transitive + description: + name: ascii_qr + sha256: "2046e400a0fa4ea0de5df44c87b992cdd1f76403bb15e64513b89263598750ae" + url: "https://pub.dev" + source: hosted + version: "1.0.1" async: dependency: transitive description: @@ -41,6 +49,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + bip32_keys: + dependency: transitive + description: + path: "." + ref: HEAD + resolved-ref: b5a0342220e7ee5aaf64d489a589bdee6ef8de22 + url: "https://github.com/1-leo/dart-bip32-keys" + source: git + version: "3.1.2" bip340: dependency: transitive description: @@ -49,6 +66,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + bip39_mnemonic: + dependency: transitive + description: + name: bip39_mnemonic + sha256: dd6bdfc2547d986b2c00f99bba209c69c0b6fa5c1a185e1f728998282f1249d5 + url: "https://pub.dev" + source: hosted + version: "4.0.1" boolean_selector: dependency: transitive description: @@ -57,6 +82,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + bs58check: + dependency: transitive + description: + name: bs58check + sha256: c4a164d42b25c2f6bc88a8beccb9fc7d01440f3c60ba23663a20a70faf484ea9 + url: "https://pub.dev" + source: hosted + version: "1.0.2" build_cli_annotations: dependency: transitive description: @@ -65,6 +98,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + cbor: + dependency: transitive + description: + name: cbor + sha256: f5239dd6b6ad24df67d1449e87d7180727d6f43b87b3c9402e6398c7a2d9609b + url: "https://pub.dev" + source: hosted + version: "6.3.7" characters: dependency: transitive description: @@ -248,6 +289,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + ieee754: + dependency: transitive + description: + name: ieee754 + sha256: "7d87451c164a56c156180d34a4e93779372edd191d2c219206100b976203128c" + url: "https://pub.dev" + source: hosted + version: "1.0.3" image: dependency: transitive description: @@ -414,21 +463,21 @@ packages: path: "../ndk" relative: true source: path - version: "0.6.0-dev.12" + version: "0.6.1-dev.1" ndk_amber: dependency: "direct main" description: path: "../amber" relative: true source: path - version: "0.3.3-dev.15" + version: "0.3.3-dev.1" ndk_rust_verifier: dependency: "direct main" description: path: "../rust_verifier" relative: true source: path - version: "0.4.2-dev.17" + version: "0.4.2-dev.1" nested: dependency: transitive description: @@ -794,6 +843,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + unorm_dart: + dependency: transitive + description: + name: unorm_dart + sha256: "0c69186b03ca6addab0774bcc0f4f17b88d4ce78d9d4d8f0619e30a99ead58e7" + url: "https://pub.dev" + source: hosted + version: "0.3.2" uri_parser: dependency: transitive description: diff --git a/packages/sembast_cache_manager/lib/src/ndk_extensions.dart b/packages/sembast_cache_manager/lib/src/ndk_extensions.dart index 9203a6739..ec56b386c 100644 --- a/packages/sembast_cache_manager/lib/src/ndk_extensions.dart +++ b/packages/sembast_cache_manager/lib/src/ndk_extensions.dart @@ -2,6 +2,7 @@ import 'package:ndk/domain_layer/entities/nip_05.dart'; import 'package:ndk/domain_layer/entities/pubkey_mapping.dart'; import 'package:ndk/domain_layer/entities/read_write_marker.dart'; import 'package:ndk/domain_layer/entities/user_relay_list.dart'; +import 'package:ndk/entities.dart'; import 'package:ndk/ndk.dart'; // Extension for Nip01Event to add JSON serialization support @@ -263,3 +264,181 @@ extension UserRelayListExtension on UserRelayList { ); } } + +// Extension for CahsuKeyset to add JSON serialization support +extension CahsuKeysetExtension on CahsuKeyset { + Map toJsonForStorage() { + return { + 'id': id, + 'mintUrl': mintUrl, + 'unit': unit, + 'active': active, + 'inputFeePPK': inputFeePPK, + 'mintKeyPairs': mintKeyPairs + .map((pair) => {'amount': pair.amount, 'pubkey': pair.pubkey}) + .toList(), + 'fetchedAt': fetchedAt, + }; + } + + static CahsuKeyset fromJsonStorage(Map json) { + return CahsuKeyset( + id: json['id'] as String, + mintUrl: json['mintUrl'] as String, + unit: json['unit'] as String, + active: json['active'] as bool, + inputFeePPK: json['inputFeePPK'] as int, + mintKeyPairs: (json['mintKeyPairs'] as List) + .map((e) => CahsuMintKeyPair( + amount: e['amount'] as int, + pubkey: e['pubkey'] as String, + )) + .toSet(), + fetchedAt: json['fetchedAt'] as int?, + ); + } +} + +// Extension for CashuProof to add JSON serialization support +extension CashuProofExtension on CashuProof { + Map toJsonForStorage() { + return { + 'secret': secret, + 'amount': amount, + 'keysetId': keysetId, + 'c': unblindedSig, + 'state': state.toString(), + }; + } + + static CashuProof fromJsonStorage(Map json) { + return CashuProof( + secret: json['secret'] as String, + amount: json['amount'] as int, + keysetId: json['keysetId'] as String, + unblindedSig: json['c'] as String, + state: CashuProofState.values.firstWhere( + (e) => e.toString() == json['state'], + orElse: () => CashuProofState.unspend, + ), + ); + } +} + +// Extension for WalletTransaction to add JSON serialization support +// Extension for WalletTransaction to add JSON serialization support +extension WalletTransactionExtension on WalletTransaction { + Map toJsonForStorage() { + return { + 'id': id, + 'walletId': walletId, + 'changeAmount': changeAmount, + 'unit': unit, + 'walletType': walletType.toString(), + 'state': state.value, + 'completionMsg': completionMsg, + 'transactionDate': transactionDate, + 'initiatedDate': initiatedDate, + 'metadata': metadata, + }; + } + + static WalletTransaction fromJsonStorage(Map json) { + return WalletTransaction.toTransactionType( + id: json['id'] as String, + walletId: json['walletId'] as String, + changeAmount: json['changeAmount'] as int, + unit: json['unit'] as String, + walletType: WalletType.values.firstWhere( + (e) => e.toString() == json['walletType'], + ), + state: WalletTransactionState.fromValue(json['state'] as String), + metadata: Map.from(json['metadata'] as Map? ?? {}), + completionMsg: json['completionMsg'] as String?, + transactionDate: json['transactionDate'] as int?, + initiatedDate: json['initiatedDate'] as int?, + ); + } +} + +// Extension for Wallet to add JSON serialization support +// Extension for Wallet to add JSON serialization support +extension WalletExtension on Wallet { + Map toJsonForStorage() { + return { + 'id': id, + 'name': name, + 'type': type.toString(), + 'supportedUnits': supportedUnits.toList(), + 'metadata': metadata, + }; + } + + static Wallet fromJsonStorage(Map json) { + return Wallet.toWalletType( + id: json['id'] as String, + name: json['name'] as String, + type: WalletType.values.firstWhere( + (e) => e.toString() == json['type'], + ), + supportedUnits: Set.from(json['supportedUnits'] as List), + metadata: Map.from(json['metadata'] as Map? ?? {}), + ); + } +} + +// Extension for CashuMintInfo to add JSON serialization support +extension CashuMintInfoExtension on CashuMintInfo { + Map toJsonForStorage() { + return { + 'name': name, + 'pubkey': pubkey, + 'version': version, + 'description': description, + 'description_long': descriptionLong, + 'contact': contact.map((c) => c.toJson()).toList(), + 'motd': motd, + 'icon_url': iconUrl, + 'urls': urls, + 'time': time, + 'tos_url': tosUrl, + 'nuts': nuts.map((k, v) => MapEntry(k.toString(), v.toJson())), + }; + } + + static CashuMintInfo fromJsonStorage(Map json) { + final nutsJson = (json['nuts'] as Map?) ?? {}; + final parsedNuts = {}; + nutsJson.forEach((k, v) { + final key = int.tryParse(k.toString()); + if (key != null) { + try { + if (v is List) { + return; // skip non-spec compliant entries + } + parsedNuts[key] = + CashuMintNut.fromJson((v ?? {}) as Map); + } catch (e) { + // skip entries that fail to parse + } + } + }); + + return CashuMintInfo( + name: json['name'] as String?, + pubkey: json['pubkey'] as String?, + version: json['version'] as String?, + description: json['description'] as String?, + descriptionLong: json['description_long'] as String?, + contact: ((json['contact'] as List?) ?? const []) + .map((e) => CashuMintContact.fromJson(e as Map)) + .toList(), + motd: json['motd'] as String?, + iconUrl: json['icon_url'] as String?, + urls: List.from(json['urls'] as List? ?? []), + time: (json['time'] is num) ? (json['time'] as num).toInt() : null, + tosUrl: json['tos_url'] as String?, + nuts: parsedNuts, + ); + } +} diff --git a/packages/sembast_cache_manager/lib/src/sembast_cache_manager_base.dart b/packages/sembast_cache_manager/lib/src/sembast_cache_manager_base.dart index 272b60d2c..2b77aa234 100644 --- a/packages/sembast_cache_manager/lib/src/sembast_cache_manager_base.dart +++ b/packages/sembast_cache_manager/lib/src/sembast_cache_manager_base.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:ndk/domain_layer/entities/nip_05.dart'; import 'package:ndk/domain_layer/entities/user_relay_list.dart'; +import 'package:ndk/entities.dart'; import 'package:ndk/ndk.dart'; import 'package:sembast/sembast.dart' as sembast; import 'package:sembast/sembast_io.dart'; @@ -31,6 +32,13 @@ class SembastCacheManager extends CacheManager { late final sembast.StoreRef> _nip05Store; late final sembast.StoreRef> _relaySetStore; + late final sembast.StoreRef> _keysetStore; + late final sembast.StoreRef> _proofStore; + late final sembast.StoreRef> _transactionStore; + late final sembast.StoreRef> _walletStore; + late final sembast.StoreRef> _mintInfoStore; + late final sembast.StoreRef> _secretCounterStore; + SembastCacheManager(this._database) { _eventsStore = sembast.stringMapStoreFactory.store('events'); _metadataStore = sembast.stringMapStoreFactory.store('metadata'); @@ -38,6 +46,13 @@ class SembastCacheManager extends CacheManager { _relayListStore = sembast.stringMapStoreFactory.store('relay_lists'); _nip05Store = sembast.stringMapStoreFactory.store('nip05'); _relaySetStore = sembast.stringMapStoreFactory.store('relay_sets'); + _keysetStore = sembast.stringMapStoreFactory.store('keysets'); + _proofStore = sembast.stringMapStoreFactory.store('proofs'); + _transactionStore = sembast.stringMapStoreFactory.store('transactions'); + _walletStore = sembast.stringMapStoreFactory.store('wallets'); + _mintInfoStore = sembast.stringMapStoreFactory.store('mint_infos'); + _secretCounterStore = + sembast.stringMapStoreFactory.store('secret_counters'); } @override @@ -441,4 +456,261 @@ class SembastCacheManager extends CacheManager { return metadatas; } + + @override + Future> getKeysets({String? mintUrl}) async { + if (mintUrl == null || mintUrl.isEmpty) { + // Return all keysets if no mintUrl + final records = await _keysetStore.find(_database); + return records + .map((record) => CahsuKeysetExtension.fromJsonStorage(record.value)) + .toList(); + } + + final finder = sembast.Finder( + filter: sembast.Filter.equals('mintUrl', mintUrl), + ); + + final records = await _keysetStore.find(_database, finder: finder); + return records + .map((record) => CahsuKeysetExtension.fromJsonStorage(record.value)) + .toList(); + } + + @override + Future> getProofs({ + String? mintUrl, + String? keysetId, + CashuProofState state = CashuProofState.unspend, + }) async { + final filters = []; + + // Filter by state + filters.add(sembast.Filter.equals('state', state.toString())); + + // Filter by keysetId if provided + if (keysetId != null && keysetId.isNotEmpty) { + filters.add(sembast.Filter.equals('keysetId', keysetId)); + } + + // Filter by mintUrl if provided + if (mintUrl != null && mintUrl.isNotEmpty) { + // Get all keysets for the mintUrl + final keysets = await getKeysets(mintUrl: mintUrl); + if (keysets.isEmpty) { + return []; + } + final keysetIds = keysets.map((k) => k.id).toList(); + filters.add(sembast.Filter.inList('keysetId', keysetIds)); + } + + final finder = sembast.Finder( + filter: sembast.Filter.and(filters), + sortOrders: [sembast.SortOrder('amount')], + ); + + final records = await _proofStore.find(_database, finder: finder); + return records + .map((record) => CashuProofExtension.fromJsonStorage(record.value)) + .toList(); + } + + @override + Future removeProofs({ + required List proofs, + required String mintUrl, + }) async { + final proofSecrets = proofs.map((p) => p.secret).toList(); + final finder = sembast.Finder( + filter: sembast.Filter.inList('secret', proofSecrets), + ); + + await _proofStore.delete(_database, finder: finder); + } + + @override + Future saveKeyset(CahsuKeyset keyset) async { + await _keysetStore + .record(keyset.id) + .put(_database, keyset.toJsonForStorage()); + } + + @override + Future saveProofs({ + required List proofs, + required String mintUrl, + }) async { + await _database.transaction((txn) async { + // Remove existing proofs by secret (upsert logic) + final secretsToCheck = proofs.map((p) => p.secret).toList(); + final finder = sembast.Finder( + filter: sembast.Filter.inList('secret', secretsToCheck), + ); + await _proofStore.delete(txn, finder: finder); + + // Insert new proofs + for (final proof in proofs) { + await _proofStore + .record(proof.secret) + .put(txn, proof.toJsonForStorage()); + } + }); + } + + @override + Future> getTransactions({ + int? limit, + int? offset, + String? walletId, + String? unit, + WalletType? walletType, + }) async { + final filters = []; + + if (walletId != null && walletId.isNotEmpty) { + filters.add(sembast.Filter.equals('walletId', walletId)); + } + + if (unit != null && unit.isNotEmpty) { + filters.add(sembast.Filter.equals('unit', unit)); + } + + if (walletType != null) { + filters.add(sembast.Filter.equals('walletType', walletType.toString())); + } + + final finder = sembast.Finder( + filter: filters.isNotEmpty ? sembast.Filter.and(filters) : null, + sortOrders: [sembast.SortOrder('transactionDate', false)], + limit: limit, + offset: offset, + ); + + final records = await _transactionStore.find(_database, finder: finder); + return records + .map((record) => + WalletTransactionExtension.fromJsonStorage(record.value)) + .toList(); + } + + @override + Future saveTransactions({ + required List transactions, + }) async { + await _database.transaction((txn) async { + // Remove existing transactions by id (upsert logic) + final idsToCheck = transactions.map((t) => t.id).toList(); + final finder = sembast.Finder( + filter: sembast.Filter.inList('id', idsToCheck), + ); + await _transactionStore.delete(txn, finder: finder); + + // Insert new transactions + for (final transaction in transactions) { + await _transactionStore + .record(transaction.id) + .put(txn, transaction.toJsonForStorage()); + } + }); + } + + @override + Future?> getWallets({List? ids}) async { + if (ids == null || ids.isEmpty) { + // Return all wallets + final records = await _walletStore.find(_database); + return records + .map((record) => WalletExtension.fromJsonStorage(record.value)) + .toList(); + } + + final finder = sembast.Finder( + filter: sembast.Filter.inList('id', ids), + ); + + final records = await _walletStore.find(_database, finder: finder); + return records + .map((record) => WalletExtension.fromJsonStorage(record.value)) + .toList(); + } + + @override + Future removeWallet(String walletId) async { + await _walletStore.record(walletId).delete(_database); + } + + @override + Future saveWallet(Wallet wallet) async { + await _walletStore + .record(wallet.id) + .put(_database, wallet.toJsonForStorage()); + } + + @override + Future?> getMintInfos({List? mintUrls}) async { + if (mintUrls == null || mintUrls.isEmpty) { + // Return all mint infos + final records = await _mintInfoStore.find(_database); + return records + .map((record) => CashuMintInfoExtension.fromJsonStorage(record.value)) + .toList(); + } + + // For Sembast, we need to filter in memory since we can't do complex array operations + final allRecords = await _mintInfoStore.find(_database); + final allMintInfos = allRecords + .map((record) => CashuMintInfoExtension.fromJsonStorage(record.value)) + .toList(); + + // Filter by URLs + return allMintInfos.where((mintInfo) { + return mintUrls.any((url) => mintInfo.urls.contains(url)); + }).toList(); + } + + @override + Future saveMintInfo({required CashuMintInfo mintInfo}) async { + // Use the first URL as the key for upsert logic + final key = mintInfo.urls.first; + + // Remove existing mint info with the same URL + final allRecords = await _mintInfoStore.find(_database); + for (final record in allRecords) { + final existingMintInfo = + CashuMintInfoExtension.fromJsonStorage(record.value); + if (existingMintInfo.urls.contains(mintInfo.urls.first)) { + await _mintInfoStore.record(record.key).delete(_database); + } + } + + // Insert new mint info + await _mintInfoStore + .record(key) + .put(_database, mintInfo.toJsonForStorage()); + } + + @override + Future getCashuSecretCounter({ + required String mintUrl, + required String keysetId, + }) async { + final key = '${mintUrl}_$keysetId'; + final data = await _secretCounterStore.record(key).get(_database); + if (data == null) return 0; + return data['counter'] as int? ?? 0; + } + + @override + Future setCashuSecretCounter({ + required String mintUrl, + required String keysetId, + required int counter, + }) async { + final key = '${mintUrl}_$keysetId'; + await _secretCounterStore.record(key).put(_database, { + 'mintUrl': mintUrl, + 'keysetId': keysetId, + 'counter': counter, + }); + } }