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 6cb2d86ed..102806a02 100644 --- a/packages/amber/pubspec.lock +++ b/packages/amber/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.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: @@ -49,6 +57,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 +74,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 +90,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 +162,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: @@ -301,6 +342,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: transitive description: @@ -325,6 +374,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 @@ -513,6 +570,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" rxdart: dependency: transitive description: @@ -630,6 +695,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/bc_ur/.gitignore b/packages/bc_ur/.gitignore new file mode 100644 index 000000000..9635349ce --- /dev/null +++ b/packages/bc_ur/.gitignore @@ -0,0 +1,57 @@ +# Dart-specific +.dart_tool/ +.packages +build/ +pubspec.lock + +# IDE-specific +.idea/ +.vscode/ + +# Flutter/Dart package-specific +*.iml +*.lock +*.log +.flutter-plugins +.flutter-plugins-dependencies + +# macOS-specific +.DS_Store + +# Windows-specific +Thumbs.db + +# Coverage reports +coverage/ + +# Temporary files +*.tmp +*.temp +*.swp + +# Generated documentation +doc/api/ + +# Avoid committing generated JavaScript files +*.dart.js +*.info.json +*.js +*.js_ +*.js.deps +*.js.map + +# Avoid committing generated files for pub +.pub/ +.pub-cache/ + +# Avoid committing build outputs +*.aot +*.dill +*.dill.track.dill +*.dill.incremental.dill + +# Avoid committing test failures +failures/ + +# Avoid committing performance profiling data +*.timeline \ No newline at end of file diff --git a/packages/bc_ur/LICENSE b/packages/bc_ur/LICENSE new file mode 100644 index 000000000..6c74f8cda --- /dev/null +++ b/packages/bc_ur/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Aleksandr Bukata + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/bc_ur/lib/bytewords.dart b/packages/bc_ur/lib/bytewords.dart new file mode 100644 index 000000000..6ae35ca65 --- /dev/null +++ b/packages/bc_ur/lib/bytewords.dart @@ -0,0 +1,149 @@ +import 'dart:typed_data'; +import 'package:ur/utils.dart'; +import 'package:collection/collection.dart'; + +const String BYTEWORDS = + 'ableacidalsoapexaquaarchatomauntawayaxisbackbaldbarnbeltbetabiasbluebodybragbrewbulbbuzzcalmcashcatschefcityclawcodecolacookcostcruxcurlcuspcyandarkdatadaysdelidicedietdoordowndrawdropdrumdulldutyeacheasyechoedgeepicevenexamexiteyesfactfairfernfigsfilmfishfizzflapflewfluxfoxyfreefrogfuelfundgalagamegeargemsgiftgirlglowgoodgraygrimgurugushgyrohalfhanghardhawkheathelphighhillholyhopehornhutsicedideaidleinchinkyintoirisironitemjadejazzjoinjoltjowljudojugsjumpjunkjurykeepkenokeptkeyskickkilnkingkitekiwiknoblamblavalazyleaflegsliarlimplionlistlogoloudloveluaulucklungmainmanymathmazememomenumeowmildmintmissmonknailnavyneednewsnextnoonnotenumbobeyoboeomitonyxopenovalowlspaidpartpeckplaypluspoempoolposepuffpumapurrquadquizraceramprealredorichroadrockroofrubyruinrunsrustsafesagascarsetssilkskewslotsoapsolosongstubsurfswantacotasktaxitenttiedtimetinytoiltombtoystriptunatwinuglyundouniturgeuservastveryvetovialvibeviewvisavoidvowswallwandwarmwaspwavewaxywebswhatwhenwhizwolfworkyankyawnyellyogayurtzapszerozestzinczonezoom'; + +List? _wordArray; + +enum Style { + standard, + uri, + minimal, +} + +int decodeWord(String word, int wordLen) { + if (word.length != wordLen) { + throw ArgumentError('Invalid Bytewords.'); + } + + const int dim = 26; + + // Since the first and last letters of each Byteword are unique, + // we can use them as indexes into a two-dimensional lookup table. + // This table is generated lazily. + if (_wordArray == null) { + _wordArray = List.generate(dim * dim, (i) => -1); + + for (int i = 0; i < 256; i++) { + int bytewordOffset = i * 4; + int x = BYTEWORDS[bytewordOffset].codeUnitAt(0) - 'a'.codeUnitAt(0); + int y = BYTEWORDS[bytewordOffset + 3].codeUnitAt(0) - 'a'.codeUnitAt(0); + int arrayOffset = y * dim + x; + _wordArray![arrayOffset] = i; + } + } + + // If the coordinates generated by the first and last letters are out of bounds, + // or the lookup table contains -1 at the coordinates, then the word is not valid. + int x = word[0].toLowerCase().codeUnitAt(0) - 'a'.codeUnitAt(0); + int y = word[wordLen == 4 ? 3 : 1].toLowerCase().codeUnitAt(0) - + 'a'.codeUnitAt(0); + if (x < 0 || x >= dim || y < 0 || y >= dim) { + throw ArgumentError('Invalid Bytewords.'); + } + + int value = _wordArray![y * dim + x]; + if (value == -1) { + throw ArgumentError('Invalid Bytewords.'); + } + + // If we're decoding a full four-letter word, verify that the two middle letters are correct. + if (wordLen == 4) { + int bytewordOffset = value * 4; + String c1 = word[1].toLowerCase(); + String c2 = word[2].toLowerCase(); + if (c1 != BYTEWORDS[bytewordOffset + 1] || + c2 != BYTEWORDS[bytewordOffset + 2]) { + throw ArgumentError('Invalid Bytewords.'); + } + } + + // Successful decode. + return value; +} + +String getWord(int index) { + int bytewordOffset = index * 4; + return BYTEWORDS.substring(bytewordOffset, bytewordOffset + 4); +} + +String getMinimalWord(int index) { + int bytewordOffset = index * 4; + return BYTEWORDS[bytewordOffset] + BYTEWORDS[bytewordOffset + 3]; +} + +String encode(Uint8List buf, String separator) { + return buf.map((byte) => getWord(byte)).join(separator); +} + +Uint8List addCrc(Uint8List buf) { + Uint8List crcBuf = crc32Bytes(buf); + return Uint8List.fromList([...buf, ...crcBuf]); +} + +String encodeWithSeparator(Uint8List buf, String separator) { + Uint8List crcBuf = addCrc(buf); + return encode(crcBuf, separator); +} + +String encodeMinimal(Uint8List buf) { + Uint8List crcBuf = addCrc(buf); + return crcBuf.map((byte) => getMinimalWord(byte)).join(); +} + +Uint8List decode(String s, String separator, int wordLen) { + List words; + if (wordLen == 4) { + words = s.split(separator); + } else { + words = partition(s, 2); + } + + Uint8List buf = Uint8List(words.length); + for (int i = 0; i < words.length; i++) { + buf[i] = decodeWord(words[i], wordLen); + } + + if (buf.length < 5) { + throw ArgumentError('Invalid Bytewords.'); + } + + // Validate checksum + Uint8List body = buf.sublist(0, buf.length - 4); + Uint8List bodyChecksum = buf.sublist(buf.length - 4); + Uint8List checksum = crc32Bytes(body); + Function listEquals = const ListEquality().equals; + if (!listEquals(checksum, bodyChecksum)) { + throw ArgumentError('Invalid Bytewords.'); + } + + return body; +} + +String encodeStyle(Style style, Uint8List bytes) { + switch (style) { + case Style.standard: + return encodeWithSeparator(bytes, ' '); + case Style.uri: + return encodeWithSeparator(bytes, '-'); + case Style.minimal: + return encodeMinimal(bytes); + default: + throw ArgumentError('Invalid Bytewords style.'); + } +} + +Uint8List decodeStyle(Style style, String str) { + switch (style) { + case Style.standard: + return decode(str, ' ', 4); + case Style.uri: + return decode(str, '-', 4); + case Style.minimal: + return decode(str, '', 2); + default: + throw ArgumentError('Invalid Bytewords style.'); + } +} diff --git a/packages/bc_ur/lib/cashu_animated_qr_example.dart b/packages/bc_ur/lib/cashu_animated_qr_example.dart new file mode 100644 index 000000000..1e2912aa7 --- /dev/null +++ b/packages/bc_ur/lib/cashu_animated_qr_example.dart @@ -0,0 +1,140 @@ +// ignore_for_file: avoid_print +import 'package:ndk/domain_layer/entities/cashu/cashu_proof.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_token.dart'; +import 'cashu_token_ur_encoder.dart'; + +/// Example demonstrating NUT-16 Animated QR codes using UR encoding +/// +/// This shows how to encode and decode Cashu tokens using the UR (Uniform Resources) +/// protocol for both single-part (static QR) and multi-part (animated QR) scenarios. +void main() { + print('=== Cashu Token UR Encoding Example (NUT-16) ===\n'); + + // Example 1: Single-part UR (for small tokens - static QR code) + singlePartExample(); + + print('\n' + '=' * 60 + '\n'); + + // Example 2: Multi-part UR (for large tokens - animated QR codes) + multiPartExample(); +} + +void singlePartExample() { + print('Example 1: Single-Part UR (Static QR Code)\n'); + + // Create a simple Cashu token with one proof + final token = CashuToken( + proofs: [ + CashuProof( + amount: 8, + secret: 'my-secret-proof-data', + unblindedSig: + '02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2', + keysetId: '009a1f293253e41e', + ), + ], + memo: 'Payment for coffee', + unit: 'sat', + mintUrl: 'https://mint.example.com', + ); + + // Encode to single-part UR + final urString = CashuTokenUrEncoder.encodeSinglePart(token: token); + print('Encoded UR (for QR code):'); + print(urString); + print('\nThis can be displayed as a single static QR code.'); + + // Decode back + final decodedToken = CashuTokenUrEncoder.decodeSinglePart(urString); + if (decodedToken != null) { + print('\nSuccessfully decoded token:'); + print(' Mint: ${decodedToken.mintUrl}'); + print(' Amount: ${decodedToken.proofs[0].amount} ${decodedToken.unit}'); + print(' Memo: ${decodedToken.memo}'); + } +} + +void multiPartExample() { + print('Example 2: Multi-Part UR (Animated QR Code)\n'); + + // Create a larger token that requires multiple parts + final proofs = List.generate( + 5, + (i) => CashuProof( + amount: 1 << i, // 1, 2, 4, 8, 16 + secret: 'proof-$i-with-long-data-${"x" * 50}', + unblindedSig: + '02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2', + keysetId: '009a1f293253e41e', + ), + ); + + final token = CashuToken( + proofs: proofs, + memo: 'Large payment requiring multiple QR codes', + unit: 'sat', + mintUrl: 'https://mint.example.com', + ); + + // Create multi-part encoder with small fragment size to demonstrate + final encoder = CashuTokenUrEncoder.createMultiPartEncoder( + token: token, + maxFragmentLen: 80, // Small size to force multiple parts + ); + + print('Encoding large token as animated QR code...'); + print('Is single part: ${encoder.isSinglePart}'); + + // Generate all parts (each part would be a frame in the animated QR) + final parts = []; + while (!encoder.isComplete) { + final part = encoder.nextPart(); + parts.add(part); + if (parts.length > 20) break; // Safety limit for example + } + + print('Generated ${parts.length} parts for animated QR code\n'); + + // Show first few parts + print('First 3 parts (each would be a QR code frame):'); + for (var i = 0; i < 3 && i < parts.length; i++) { + print(' Part ${i + 1}: ${parts[i].substring(0, 40)}...'); + } + + // Demonstrate decoding (receiver side) + print('\nDecoding process (scanning animated QR)...'); + + final decoder = CashuTokenUrEncoder.createMultiPartDecoder(); + + // Feed parts to decoder (simulating scanning QR codes) + for (var i = 0; i < parts.length; i++) { + decoder.receivePart(parts[i]); + final progress = decoder.estimatedPercentComplete(); + if (i % 2 == 0 || decoder.isComplete()) { + // Show progress every 2 parts + print( + ' Scanned part ${i + 1}/${parts.length} - ${(progress * 100).toStringAsFixed(1)}% complete'); + } + if (decoder.isComplete()) break; + } + + // Decode the complete token + if (decoder.isComplete()) { + final decodedToken = + CashuTokenUrEncoder.decodeFromMultiPartDecoder(decoder); + if (decodedToken != null) { + print('\nSuccessfully decoded complete token:'); + print(' Mint: ${decodedToken.mintUrl}'); + print(' Total proofs: ${decodedToken.proofs.length}'); + final totalAmount = + decodedToken.proofs.fold(0, (sum, p) => sum + p.amount); + print(' Total amount: $totalAmount ${decodedToken.unit}'); + print(' Memo: ${decodedToken.memo}'); + } + } + + print('\n📱 In a real wallet app:'); + print(' - Sender: Display parts as animated QR (cycling through frames)'); + print(' - Receiver: Scan until decoder.isComplete() returns true'); + print(' - Parts can be received in any order (robust to missed frames)'); +} diff --git a/packages/bc_ur/lib/cashu_token_ur_encoder.dart b/packages/bc_ur/lib/cashu_token_ur_encoder.dart new file mode 100644 index 000000000..619619741 --- /dev/null +++ b/packages/bc_ur/lib/cashu_token_ur_encoder.dart @@ -0,0 +1,131 @@ +import 'dart:typed_data'; + +import 'package:cbor/cbor.dart'; +import 'package:ndk/entities.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ur/ur.dart'; +import 'package:ur/ur_encoder.dart'; +import 'package:ur/ur_decoder.dart'; + +/// Encoder and decoder for Cashu tokens using UR (Uniform Resources) format. +/// This implements NUT-16 for animated QR codes support. +/// +/// Based on the UR specification: https://developer.blockchaincommons.com/ur/ +class CashuTokenUrEncoder { + /// The UR type for Cashu tokens + static const String urType = 'bytes'; + + /// Encodes a Cashu token to a single-part UR string. + /// Use this for tokens that can fit in a single QR code. + /// + /// Returns a UR-formatted string like: "ur:bytes/..." + static String encodeSinglePart({ + required CashuToken token, + }) { + try { + final json = token.toV4Json(); + final myCbor = CborValue(json); + final cborBytes = Uint8List.fromList(cbor.encode(myCbor)); + + final ur = UR(urType, cborBytes); + return UREncoder.encode(ur); + } catch (e) { + Logger.log.f('Error encoding token to UR: $e'); + rethrow; + } + } + + /// Decodes a single-part UR string back to a Cashu token. + /// + /// Returns null if the UR string is invalid or cannot be decoded. + static CashuToken? decodeSinglePart(String urString) { + try { + final ur = URDecoder.decode(urString); + + if (ur.type != urType) { + Logger.log.f('Invalid UR type: expected $urType, got ${ur.type}'); + return null; + } + + final cborValue = cbor.decode(ur.cbor); + final json = cborValue.toJson() as Map; + + return CashuToken.fromV4Json(json); + } catch (e) { + Logger.log.f('Error decoding UR to token: $e'); + return null; + } + } + + /// Creates a UREncoder for generating animated QR codes (multi-part URs). + /// Use this for large tokens that need to be split across multiple QR codes. + /// + /// [token] - The Cashu token to encode + /// [maxFragmentLen] - Maximum size of each fragment (default: 100 bytes) + /// + /// Returns a UREncoder that can generate multiple UR parts via nextPart() + static UREncoder createMultiPartEncoder({ + required CashuToken token, + int maxFragmentLen = 100, + int firstSeqNum = 0, + int minFragmentLen = 10, + }) { + try { + final json = token.toV4Json(); + final myCbor = CborValue(json); + final cborBytes = Uint8List.fromList(cbor.encode(myCbor)); + + final ur = UR(urType, cborBytes); + return UREncoder( + ur, + maxFragmentLen, + firstSeqNum: firstSeqNum, + minFragmentLen: minFragmentLen, + ); + } catch (e) { + Logger.log.f('Error creating multi-part UR encoder: $e'); + rethrow; + } + } + + /// Creates a URDecoder for decoding animated QR codes (multi-part URs). + /// Feed each scanned UR part to the decoder using receivePart() until complete. + /// + /// Returns a URDecoder that accumulates parts until the token is complete + static URDecoder createMultiPartDecoder() { + return URDecoder(); + } + + /// Decodes a complete multi-part UR back to a Cashu token. + /// Call this after the URDecoder indicates it's complete (isComplete() == true). + /// + /// Returns null if the decoder is not complete or decoding fails. + static CashuToken? decodeFromMultiPartDecoder(URDecoder decoder) { + try { + if (!decoder.isComplete()) { + Logger.log.f('Decoder is not complete yet'); + return null; + } + + final result = decoder.resultMessage(); + if (result == null || result is! UR) { + Logger.log.f('Invalid decoder result'); + return null; + } + + final ur = result as UR; + if (ur.type != urType) { + Logger.log.f('Invalid UR type: expected $urType, got ${ur.type}'); + return null; + } + + final cborValue = cbor.decode(ur.cbor); + final json = cborValue.toJson() as Map; + + return CashuToken.fromV4Json(json); + } catch (e) { + Logger.log.f('Error decoding multi-part UR to token: $e'); + return null; + } + } +} diff --git a/packages/bc_ur/lib/cbor_lite.dart b/packages/bc_ur/lib/cbor_lite.dart new file mode 100644 index 000000000..c378d17c6 --- /dev/null +++ b/packages/bc_ur/lib/cbor_lite.dart @@ -0,0 +1,337 @@ +import 'dart:typed_data'; + +enum Flag { + none, + requireMinimalEncoding, +} + +class CBORTag { + static const int majorUnsignedInteger = 0; + static const int majorNegativeInteger = 1 << 5; + static const int majorByteString = 2 << 5; + static const int majorTextString = 3 << 5; + static const int majorArray = 4 << 5; + static const int majorMap = 5 << 5; + static const int majorSemantic = 6 << 5; + static const int majorFloatingPoint = 7 << 5; + static const int majorSimple = 7 << 5; + static const int majorMask = 0xe0; + + static const int minorLength1 = 24; + static const int minorLength2 = 25; + static const int minorLength4 = 26; + static const int minorLength8 = 27; + + static const int minorFalse = 20; + static const int minorTrue = 21; + static const int minorNull = 22; + static const int minorUndefined = 23; + static const int minorHalfFloat = 25; + static const int minorSingleFloat = 26; + static const int minorDoubleFloat = 27; + + static const int minorDateTime = 0; + static const int minorEpochDateTime = 1; + static const int minorPositiveBignum = 2; + static const int minorNegativeBignum = 3; + static const int minorDecimalFraction = 4; + static const int minorBigFloat = 5; + static const int minorConvertBase64Url = 21; + static const int minorConvertBase64 = 22; + static const int minorConvertBase16 = 23; + static const int minorCborEncodedData = 24; + static const int minorUri = 32; + static const int minorBase64Url = 33; + static const int minorBase64 = 34; + static const int minorRegex = 35; + static const int minorMimeMessage = 36; + static const int minorSelfDescribeCbor = 55799; + static const int minorMask = 0x1f; + static const int undefined = majorSemantic + minorUndefined; +} + +int getByteLength(int value) { + if (value < 24) { + return 0; + } + return (value.bitLength + 7) ~/ 8; +} + +class CBOREncoder { + final BytesBuilder _buffer = BytesBuilder(); + + Uint8List getBytes() { + return _buffer.toBytes(); + } + + int encodeTagAndAdditional(int tag, int additional) { + _buffer.addByte(tag + additional); + return 1; + } + + int encodeTagAndValue(int tag, int value) { + int length = getByteLength(value); + + if (length >= 5 && length <= 8) { + encodeTagAndAdditional(tag, CBORTag.minorLength8); + _buffer.add( + Uint8List(8)..buffer.asByteData().setUint64(0, value, Endian.big)); + } else if (length == 3 || length == 4) { + encodeTagAndAdditional(tag, CBORTag.minorLength4); + _buffer.add( + Uint8List(4)..buffer.asByteData().setUint32(0, value, Endian.big)); + } else if (length == 2) { + encodeTagAndAdditional(tag, CBORTag.minorLength2); + _buffer.add( + Uint8List(2)..buffer.asByteData().setUint16(0, value, Endian.big)); + } else if (length == 1) { + encodeTagAndAdditional(tag, CBORTag.minorLength1); + _buffer.addByte(value); + } else if (length == 0) { + encodeTagAndAdditional(tag, value); + } else { + throw Exception( + "Unsupported byte length of $length for value in encodeTagAndValue()"); + } + + return 1 + length; + } + + int encodeUnsigned(int value) { + return encodeTagAndValue(CBORTag.majorUnsignedInteger, value); + } + + int encodeNegative(int value) { + return encodeTagAndValue(CBORTag.majorNegativeInteger, value); + } + + int encodeInteger(int value) { + return value >= 0 ? encodeUnsigned(value) : encodeNegative(-value - 1); + } + + int encodeBool(bool value) { + return encodeTagAndValue( + CBORTag.majorSimple, value ? CBORTag.minorTrue : CBORTag.minorFalse); + } + + int encodeBytes(Uint8List value) { + int length = encodeTagAndValue(CBORTag.majorByteString, value.length); + _buffer.add(value); + return length + value.length; + } + + int encodeEncodedBytesPrefix(int value) { + return encodeTagAndValue( + CBORTag.majorSemantic, CBORTag.minorCborEncodedData); + } + + int encodeEncodedBytes(Uint8List value) { + int length = + encodeTagAndValue(CBORTag.majorSemantic, CBORTag.minorCborEncodedData); + return length + encodeBytes(value); + } + + int encodeText(String value) { + Uint8List utf8Bytes = Uint8List.fromList(value.codeUnits); + int length = encodeTagAndValue(CBORTag.majorTextString, utf8Bytes.length); + _buffer.add(utf8Bytes); + return length + utf8Bytes.length; + } + + int encodeArraySize(int value) { + return encodeTagAndValue(CBORTag.majorArray, value); + } + + int encodeMapSize(int value) { + return encodeTagAndValue(CBORTag.majorMap, value); + } +} + +class CBORDecoder { + final Uint8List _buffer; + int _position = 0; + + CBORDecoder(this._buffer); + + (int, int, int) decodeTagAndAdditional([Flag flag = Flag.none]) { + if (_position == _buffer.length) { + throw Exception("Not enough input"); + } + int octet = _buffer[_position++]; + int tag = octet & CBORTag.majorMask; + int additional = octet & CBORTag.minorMask; + return (tag, additional, 1); + } + + (int, int, int) decodeTagAndValue([Flag flag = Flag.none]) { + if (_position == _buffer.length) { + throw Exception("Not enough input"); + } + + var (tag, additional, length) = decodeTagAndAdditional(flag); + if (additional < CBORTag.minorLength1) { + return (tag, additional, length); + } + + int value = 0; + int bytesToRead = 0; + + switch (additional) { + case CBORTag.minorLength8: + bytesToRead = 8; + break; + case CBORTag.minorLength4: + bytesToRead = 4; + break; + case CBORTag.minorLength2: + bytesToRead = 2; + break; + case CBORTag.minorLength1: + bytesToRead = 1; + break; + default: + throw Exception("Bad additional value"); + } + + if (_buffer.length - _position < bytesToRead) { + throw Exception("Not enough input"); + } + + ByteData byteData = + ByteData.sublistView(_buffer, _position, _position + bytesToRead); + _position += bytesToRead; + + switch (bytesToRead) { + case 8: + value = byteData.getUint64(0, Endian.big); + break; + case 4: + value = byteData.getUint32(0, Endian.big); + break; + case 2: + value = byteData.getUint16(0, Endian.big); + break; + case 1: + value = byteData.getUint8(0); + break; + } + + if (flag == Flag.requireMinimalEncoding && value < 24) { + throw Exception("Encoding not minimal"); + } + + return (tag, value, _position); + } + + (int, int) decodeUnsigned([Flag flag = Flag.none]) { + var (tag, value, length) = decodeTagAndValue(flag); + if (tag != CBORTag.majorUnsignedInteger) { + throw Exception("Expected majorUnsignedInteger, but found $tag"); + } + return (value, length); + } + + (int, int) decodeNegative([Flag flag = Flag.none]) { + var (tag, value, length) = decodeTagAndValue(flag); + if (tag != CBORTag.majorNegativeInteger) { + throw Exception("Expected majorNegativeInteger, but found $tag"); + } + return (value, length); + } + + (int, int) decodeInteger([Flag flag = Flag.none]) { + var (tag, value, length) = decodeTagAndValue(flag); + if (tag == CBORTag.majorUnsignedInteger) { + return (value, length); + } else if (tag == CBORTag.majorNegativeInteger) { + return (-1 - value, length); + } + throw Exception("Expected integer, but found $tag"); + } + + (bool, int) decodeBool([Flag flag = Flag.none]) { + var (tag, value, length) = decodeTagAndValue(flag); + if (tag == CBORTag.majorSimple) { + if (value == CBORTag.minorTrue) { + return (true, length); + } else if (value == CBORTag.minorFalse) { + return (false, length); + } + } + throw Exception("Not a Boolean"); + } + + (Uint8List, int) decodeBytes([Flag flag = Flag.none]) { + var (tag, byteLength, sizeLength) = decodeTagAndValue(flag); + if (tag != CBORTag.majorByteString) { + throw Exception("Not a byteString"); + } + + if (_buffer.length - _position < byteLength) { + throw Exception("Not enough input"); + } + + Uint8List value = + Uint8List.sublistView(_buffer, _position, _position + byteLength); + _position += byteLength; + return (value, sizeLength + byteLength); + } + + (int, int, int) decodeEncodedBytesPrefix([Flag flag = Flag.none]) { + var (tag, value, length1) = decodeTagAndValue(flag); + if (tag != CBORTag.majorSemantic || value != CBORTag.minorCborEncodedData) { + throw Exception("Not CBOR Encoded Data"); + } + + var (tag2, value2, length2) = decodeTagAndValue(flag); + if (tag2 != CBORTag.majorByteString) { + throw Exception("Not byteString"); + } + + return (tag2, value2, length1 + length2); + } + + (Uint8List, int) decodeEncodedBytes([Flag flag = Flag.none]) { + var (tag, minorTag, tagLength) = decodeTagAndValue(flag); + if (tag != CBORTag.majorSemantic || + minorTag != CBORTag.minorCborEncodedData) { + throw Exception("Not CBOR Encoded Data"); + } + + var (value, length) = decodeBytes(flag); + return (value, tagLength + length); + } + + (String, int) decodeText([Flag flag = Flag.none]) { + var (tag, byteLength, sizeLength) = decodeTagAndValue(flag); + if (tag != CBORTag.majorTextString) { + throw Exception("Not a textString"); + } + + if (_buffer.length - _position < byteLength) { + throw Exception("Not enough input"); + } + + Uint8List utf8Bytes = + Uint8List.sublistView(_buffer, _position, _position + byteLength); + _position += byteLength; + String value = String.fromCharCodes(utf8Bytes); + return (value, sizeLength + byteLength); + } + + (int, int) decodeArraySize([Flag flag = Flag.none]) { + var (tag, value, length) = decodeTagAndValue(flag); + if (tag != CBORTag.majorArray) { + throw Exception("Expected majorArray, but found $tag"); + } + return (value, length); + } + + (int, int) decodeMapSize([Flag flag = Flag.none]) { + var (tag, value, length) = decodeTagAndValue(flag); + if (tag != CBORTag.majorMap) { + throw Exception("Expected majorMap, but found $tag"); + } + return (value, length); + } +} diff --git a/packages/bc_ur/lib/constants.dart b/packages/bc_ur/lib/constants.dart new file mode 100644 index 000000000..5ee6aa827 --- /dev/null +++ b/packages/bc_ur/lib/constants.dart @@ -0,0 +1,3 @@ +const int MAX_UINT32 = 0xFFFFFFFF; +final BigInt MAX_UINT64 = + BigInt.parse('18446744073709551615'); // 0xFFFFFFFFFFFFFFFF diff --git a/packages/bc_ur/lib/crc32.dart b/packages/bc_ur/lib/crc32.dart new file mode 100644 index 000000000..250f5f468 --- /dev/null +++ b/packages/bc_ur/lib/crc32.dart @@ -0,0 +1,33 @@ +import 'dart:typed_data'; +import 'package:ur/constants.dart'; + +class CRC32 { + static List? _table; + + static int crc32(Uint8List buf) { + // Lazily instantiate CRC table + if (_table == null) { + _table = List.filled(256 * 4, 0); + + for (int i = 0; i < 256; i++) { + int c = i; + for (int j = 0; j < 8; j++) { + c = (c % 2 == 0) ? (c >> 1) : (0xEDB88320 ^ (c >> 1)); + } + _table![i] = c; + } + } + + int crc = MAX_UINT32 & ~0; + for (int byte in buf) { + crc = (crc >> 8) ^ _table![(crc ^ byte) & 0xFF]; + } + + return MAX_UINT32 & ~crc; + } + + static Uint8List crc32n(Uint8List buf) { + int n = crc32(buf); + return Uint8List(4)..buffer.asByteData().setUint32(0, n, Endian.big); + } +} diff --git a/packages/bc_ur/lib/fountain_decoder.dart b/packages/bc_ur/lib/fountain_decoder.dart new file mode 100644 index 000000000..4ffaed118 --- /dev/null +++ b/packages/bc_ur/lib/fountain_decoder.dart @@ -0,0 +1,216 @@ +import 'dart:typed_data'; +import 'package:ur/fountain_encoder.dart'; +import 'package:ur/fountain_utils.dart'; +import 'package:ur/utils.dart'; + +class InvalidPart implements Exception { + String message; + InvalidPart([this.message = 'Invalid part']); +} + +class InvalidChecksum implements Exception { + String message; + InvalidChecksum([this.message = 'Invalid checksum']); +} + +class FountainDecoderPart { + final Set indexes; + final Uint8List data; + + FountainDecoderPart(this.indexes, this.data); + + factory FountainDecoderPart.fromEncoderPart(FountainEncoderPart p) { + return FountainDecoderPart( + chooseFragments(p.seqNum, p.seqLen, p.checksum), + Uint8List.fromList(p.data), + ); + } + + bool get isSimple => indexes.length == 1; + + int get index => indexes.first; +} + +class FountainDecoder { + Set receivedPartIndexes = {}; + Set? lastPartIndexes; + int processedPartsCount = 0; + dynamic result; + Set? expectedPartIndexes; + int? expectedFragmentLen; + int? expectedMessageLen; + int? expectedChecksum; + Map, FountainDecoderPart> simpleParts = {}; + Map, FountainDecoderPart> mixedParts = {}; + List queuedParts = []; + + int? expectedPartCount() { + return expectedPartIndexes?.length; + } + + bool isSuccess() { + return result != null && result is! Exception; + } + + bool isFailure() { + return result != null && result is Exception; + } + + bool isComplete() { + return result != null; + } + + dynamic resultMessage() { + return result; + } + + Exception? resultError() { + return result is Exception ? result : null; + } + + double estimatedPercentComplete() { + if (isComplete()) { + return 1; + } + if (expectedPartIndexes == null) { + return 0; + } + double estimatedInputParts = expectedPartCount()! * 1.75; + return (processedPartsCount / estimatedInputParts).clamp(0, 0.99); + } + + bool receivePart(FountainEncoderPart encoderPart) { + if (isComplete()) { + return false; + } + + if (!validatePart(encoderPart)) { + return false; + } + + var p = FountainDecoderPart.fromEncoderPart(encoderPart); + lastPartIndexes = p.indexes; + enqueue(p); + + while (!isComplete() && queuedParts.isNotEmpty) { + processQueueItem(); + } + + processedPartsCount++; + + return true; + } + + static Uint8List joinFragments(List fragments, int messageLen) { + var message = joinBytes(fragments); + return takeFirst(message, messageLen); + } + + void enqueue(FountainDecoderPart p) { + queuedParts.add(p); + } + + void processQueueItem() { + var part = queuedParts.removeAt(0); + + if (part.isSimple) { + processSimplePart(part); + } else { + processMixedPart(part); + } + } + + void reduceBy(FountainDecoderPart p) { + var reducedParts = + mixedParts.values.map((value) => reducePartByPart(value, p)).toList(); + + var newMixed = , FountainDecoderPart>{}; + for (var reducedPart in reducedParts) { + if (reducedPart.isSimple) { + enqueue(reducedPart); + } else { + newMixed[reducedPart.indexes] = reducedPart; + } + } + + mixedParts = newMixed; + } + + FountainDecoderPart reducePartByPart( + FountainDecoderPart a, FountainDecoderPart b) { + if (isStrictSubset(b.indexes, a.indexes)) { + var newIndexes = a.indexes.difference(b.indexes); + var newData = xorWith(Uint8List.fromList(a.data), b.data); + return FountainDecoderPart(newIndexes, newData); + } else { + return a; + } + } + + void processSimplePart(FountainDecoderPart p) { + var fragmentIndex = p.index; + if (receivedPartIndexes.contains(fragmentIndex)) { + return; + } + + simpleParts[p.indexes] = p; + receivedPartIndexes.add(fragmentIndex); + + if (receivedPartIndexes.length == expectedPartIndexes!.length) { + var sortedParts = simpleParts.values.toList() + ..sort((a, b) => a.index.compareTo(b.index)); + + var fragments = sortedParts.map((part) => part.data).toList(); + + var message = joinFragments(fragments, expectedMessageLen!); + + var checksum = crc32Int(message); + if (checksum == expectedChecksum) { + result = message; + } else { + result = InvalidChecksum(); + } + } else { + reduceBy(p); + } + } + + void processMixedPart(FountainDecoderPart p) { + if (mixedParts.values.any((r) => r.indexes == p.indexes)) { + return; + } + + var p2 = p; + for (var r in simpleParts.values) { + p2 = reducePartByPart(p2, r); + } + + for (var r in mixedParts.values) { + p2 = reducePartByPart(p2, r); + } + + if (p2.isSimple) { + enqueue(p2); + } else { + reduceBy(p2); + mixedParts[p2.indexes] = p2; + } + } + + bool validatePart(FountainEncoderPart p) { + if (expectedPartIndexes == null) { + expectedPartIndexes = + Set.from(List.generate(p.seqLen, (i) => i)); + expectedMessageLen = p.messageLen; + expectedChecksum = p.checksum; + expectedFragmentLen = p.data.length; + } else { + if (expectedPartCount() != p.seqLen) return false; + if (expectedMessageLen != p.messageLen) return false; + if (expectedChecksum != p.checksum) return false; + if (expectedFragmentLen != p.data.length) return false; + } + + return true; + } +} diff --git a/packages/bc_ur/lib/fountain_encoder.dart b/packages/bc_ur/lib/fountain_encoder.dart new file mode 100644 index 000000000..731a2a023 --- /dev/null +++ b/packages/bc_ur/lib/fountain_encoder.dart @@ -0,0 +1,131 @@ +import 'dart:math'; +import 'dart:typed_data'; +import 'package:ur/cbor_lite.dart'; +import 'package:ur/fountain_utils.dart'; +import 'package:ur/utils.dart'; +import 'package:ur/constants.dart'; + +class InvalidHeader implements Exception { + String message; + InvalidHeader([this.message = 'Invalid header']); +} + +class FountainEncoderPart { + final int seqNum; + final int seqLen; + final int messageLen; + final int checksum; + final Uint8List data; + + FountainEncoderPart( + this.seqNum, this.seqLen, this.messageLen, this.checksum, this.data); + + static FountainEncoderPart fromCbor(Uint8List cborBuf) { + var decoder = CBORDecoder(cborBuf); + var (arraySize, _) = decoder.decodeArraySize(); + if (arraySize != 5) { + throw InvalidHeader(); + } + + var (seqNum, _) = decoder.decodeUnsigned(); + var (seqLen, _) = decoder.decodeUnsigned(); + var (messageLen, _) = decoder.decodeUnsigned(); + var (checksum, _) = decoder.decodeUnsigned(); + var (data, _) = decoder.decodeBytes(); + + return FountainEncoderPart(seqNum, seqLen, messageLen, checksum, data); + } + + Uint8List cbor() { + var encoder = CBOREncoder(); + encoder.encodeArraySize(5); + encoder.encodeInteger(seqNum); + encoder.encodeInteger(seqLen); + encoder.encodeInteger(messageLen); + encoder.encodeInteger(checksum); + encoder.encodeBytes(data); + return encoder.getBytes(); + } + + String description() { + return "seqNum:$seqNum, seqLen:$seqLen, messageLen:$messageLen, checksum:$checksum, data:${dataToHex(data)}"; + } +} + +class FountainEncoder { + final int messageLen; + final int checksum; + final int fragmentLen; + final List fragments; + int seqNum; + + FountainEncoder(Uint8List message, int maxFragmentLen, + {int firstSeqNum = 0, int minFragmentLen = 10}) + : messageLen = message.length, + checksum = crc32Int(message), + fragmentLen = findNominalFragmentLength( + message.length, minFragmentLen, maxFragmentLen), + fragments = partitionMessage( + message, + findNominalFragmentLength( + message.length, minFragmentLen, maxFragmentLen)), + seqNum = firstSeqNum { + assert(message.length <= MAX_UINT32); + } + + static int findNominalFragmentLength( + int messageLen, int minFragmentLen, int maxFragmentLen) { + assert(messageLen > 0); + assert(minFragmentLen > 0); + assert(maxFragmentLen >= minFragmentLen); + int maxFragmentCount = messageLen ~/ minFragmentLen; + int fragmentLen = messageLen; + + for (int fragmentCount = 1; + fragmentCount <= maxFragmentCount; + fragmentCount++) { + fragmentLen = (messageLen / fragmentCount).ceil(); + if (fragmentLen <= maxFragmentLen) { + break; + } + } + + return fragmentLen; + } + + static List partitionMessage(Uint8List message, int fragmentLen) { + List fragments = []; + for (int i = 0; i < message.length; i += fragmentLen) { + int end = min(i + fragmentLen, message.length); + Uint8List fragment = Uint8List(fragmentLen); + fragment.setAll(0, message.sublist(i, end)); + fragments.add(fragment); + } + return fragments; + } + + // Set get lastPartIndexes => + // chooseDegree(seqNum, seqLen, checksum).toSet(); + + int get seqLen => fragments.length; + + bool get isComplete => seqNum >= seqLen; + + bool get isSinglePart => seqLen == 1; + + FountainEncoderPart nextPart() { + seqNum++; + seqNum %= MAX_UINT32; // wrap at period 2^32 + var indexes = chooseFragments(seqNum, seqLen, checksum); + var mixed = mix(indexes); + return FountainEncoderPart(seqNum, seqLen, messageLen, checksum, mixed); + } + + Uint8List mix(Set indexes) { + var result = Uint8List(fragmentLen); + for (var index in indexes) { + xorInto(result, fragments[index]); + } + return result; + } +} diff --git a/packages/bc_ur/lib/fountain_utils.dart b/packages/bc_ur/lib/fountain_utils.dart new file mode 100644 index 000000000..43e475f9e --- /dev/null +++ b/packages/bc_ur/lib/fountain_utils.dart @@ -0,0 +1,51 @@ +import 'dart:typed_data'; +import 'package:ur/random_sampler.dart'; +import 'package:ur/utils.dart'; +import 'package:ur/xoshiro256.dart'; + +// Fisher-Yates shuffle +List shuffled(List items, Xoshiro256 rng) { + List remaining = List.from(items); + List result = []; + while (remaining.isNotEmpty) { + int index = rng.nextInt(0, remaining.length - 1); + T item = remaining.removeAt(index); + result.add(item); + } + return result; +} + +int chooseDegree(int seqLen, Xoshiro256 rng) { + List degreeProbabilities = []; + for (int i = 1; i <= seqLen; i++) { + degreeProbabilities.add(1.0 / i); + } + + RandomSampler degreeChooser = RandomSampler(degreeProbabilities); + return degreeChooser.next(() => rng.nextDouble()).toInt() + 1; +} + +Set chooseFragments(int seqNum, int seqLen, int checksum) { + // The first `seqLen` parts are the "pure" fragments, not mixed with any + // others. This means that if you only generate the first `seqLen` parts, + // then you have all the parts you need to decode the message. + if (seqNum <= seqLen) { + return {seqNum - 1}; + } else { + Uint8List seed = + Uint8List.fromList(intToBytes(seqNum) + intToBytes(checksum)); + Xoshiro256 rng = Xoshiro256.fromBytes(seed); + int degree = chooseDegree(seqLen, rng); + List indexes = List.generate(seqLen, (i) => i); + List shuffledIndexes = shuffled(indexes, rng); + return Set.from(shuffledIndexes.sublist(0, degree)); + } +} + +bool contains(Iterable setOrList, dynamic el) { + return setOrList.contains(el); +} + +bool isStrictSubset(Set a, Set b) { + return a.difference(b).isEmpty && a.length < b.length; +} diff --git a/packages/bc_ur/lib/random_sampler.dart b/packages/bc_ur/lib/random_sampler.dart new file mode 100644 index 000000000..0ffe92ef0 --- /dev/null +++ b/packages/bc_ur/lib/random_sampler.dart @@ -0,0 +1,82 @@ +import 'package:ur/xoshiro256.dart'; + +class RandomSampler { + final List _probs; + final List _aliases; + + RandomSampler._(List probs, List _aliases) + : _probs = probs, + _aliases = _aliases {} + + factory RandomSampler(List probs) { + assert(probs.every((p) => p > 0), "All probabilities must be positive"); + + // Normalize given probabilities + double total = probs.reduce((a, b) => a + b); + assert(total > 0, "Total probability must be positive"); + + int n = probs.length; + + List P = probs.map((p) => (p * n) / total).toList(); + + List S = []; + List L = []; + + // Set separate index lists for small and large probabilities: + for (int i = n - 1; i >= 0; i--) { + // at variance from Schwarz, we reverse the index order + if (P[i] < 1) { + S.add(i); + } else { + L.add(i); + } + } + + // Work through index lists + List _probs = List.filled(n, 0); + List _aliases = List.filled(n, 0); + + while (S.isNotEmpty && L.isNotEmpty) { + int a = S.removeLast(); // Schwarz's l + int g = L.removeLast(); // Schwarz's g + _probs[a] = P[a]; + _aliases[a] = g.toDouble(); + P[g] += P[a] - 1; + if (P[g] < 1) { + S.add(g); + } else { + L.add(g); + } + } + + while (L.isNotEmpty) { + _probs[L.removeLast()] = 1; + } + + while (S.isNotEmpty) { + // can only happen through numeric instability + _probs[S.removeLast()] = 1; + } + + return RandomSampler._(_probs, _aliases); + } + + int next(Function rndDouble) { + double r1 = rndDouble(); + double r2 = rndDouble(); + int n = _probs.length; + int i = (n * r1).floor(); + return r2 < _probs[i] ? i : _aliases[i].toInt(); + } +} + +List shuffled(List list, Xoshiro256 rng) { + var result = List.from(list); + for (var i = result.length - 1; i > 0; i--) { + var j = rng.nextInt(0, i + 1); + var temp = result[i]; + result[i] = result[j]; + result[j] = temp; + } + return result; +} diff --git a/packages/bc_ur/lib/ur.dart b/packages/bc_ur/lib/ur.dart new file mode 100644 index 000000000..deba9bff2 --- /dev/null +++ b/packages/bc_ur/lib/ur.dart @@ -0,0 +1,39 @@ +import 'dart:typed_data'; +import 'package:ur/utils.dart'; + +class InvalidType implements Exception { + String message; + InvalidType([this.message = 'Invalid type']); +} + +class UR { + final String type; + final Uint8List cbor; + + UR(this.type, this.cbor) { + if (!isUrType(type)) { + throw InvalidType(); + } + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is UR && + other.type == type && + _listEquals(other.cbor, cbor); + } + + @override + int get hashCode => type.hashCode ^ cbor.hashCode; + + // Helper method to compare Uint8List + bool _listEquals(Uint8List? a, Uint8List? b) { + if (a == null) return b == null; + if (b == null || a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} \ No newline at end of file diff --git a/packages/bc_ur/lib/ur_decoder.dart b/packages/bc_ur/lib/ur_decoder.dart new file mode 100644 index 000000000..5c8ac132a --- /dev/null +++ b/packages/bc_ur/lib/ur_decoder.dart @@ -0,0 +1,191 @@ +import 'package:ur/ur.dart'; +import 'package:ur/fountain_encoder.dart' as FountainEncoder; +import 'package:ur/fountain_decoder.dart' as FountainDecoder; +import 'package:ur/bytewords.dart' as Bytewords; +import 'package:ur/utils.dart'; + +class InvalidScheme implements Exception {} + +class InvalidType implements Exception {} + +class InvalidPathLength implements Exception {} + +class InvalidSequenceComponent implements Exception {} + +class InvalidFragment implements Exception {} + +class URDecoder { + final FountainDecoder.FountainDecoder fountainDecoder = + FountainDecoder.FountainDecoder(); + String? expectedType; + dynamic result; + + static UR decode(String str) { + var (type, components) = URDecoder.parse(str); + if (components.isEmpty) { + throw InvalidPathLength(); + } + + var body = components[0]; + return URDecoder.decodeByType(type, body); + } + + static UR decodeByType(String type, String body) { + var cbor = Bytewords.decodeStyle(Bytewords.Style.minimal, body); + return UR(type, cbor); + } + + static (String, List) parse(String str) { + // Don't consider case + var lowered = str.toLowerCase(); + + // Validate URI scheme + if (!lowered.startsWith('ur:')) { + throw InvalidScheme(); + } + + var path = lowered.substring(3); + + // Split the remainder into path components + var components = path.split('/'); + + // Make sure there are at least two path components + if (components.length < 2) { + throw InvalidPathLength(); + } + + // Validate the type + var type = components[0]; + if (!isUrType(type)) { + throw InvalidType(); + } + + var comps = components.sublist(1); // Don't include the ur type + return (type, comps); + } + + static (int, int) parseSequenceComponent(String str) { + try { + var comps = str.split('-'); + if (comps.length != 2) { + throw InvalidSequenceComponent(); + } + var seqNum = int.parse(comps[0]); + var seqLen = int.parse(comps[1]); + if (seqNum < 1 || seqLen < 1) { + throw InvalidSequenceComponent(); + } + return (seqNum, seqLen); + } catch (_) { + throw InvalidSequenceComponent(); + } + } + + bool validatePart(String type) { + if (expectedType == null) { + if (!isUrType(type)) { + return false; + } + expectedType = type; + return true; + } else { + return type == expectedType; + } + } + + bool receivePart(String str) { + try { + // Don't process the part if we're already done + if (result != null) { + return false; + } + + // Don't continue if this part doesn't validate + var (type, components) = URDecoder.parse(str); + if (!validatePart(type)) { + return false; + } + + // If this is a single-part UR then we're done + if (components.length == 1) { + var body = components[0]; + result = URDecoder.decodeByType(type, body); + return true; + } + + // Multi-part URs must have two path components: seq/fragment + if (components.length != 2) { + throw InvalidPathLength(); + } + var seq = components[0]; + var fragment = components[1]; + + // Parse the sequence component and the fragment, and make sure they agree. + var (seqNum, seqLen) = URDecoder.parseSequenceComponent(seq); + var cbor = Bytewords.decodeStyle(Bytewords.Style.minimal, fragment); + var part = FountainEncoder.FountainEncoderPart.fromCbor(cbor); + if (seqNum != part.seqNum || seqLen != part.seqLen) { + return false; + } + + // Process the part + if (!fountainDecoder.receivePart(part)) { + return false; + } + + if (fountainDecoder.isSuccess()) { + result = UR(type, fountainDecoder.resultMessage()); + } else if (fountainDecoder.isFailure()) { + result = fountainDecoder.resultError(); + } + + return true; + } catch (err) { + return false; + } + } + + // String? expectedType() { + // return expectedType; + // } + + int? expectedPartCount() { + return fountainDecoder.expectedPartCount(); + } + + Set receivedPartIndexes() { + return fountainDecoder.receivedPartIndexes; + } + + Set? lastPartIndexes() { + return fountainDecoder.lastPartIndexes; + } + + int processedPartsCount() { + return fountainDecoder.processedPartsCount; + } + + double estimatedPercentComplete() { + return fountainDecoder.estimatedPercentComplete(); + } + + bool isSuccess() { + return result != null && result is! Exception; + } + + bool isFailure() { + return result != null && result is Exception; + } + + bool isComplete() { + return result != null; + } + + dynamic resultMessage() { + return result; + } + + Exception? resultError() { + return result is Exception ? result : null; + } +} diff --git a/packages/bc_ur/lib/ur_encoder.dart b/packages/bc_ur/lib/ur_encoder.dart new file mode 100644 index 000000000..d9bbadef9 --- /dev/null +++ b/packages/bc_ur/lib/ur_encoder.dart @@ -0,0 +1,50 @@ +import 'package:ur/ur.dart'; +import 'package:ur/fountain_encoder.dart'; +import 'package:ur/bytewords.dart' as Bytewords; + +class UREncoder { + final UR ur; + final FountainEncoder fountainEncoder; + + UREncoder(this.ur, int maxFragmentLen, + {int firstSeqNum = 0, int minFragmentLen = 10}) + : fountainEncoder = FountainEncoder(ur.cbor, maxFragmentLen, + firstSeqNum: firstSeqNum, minFragmentLen: minFragmentLen); + + static String encode(UR ur) { + String body = Bytewords.encodeStyle(Bytewords.Style.minimal, ur.cbor); + return UREncoder.encodeUR([ur.type, body]); + } + + // Set lastPartIndexes() { + // return fountainEncoder.lastPartIndexes; + // } + + bool get isComplete => fountainEncoder.isComplete; + + bool get isSinglePart => fountainEncoder.isSinglePart; + + String nextPart() { + FountainEncoderPart part = fountainEncoder.nextPart(); + if (isSinglePart) { + return UREncoder.encode(ur); + } else { + return UREncoder.encodePart(ur.type, part); + } + } + + static String encodePart(String type, FountainEncoderPart part) { + String seq = '${part.seqNum}-${part.seqLen}'; + String body = Bytewords.encodeStyle(Bytewords.Style.minimal, part.cbor()); + return UREncoder.encodeUR([type, seq, body]); + } + + static String encodeUri(String scheme, List pathComponents) { + String path = pathComponents.join('/'); + return '$scheme:$path'; + } + + static String encodeUR(List pathComponents) { + return UREncoder.encodeUri('ur', pathComponents); + } +} diff --git a/packages/bc_ur/lib/utils.dart b/packages/bc_ur/lib/utils.dart new file mode 100644 index 000000000..ecc1a090e --- /dev/null +++ b/packages/bc_ur/lib/utils.dart @@ -0,0 +1,83 @@ +import 'dart:typed_data'; +import 'dart:convert'; +import 'package:ur/crc32.dart'; + +Uint8List crc32Bytes(Uint8List buf) { + return CRC32.crc32n(buf); +} + +int crc32Int(Uint8List buf) { + return CRC32.crc32(buf); +} + +String dataToHex(Uint8List buf) { + return buf.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); +} + +Uint8List intToBytes(int n) { + return Uint8List.fromList([ + (n >> 24) & 0xFF, + (n >> 16) & 0xFF, + (n >> 8) & 0xFF, + n & 0xFF, + ]); +} + +int bytesToInt(Uint8List buf) { + return (buf[0] << 24) | (buf[1] << 16) | (buf[2] << 8) | buf[3]; +} + +Uint8List stringToBytes(String s) { + return Uint8List.fromList(utf8.encode(s)); +} + +bool isUrType(String type) { + return RegExp(r'^[a-z0-9-]+$').hasMatch(type); +} + +List partition(String s, int n) { + return List.generate( + (s.length / n).ceil(), + (i) => + s.substring(i * n, (i + 1) * n > s.length ? s.length : (i + 1) * n)); +} + +Tuple split(Uint8List buf, int count) { + return Tuple(buf.sublist(0, count), buf.sublist(count)); +} + +List joinLists(List> lists) { + return lists.expand((list) => list).toList(); +} + +Uint8List joinBytes(List listOfBa) { + return Uint8List.fromList(listOfBa.expand((ba) => ba).toList()); +} + +void xorInto(Uint8List target, Uint8List source) { + assert(target.length == source.length, "Must be the same length"); + for (int i = 0; i < target.length; i++) { + target[i] ^= source[i]; + } +} + +Uint8List xorWith(Uint8List a, Uint8List b) { + Uint8List target = Uint8List.fromList(a); + xorInto(target, b); + return target; +} + +Uint8List takeFirst(Uint8List s, int count) { + return s.sublist(0, count); +} + +Uint8List dropFirst(Uint8List s, int count) { + return s.sublist(count); +} + +class Tuple { + final T1 item1; + final T2 item2; + + Tuple(this.item1, this.item2); +} diff --git a/packages/bc_ur/lib/xoshiro256.dart b/packages/bc_ur/lib/xoshiro256.dart new file mode 100644 index 000000000..65df40bbb --- /dev/null +++ b/packages/bc_ur/lib/xoshiro256.dart @@ -0,0 +1,166 @@ +import 'dart:typed_data'; +import 'package:crypto/crypto.dart'; +import 'package:ur/utils.dart'; +import 'package:ur/constants.dart'; + +// Mask for 64-bit unsigned integers +final BigInt _MASK64 = MAX_UINT64; + +BigInt rotl(BigInt x, int k) { + return ((x << k) | (x >> (64 - k))) & _MASK64; +} + +final List JUMP = [ + BigInt.parse('1733541517147835066'), // 0x180ec6d33cfd0aba + BigInt.parse('15369461998538869804'), // 0xd5a61266f0c9392c + BigInt.parse('12197330014494892970'), // 0xa9582618e03fc9aa + BigInt.parse('4138621300654548508') // 0x39abdc4529b1661c +]; + +final List LONG_JUMP = [ + BigInt.parse('8555335991981124543'), // 0x76e15d3efefdcbbf + BigInt.parse('14194738350262587827'), // 0xc5004e441c522fb3 + BigInt.parse('8593769755450971713'), // 0x77710069854ee241 + BigInt.parse('4111657796531716661') // 0x39109bb02acbe635 +]; + +class Xoshiro256 { + List s = List.filled(4, BigInt.zero); + + Xoshiro256([List? arr]) { + if (arr != null) { + for (int i = 0; i < 4; i++) { + s[i] = arr[i]; + } + } + } + + void _setS(Uint8List arr) { + for (int i = 0; i < 4; i++) { + int o = i * 8; + BigInt v = BigInt.zero; + for (int n = 0; n < 8; n++) { + v = (v << 8) | BigInt.from(arr[o + n]); + // print("v: " + v.toString()); + } + s[i] = v; + } + // print("s: " + s.toString()); + } + + void _hashThenSetS(Uint8List buf) { + var digest = sha256.convert(buf).bytes; + _setS(Uint8List.fromList(digest)); + } + + static Xoshiro256 fromInt8Array(Uint8List arr) { + var x = Xoshiro256(); + x._setS(arr); + return x; + } + + static Xoshiro256 fromBytes(Uint8List buf) { + var x = Xoshiro256(); + x._hashThenSetS(buf); + return x; + } + + static Xoshiro256 fromCrc32(int crc32) { + var x = Xoshiro256(); + var buf = intToBytes(crc32); + x._hashThenSetS(buf); + return x; + } + + static Xoshiro256 fromString(String s) { + var x = Xoshiro256(); + var buf = stringToBytes(s); + x._hashThenSetS(buf); + return x; + } + + BigInt next() { + var temp = (s[1] * BigInt.from(5)) & _MASK64; + temp = rotl(temp, 7); + var resultRaw = (temp * BigInt.from(9)) & _MASK64; + + BigInt t = (s[1] << 17) & _MASK64; + + s[2] ^= s[0]; + s[3] ^= s[1]; + s[1] ^= s[2]; + s[0] ^= s[3]; + + s[2] ^= t; + + s[3] = rotl(s[3], 45); + + return resultRaw; + } + + double nextDouble() { + BigInt m = MAX_UINT64 + BigInt.one; + BigInt nxt = next(); + return nxt / m; + } + + int nextInt(int low, int high) { + return (nextDouble() * (high - low + 1) + low).floor(); + } + + int nextByte() { + return nextInt(0, 255); + } + + Uint8List nextData(int count) { + var result = Uint8List(count); + for (int i = 0; i < count; i++) { + result[i] = nextByte(); + } + return result; + } + + void jump() { + BigInt s0 = BigInt.zero, + s1 = BigInt.zero, + s2 = BigInt.zero, + s3 = BigInt.zero; + for (int i = 0; i < JUMP.length; i++) { + for (int b = 0; b < 64; b++) { + if ((JUMP[i] & (BigInt.one << b)) != BigInt.zero) { + s0 ^= s[0]; + s1 ^= s[1]; + s2 ^= s[2]; + s3 ^= s[3]; + } + next(); + } + } + s[0] = s0; + s[1] = s1; + s[2] = s2; + s[3] = s3; + } + + void longJump() { + BigInt s0 = BigInt.zero, + s1 = BigInt.zero, + s2 = BigInt.zero, + s3 = BigInt.zero; + for (int i = 0; i < LONG_JUMP.length; i++) { + for (int b = 0; b < 64; b++) { + if ((LONG_JUMP[i] & (BigInt.one << b)) != BigInt.zero) { + s0 ^= s[0]; + s1 ^= s[1]; + s2 ^= s[2]; + s3 ^= s[3]; + } + next(); + } + } + s[0] = s0; + s[1] = s1; + s[2] = s2; + s[3] = s3; + } +} diff --git a/packages/bc_ur/pubspec.yaml b/packages/bc_ur/pubspec.yaml new file mode 100644 index 000000000..e0573cc91 --- /dev/null +++ b/packages/bc_ur/pubspec.yaml @@ -0,0 +1,19 @@ +name: ur +description: A Dart implementation of Uniform Resources (UR) encoding and decoding. +version: 0.1.0 +homepage: https://github.com/bukata-sa/dart-bc-ur + +environment: + sdk: '>3.0.0' + +dependencies: + cbor: ^6.3.7 + ndk: ^0.7.1-dev.3 + +dev_dependencies: + test: ^1.16.0 + lints: ^4.0.0 + +dependency_overrides: + ndk: + path: ../ndk diff --git a/packages/bc_ur/test/cashu_token_ur_encoder_test.dart b/packages/bc_ur/test/cashu_token_ur_encoder_test.dart new file mode 100644 index 000000000..59147e74e --- /dev/null +++ b/packages/bc_ur/test/cashu_token_ur_encoder_test.dart @@ -0,0 +1,409 @@ +import 'package:ndk/domain_layer/entities/cashu/cashu_proof.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_token.dart'; +import '../lib/cashu_token_ur_encoder.dart'; +import 'package:test/test.dart'; + +void main() { + group('CashuTokenUrEncoder - Single Part', () { + test('encode and decode simple token', () { + // Create a simple test token + final token = CashuToken( + proofs: [ + CashuProof( + amount: 8, + secret: 'test-secret-123', + unblindedSig: + '02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2', + keysetId: '009a1f293253e41e', + ), + ], + memo: 'test memo', + unit: 'sat', + mintUrl: 'https://testmint.example.com', + ); + + // Encode to UR + final urString = CashuTokenUrEncoder.encodeSinglePart(token: token); + + // Verify it starts with ur:bytes/ + expect(urString.startsWith('ur:bytes/'), isTrue); + + // Decode back + final decodedToken = CashuTokenUrEncoder.decodeSinglePart(urString); + + // Verify decoded token matches original + expect(decodedToken, isNotNull); + expect(decodedToken!.mintUrl, equals(token.mintUrl)); + expect(decodedToken.unit, equals(token.unit)); + expect(decodedToken.memo, equals(token.memo)); + expect(decodedToken.proofs.length, equals(1)); + expect(decodedToken.proofs[0].amount, equals(8)); + expect(decodedToken.proofs[0].secret, equals('test-secret-123')); + }); + + test('encode and decode token without memo', () { + final token = CashuToken( + proofs: [ + CashuProof( + amount: 16, + secret: 'another-secret', + unblindedSig: + '03b01869f528337e161a6768e480fcf9af32c76ff5dcf90bb4d1993c5c4e6e8e59', + keysetId: '009a1f293253e41e', + ), + ], + memo: '', + unit: 'sat', + mintUrl: 'https://mint.example.com', + ); + + final urString = CashuTokenUrEncoder.encodeSinglePart(token: token); + final decodedToken = CashuTokenUrEncoder.decodeSinglePart(urString); + + expect(decodedToken, isNotNull); + expect(decodedToken!.memo, equals('')); + expect(decodedToken.proofs[0].amount, equals(16)); + }); + + test('encode and decode token with multiple proofs', () { + final token = CashuToken( + proofs: [ + CashuProof( + amount: 1, + secret: 'secret-1', + unblindedSig: + '02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2', + keysetId: '009a1f293253e41e', + ), + CashuProof( + amount: 2, + secret: 'secret-2', + unblindedSig: + '03b01869f528337e161a6768e480fcf9af32c76ff5dcf90bb4d1993c5c4e6e8e59', + keysetId: '009a1f293253e41e', + ), + CashuProof( + amount: 4, + secret: 'secret-3', + unblindedSig: + '02c0ee6e3ecf9f2e6aa06a4b0cf0b9c4c3e6c9b8d0a0f3a4c3d9e8b7a6c5d4e3f2', + keysetId: '009a1f293253e41e', + ), + ], + memo: 'multiple proofs', + unit: 'sat', + mintUrl: 'https://multimint.example.com', + ); + + final urString = CashuTokenUrEncoder.encodeSinglePart(token: token); + final decodedToken = CashuTokenUrEncoder.decodeSinglePart(urString); + + expect(decodedToken, isNotNull); + expect(decodedToken!.proofs.length, equals(3)); + expect(decodedToken.proofs[0].amount, equals(1)); + expect(decodedToken.proofs[1].amount, equals(2)); + expect(decodedToken.proofs[2].amount, equals(4)); + }); + + test('decode invalid UR string returns null', () { + final decodedToken = + CashuTokenUrEncoder.decodeSinglePart('invalid-ur-string'); + expect(decodedToken, isNull); + }); + + test('decode UR with wrong type returns null', () { + // This is a valid UR but with wrong type + final decodedToken = + CashuTokenUrEncoder.decodeSinglePart('ur:crypto-seed/oeadgdaxbt'); + expect(decodedToken, isNull); + }); + }); + + group('CashuTokenUrEncoder - Multi Part (Animated QR)', () { + test('create multi-part encoder for large token', () { + // Create a token with many proofs to ensure it needs multiple parts + final proofs = List.generate( + 10, + (i) => CashuProof( + amount: 1 << i, // Powers of 2: 1, 2, 4, 8, 16, etc. + secret: 'secret-$i-with-some-long-text-to-make-it-larger-${"x" * 50}', + unblindedSig: + '02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2', + keysetId: '009a1f293253e41e', + ), + ); + + final token = CashuToken( + proofs: proofs, + memo: 'large token requiring multiple QR codes', + unit: 'sat', + mintUrl: 'https://largemint.example.com', + ); + + // Create encoder with small fragment size to force multiple parts + final encoder = CashuTokenUrEncoder.createMultiPartEncoder( + token: token, + maxFragmentLen: 100, + ); + + expect(encoder, isNotNull); + expect(encoder.isSinglePart, isFalse); + }); + + test('encode and decode multi-part UR', () { + // Create a token with several proofs + final proofs = List.generate( + 5, + (i) => CashuProof( + amount: 1 << i, + secret: 'secret-$i-${"x" * 30}', + unblindedSig: + '02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2', + keysetId: '009a1f293253e41e', + ), + ); + + final token = CashuToken( + proofs: proofs, + memo: 'multi-part test', + unit: 'sat', + mintUrl: 'https://mint.example.com', + ); + + // Create encoder with small fragment size + final encoder = CashuTokenUrEncoder.createMultiPartEncoder( + token: token, + maxFragmentLen: 80, + ); + + // Create decoder + final decoder = CashuTokenUrEncoder.createMultiPartDecoder(); + + // Generate and feed parts until complete + final parts = []; + while (!decoder.isComplete()) { + final part = encoder.nextPart(); + parts.add(part); + decoder.receivePart(part); + + // Prevent infinite loop + if (parts.length > 100) { + fail('Too many parts generated, something is wrong'); + } + } + + // Verify we generated multiple parts + expect(parts.length, greaterThan(1)); + + // Decode the complete message + final decodedToken = + CashuTokenUrEncoder.decodeFromMultiPartDecoder(decoder); + + // Verify decoded token matches original + expect(decodedToken, isNotNull); + expect(decodedToken!.mintUrl, equals(token.mintUrl)); + expect(decodedToken.unit, equals(token.unit)); + expect(decodedToken.memo, equals(token.memo)); + expect(decodedToken.proofs.length, equals(5)); + expect(decodedToken.proofs[0].amount, equals(1)); + expect(decodedToken.proofs[4].amount, equals(16)); + }); + + test('decoder tracks progress', () { + final proofs = List.generate( + 5, + (i) => CashuProof( + amount: 1 << i, + secret: 'secret-$i-${"x" * 30}', + unblindedSig: + '02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2', + keysetId: '009a1f293253e41e', + ), + ); + + final token = CashuToken( + proofs: proofs, + memo: 'progress test', + unit: 'sat', + mintUrl: 'https://mint.example.com', + ); + + final encoder = CashuTokenUrEncoder.createMultiPartEncoder( + token: token, + maxFragmentLen: 80, + ); + + final decoder = CashuTokenUrEncoder.createMultiPartDecoder(); + + // Feed first part + final firstPart = encoder.nextPart(); + decoder.receivePart(firstPart); + + // Check progress + final progress = decoder.estimatedPercentComplete(); + expect(progress, greaterThan(0.0)); + expect(progress, lessThanOrEqualTo(1.0)); + + // Complete the decoding + while (!decoder.isComplete()) { + final part = encoder.nextPart(); + decoder.receivePart(part); + } + + expect(decoder.isComplete(), isTrue); + expect(decoder.isSuccess(), isTrue); + }); + + test('decode incomplete multi-part returns null', () { + final proofs = List.generate( + 3, + (i) => CashuProof( + amount: 1, + secret: 'secret-$i-${"x" * 30}', + unblindedSig: + '02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2', + keysetId: '009a1f293253e41e', + ), + ); + + final token = CashuToken( + proofs: proofs, + memo: 'incomplete test', + unit: 'sat', + mintUrl: 'https://mint.example.com', + ); + + final encoder = CashuTokenUrEncoder.createMultiPartEncoder( + token: token, + maxFragmentLen: 50, + ); + + final decoder = CashuTokenUrEncoder.createMultiPartDecoder(); + + // Feed only first part (not complete) + final firstPart = encoder.nextPart(); + decoder.receivePart(firstPart); + + // Try to decode incomplete data + final decodedToken = + CashuTokenUrEncoder.decodeFromMultiPartDecoder(decoder); + expect(decodedToken, isNull); + }); + + test('parts can be received in any order', () { + final proofs = List.generate( + 4, + (i) => CashuProof( + amount: 1 << i, + secret: 'secret-$i-${"x" * 25}', + unblindedSig: + '02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2', + keysetId: '009a1f293253e41e', + ), + ); + + final token = CashuToken( + proofs: proofs, + memo: 'order test', + unit: 'sat', + mintUrl: 'https://mint.example.com', + ); + + final encoder = CashuTokenUrEncoder.createMultiPartEncoder( + token: token, + maxFragmentLen: 70, + ); + + // Generate all parts + final parts = []; + while (!encoder.isComplete) { + parts.add(encoder.nextPart()); + if (parts.length > 50) break; + } + + expect(parts.length, greaterThan(1)); + + // Shuffle parts to simulate out-of-order reception + final shuffledParts = List.from(parts)..shuffle(); + + // Decode shuffled parts + final decoder = CashuTokenUrEncoder.createMultiPartDecoder(); + for (final part in shuffledParts) { + decoder.receivePart(part); + if (decoder.isComplete()) break; + } + + expect(decoder.isComplete(), isTrue); + + final decodedToken = + CashuTokenUrEncoder.decodeFromMultiPartDecoder(decoder); + expect(decodedToken, isNotNull); + expect(decodedToken!.proofs.length, equals(4)); + }); + }); + + group('CashuTokenUrEncoder - Edge Cases', () { + test('encode token with empty proofs list', () { + final token = CashuToken( + proofs: [], + memo: '', + unit: 'sat', + mintUrl: 'https://mint.example.com', + ); + + final urString = CashuTokenUrEncoder.encodeSinglePart(token: token); + final decodedToken = CashuTokenUrEncoder.decodeSinglePart(urString); + + expect(decodedToken, isNotNull); + expect(decodedToken!.proofs.length, equals(0)); + }); + + test('encode token with long memo', () { + final longMemo = 'This is a very long memo ' * 10; + final token = CashuToken( + proofs: [ + CashuProof( + amount: 8, + secret: 'test-secret', + unblindedSig: + '02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2', + keysetId: '009a1f293253e41e', + ), + ], + memo: longMemo, + unit: 'sat', + mintUrl: 'https://mint.example.com', + ); + + final urString = CashuTokenUrEncoder.encodeSinglePart(token: token); + final decodedToken = CashuTokenUrEncoder.decodeSinglePart(urString); + + expect(decodedToken, isNotNull); + expect(decodedToken!.memo, equals(longMemo)); + }); + + test('encode token with special characters in secret', () { + final token = CashuToken( + proofs: [ + CashuProof( + amount: 8, + secret: '特殊字符-🎉-émojis-тест', + unblindedSig: + '02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2', + keysetId: '009a1f293253e41e', + ), + ], + memo: 'unicode test 测试 🚀', + unit: 'sat', + mintUrl: 'https://mint.example.com', + ); + + final urString = CashuTokenUrEncoder.encodeSinglePart(token: token); + final decodedToken = CashuTokenUrEncoder.decodeSinglePart(urString); + + expect(decodedToken, isNotNull); + expect(decodedToken!.proofs[0].secret, equals('特殊字符-🎉-émojis-тест')); + expect(decodedToken.memo, equals('unicode test 测试 🚀')); + }); + }); +} diff --git a/packages/bc_ur/test/test_utils.dart b/packages/bc_ur/test/test_utils.dart new file mode 100644 index 000000000..d59682a2c --- /dev/null +++ b/packages/bc_ur/test/test_utils.dart @@ -0,0 +1,17 @@ +import 'dart:typed_data'; +import 'package:ur/xoshiro256.dart'; +import 'package:ur/cbor_lite.dart'; +import 'package:ur/ur.dart'; + +Uint8List makeMessage(int length, {String seed = "Wolf"}) { + var rng = Xoshiro256.fromString(seed); + return rng.nextData(length); +} + +UR makeMessageUR(int length, {String seed = "Wolf"}) { + var message = makeMessage(length, seed: seed); + var encoder = CBOREncoder(); + encoder.encodeBytes(message); + + return UR("bytes", encoder.getBytes()); +} diff --git a/packages/bc_ur/test/ur_test.dart b/packages/bc_ur/test/ur_test.dart new file mode 100644 index 000000000..c507bae17 --- /dev/null +++ b/packages/bc_ur/test/ur_test.dart @@ -0,0 +1,1592 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:ur/bytewords.dart' as Bytewords; +import 'package:ur/cbor_lite.dart'; +import 'package:ur/xoshiro256.dart'; +import 'package:ur/fountain_encoder.dart'; +import 'package:ur/fountain_decoder.dart'; +import 'package:ur/fountain_utils.dart'; +import 'package:ur/ur_encoder.dart'; +import 'package:ur/ur_decoder.dart'; +import 'package:ur/ur.dart'; +import 'package:ur/random_sampler.dart' show RandomSampler; +import 'package:ur/utils.dart'; + +import 'test_utils.dart'; + +void main() { + group('UR Tests', () { + test('CRC32', () { + expect(checkCRC32("Hello, world!", "ebe6c6e6"), isTrue); + expect(checkCRC32("Wolf", "598c84dc"), isTrue); + }); + + test('Bytewords 1', () { + var input = Uint8List.fromList([0, 1, 2, 128, 255]); + expect(Bytewords.encodeStyle(Bytewords.Style.standard, input), + equals("able acid also lava zoom jade need echo taxi")); + expect(Bytewords.encodeStyle(Bytewords.Style.uri, input), + equals("able-acid-also-lava-zoom-jade-need-echo-taxi")); + expect(Bytewords.encodeStyle(Bytewords.Style.minimal, input), + equals("aeadaolazmjendeoti")); + + expect( + Bytewords.decodeStyle(Bytewords.Style.standard, + "able acid also lava zoom jade need echo taxi"), + equals(input)); + expect( + Bytewords.decodeStyle(Bytewords.Style.uri, + "able-acid-also-lava-zoom-jade-need-echo-taxi"), + equals(input)); + expect( + Bytewords.decodeStyle(Bytewords.Style.minimal, "aeadaolazmjendeoti"), + equals(input)); + + expect( + () => Bytewords.decodeStyle(Bytewords.Style.standard, + "able acid also lava zoom jade need echo wolf"), + throwsA(isA())); + expect( + () => Bytewords.decodeStyle(Bytewords.Style.uri, + "able-acid-also-lava-zoom-jade-need-echo-wolf"), + throwsA(isA())); + expect( + () => Bytewords.decodeStyle( + Bytewords.Style.minimal, "aeadaolazmjendeowf"), + throwsA(isA())); + + expect(() => Bytewords.decodeStyle(Bytewords.Style.standard, "wolf"), + throwsA(isA())); + expect(() => Bytewords.decodeStyle(Bytewords.Style.standard, ""), + throwsA(isA())); + }); + + test('Bytewords 2', () { + Uint8List input = Uint8List.fromList([ + 245, + 215, + 20, + 198, + 241, + 235, + 69, + 59, + 209, + 205, + 165, + 18, + 150, + 158, + 116, + 135, + 229, + 212, + 19, + 159, + 17, + 37, + 239, + 240, + 253, + 11, + 109, + 191, + 37, + 242, + 38, + 120, + 223, + 41, + 156, + 189, + 242, + 254, + 147, + 204, + 66, + 163, + 216, + 175, + 191, + 72, + 169, + 54, + 32, + 60, + 144, + 230, + 210, + 137, + 184, + 197, + 33, + 113, + 88, + 14, + 157, + 31, + 177, + 46, + 1, + 115, + 205, + 69, + 225, + 150, + 65, + 235, + 58, + 144, + 65, + 240, + 133, + 69, + 113, + 247, + 63, + 53, + 242, + 165, + 160, + 144, + 26, + 13, + 79, + 237, + 133, + 71, + 82, + 69, + 254, + 165, + 138, + 41, + 85, + 24 + ]); + + var encoded = + "yank toys bulb skew when warm free fair tent swan open brag mint noon jury list view tiny brew note body data webs what zinc bald join runs data whiz days keys user diet news ruby whiz zone menu surf flew omit trip pose runs fund part even crux fern math visa tied loud redo silk curl jugs hard beta next cost puma drum acid junk swan free very mint flap warm fact math flap what limp free jugs yell fish epic whiz open numb math city belt glow wave limp fuel grim free zone open love diet gyro cats fizz holy city puff"; + + var encodedMinimal = + "yktsbbswwnwmfefrttsnonbgmtnnjyltvwtybwnebydawswtzcbdjnrsdawzdsksurdtnsrywzzemusffwottppersfdptencxfnmhvatdldroskcljshdbantctpadmadjksnfevymtfpwmftmhfpwtlpfejsylfhecwzonnbmhcybtgwwelpflgmfezeonledtgocsfzhycypf"; + + expect(Bytewords.encodeStyle(Bytewords.Style.standard, input), + equals(encoded)); + expect(Bytewords.encodeStyle(Bytewords.Style.minimal, input), + equals(encodedMinimal)); + expect(Bytewords.decodeStyle(Bytewords.Style.standard, encoded), + equals(input)); + expect(Bytewords.decodeStyle(Bytewords.Style.minimal, encodedMinimal), + equals(input)); + }); + + test('RNG 1', () { + var rng = Xoshiro256.fromString("Wolf"); + var numbers = List.generate( + 100, (_) => (rng.next() % BigInt.from(100)).toInt()); + + var expectedNumbers = [ + 42, + 81, + 85, + 8, + 82, + 84, + 76, + 73, + 70, + 88, + 2, + 74, + 40, + 48, + 77, + 54, + 88, + 7, + 5, + 88, + 37, + 25, + 82, + 13, + 69, + 59, + 30, + 39, + 11, + 82, + 19, + 99, + 45, + 87, + 30, + 15, + 32, + 22, + 89, + 44, + 92, + 77, + 29, + 78, + 4, + 92, + 44, + 68, + 92, + 69, + 1, + 42, + 89, + 50, + 37, + 84, + 63, + 34, + 32, + 3, + 17, + 62, + 40, + 98, + 82, + 89, + 24, + 43, + 85, + 39, + 15, + 3, + 99, + 29, + 20, + 42, + 27, + 10, + 85, + 66, + 50, + 35, + 69, + 70, + 70, + 74, + 30, + 13, + 72, + 54, + 11, + 5, + 70, + 55, + 91, + 52, + 10, + 43, + 43, + 52 + ]; + expect(numbers, equals(expectedNumbers)); + }); + + test('RNG 2', () { + var checksum = + bytesToInt(crc32Bytes(Uint8List.fromList("Wolf".codeUnits))); + var rng = Xoshiro256.fromCrc32(checksum); + var numbers = List.generate( + 100, (_) => (rng.next() % BigInt.from(100)).toInt()); + + var expectedNumbers = [ + 88, + 44, + 94, + 74, + 0, + 99, + 7, + 77, + 68, + 35, + 47, + 78, + 19, + 21, + 50, + 15, + 42, + 36, + 91, + 11, + 85, + 39, + 64, + 22, + 57, + 11, + 25, + 12, + 1, + 91, + 17, + 75, + 29, + 47, + 88, + 11, + 68, + 58, + 27, + 65, + 21, + 54, + 47, + 54, + 73, + 83, + 23, + 58, + 75, + 27, + 26, + 15, + 60, + 36, + 30, + 21, + 55, + 57, + 77, + 76, + 75, + 47, + 53, + 76, + 9, + 91, + 14, + 69, + 3, + 95, + 11, + 73, + 20, + 99, + 68, + 61, + 3, + 98, + 36, + 98, + 56, + 65, + 14, + 80, + 74, + 57, + 63, + 68, + 51, + 56, + 24, + 39, + 53, + 80, + 57, + 51, + 81, + 3, + 1, + 30 + ]; + expect(numbers, equals(expectedNumbers)); + }); + + test('RNG 3', () { + var rng = Xoshiro256.fromString("Wolf"); + var numbers = List.generate(100, (_) => rng.nextInt(1, 10)); + + var expectedNumbers = [ + 6, + 5, + 8, + 4, + 10, + 5, + 7, + 10, + 4, + 9, + 10, + 9, + 7, + 7, + 1, + 1, + 2, + 9, + 9, + 2, + 6, + 4, + 5, + 7, + 8, + 5, + 4, + 2, + 3, + 8, + 7, + 4, + 5, + 1, + 10, + 9, + 3, + 10, + 2, + 6, + 8, + 5, + 7, + 9, + 3, + 1, + 5, + 2, + 7, + 1, + 4, + 4, + 4, + 4, + 9, + 4, + 5, + 5, + 6, + 9, + 5, + 1, + 2, + 8, + 3, + 3, + 2, + 8, + 4, + 3, + 2, + 1, + 10, + 8, + 9, + 3, + 10, + 8, + 5, + 5, + 6, + 7, + 10, + 5, + 8, + 9, + 4, + 6, + 4, + 2, + 10, + 2, + 1, + 7, + 9, + 6, + 7, + 4, + 2, + 5 + ]; + expect(numbers, equals(expectedNumbers)); + }); + + test('Find Fragment Length', () { + expect(FountainEncoder.findNominalFragmentLength(12345, 1005, 1955), + equals(1764)); + expect(FountainEncoder.findNominalFragmentLength(12345, 1005, 30000), + equals(12345)); + }); + + test('Random Sampler', () { + var probs = [1.0, 2.0, 4.0, 8.0]; + var sampler = RandomSampler(probs); + var rng = Xoshiro256.fromString("Wolf"); + var samples = + List.generate(500, (_) => sampler.next(rng.nextDouble)); + var expectedSamples = [ + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 0, + 2, + 3, + 3, + 3, + 3, + 1, + 2, + 2, + 1, + 3, + 3, + 2, + 3, + 3, + 1, + 1, + 2, + 1, + 1, + 3, + 1, + 3, + 1, + 2, + 0, + 2, + 1, + 0, + 3, + 3, + 3, + 1, + 3, + 3, + 3, + 3, + 1, + 3, + 2, + 3, + 2, + 2, + 3, + 3, + 3, + 3, + 2, + 3, + 3, + 0, + 3, + 3, + 3, + 3, + 1, + 2, + 3, + 3, + 2, + 2, + 2, + 1, + 2, + 2, + 1, + 2, + 3, + 1, + 3, + 0, + 3, + 2, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 2, + 3, + 1, + 3, + 3, + 2, + 0, + 2, + 2, + 3, + 1, + 1, + 2, + 3, + 2, + 3, + 3, + 3, + 3, + 2, + 3, + 3, + 3, + 3, + 3, + 2, + 3, + 1, + 2, + 1, + 1, + 3, + 1, + 3, + 2, + 2, + 3, + 3, + 3, + 1, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 2, + 3, + 2, + 3, + 3, + 1, + 2, + 3, + 3, + 1, + 3, + 2, + 3, + 3, + 3, + 2, + 3, + 1, + 3, + 0, + 3, + 2, + 1, + 1, + 3, + 1, + 3, + 2, + 3, + 3, + 3, + 3, + 2, + 0, + 3, + 3, + 1, + 3, + 0, + 2, + 1, + 3, + 3, + 1, + 1, + 3, + 1, + 2, + 3, + 3, + 3, + 0, + 2, + 3, + 2, + 0, + 1, + 3, + 3, + 3, + 2, + 2, + 2, + 3, + 3, + 3, + 3, + 3, + 2, + 3, + 3, + 3, + 3, + 2, + 3, + 3, + 2, + 0, + 2, + 3, + 3, + 3, + 3, + 2, + 1, + 1, + 1, + 2, + 1, + 3, + 3, + 3, + 2, + 2, + 3, + 3, + 1, + 2, + 3, + 0, + 3, + 2, + 3, + 3, + 3, + 3, + 0, + 2, + 2, + 3, + 2, + 2, + 3, + 3, + 3, + 3, + 1, + 3, + 2, + 3, + 3, + 3, + 3, + 3, + 2, + 2, + 3, + 1, + 3, + 0, + 2, + 1, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 1, + 3, + 3, + 3, + 3, + 2, + 2, + 2, + 3, + 1, + 1, + 3, + 2, + 2, + 0, + 3, + 2, + 1, + 2, + 1, + 0, + 3, + 3, + 3, + 2, + 2, + 3, + 2, + 1, + 2, + 0, + 0, + 3, + 3, + 2, + 3, + 3, + 2, + 3, + 3, + 3, + 3, + 3, + 2, + 2, + 2, + 3, + 3, + 3, + 3, + 3, + 1, + 1, + 3, + 2, + 2, + 3, + 1, + 1, + 0, + 1, + 3, + 2, + 3, + 3, + 2, + 3, + 3, + 2, + 3, + 3, + 2, + 2, + 2, + 2, + 3, + 2, + 2, + 2, + 2, + 2, + 1, + 2, + 3, + 3, + 2, + 2, + 2, + 2, + 3, + 3, + 2, + 0, + 2, + 1, + 3, + 3, + 3, + 3, + 0, + 3, + 3, + 3, + 3, + 2, + 2, + 3, + 1, + 3, + 3, + 3, + 2, + 3, + 3, + 3, + 2, + 3, + 3, + 3, + 3, + 2, + 3, + 2, + 1, + 3, + 3, + 3, + 3, + 2, + 2, + 0, + 1, + 2, + 3, + 2, + 0, + 3, + 3, + 3, + 3, + 3, + 3, + 1, + 3, + 3, + 2, + 3, + 2, + 2, + 3, + 3, + 3, + 3, + 3, + 2, + 2, + 3, + 3, + 2, + 2, + 2, + 1, + 3, + 3, + 3, + 3, + 1, + 2, + 3, + 2, + 3, + 3, + 2, + 3, + 2, + 3, + 3, + 3, + 2, + 3, + 1, + 2, + 3, + 2, + 1, + 1, + 3, + 3, + 2, + 3, + 3, + 2, + 3, + 3, + 0, + 0, + 1, + 3, + 3, + 2, + 3, + 3, + 3, + 3, + 1, + 3, + 3, + 0, + 3, + 2, + 3, + 3, + 1, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 0, + 3, + 3, + 2 + ]; + expect(samples, equals(expectedSamples)); + }); + + test('Shuffle', () { + var rng = Xoshiro256.fromString("Wolf"); + var values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + var result = []; + for (var i = 0; i < 10; i++) { + result.add(shuffled(List.from(values), rng)); + } + + var expectedResult = [ + [6, 4, 9, 3, 10, 5, 7, 8, 1, 2], + [10, 8, 6, 5, 1, 2, 3, 9, 7, 4], + [6, 4, 5, 8, 9, 3, 2, 1, 7, 10], + [7, 3, 5, 1, 10, 9, 4, 8, 2, 6], + [8, 5, 7, 10, 2, 1, 4, 3, 9, 6], + [4, 3, 5, 6, 10, 2, 7, 8, 9, 1], + [5, 1, 3, 9, 4, 6, 2, 10, 7, 8], + [2, 1, 10, 8, 9, 4, 7, 6, 3, 5], + [6, 7, 10, 4, 8, 9, 2, 3, 1, 5], + [10, 2, 1, 7, 9, 5, 6, 3, 4, 8] + ]; + expect(result, equals(expectedResult)); + }); + + test('Partition and join', () { + var message = makeMessage(1024); + var fragmentLen = + FountainEncoder.findNominalFragmentLength(message.length, 10, 100); + var fragments = FountainEncoder.partitionMessage(message, fragmentLen); + var fragmentsHex = fragments.map((f) => dataToHex(f)).toList(); + + var expectedFragments = [ + "916ec65cf77cadf55cd7f9cda1a1030026ddd42e905b77adc36e4f2d3ccba44f7f04f2de44f42d84c374a0e149136f25b01852545961d55f7f7a8cde6d0e2ec43f3b2dcb644a2209e8c9e34af5c4747984a5e873c9cf5f965e25ee29039f", + "df8ca74f1c769fc07eb7ebaec46e0695aea6cbd60b3ec4bbff1b9ffe8a9e7240129377b9d3711ed38d412fbb4442256f1e6f595e0fc57fed451fb0a0101fb76b1fb1e1b88cfdfdaa946294a47de8fff173f021c0e6f65b05c0a494e50791", + "270a0050a73ae69b6725505a2ec8a5791457c9876dd34aadd192a53aa0dc66b556c0c215c7ceb8248b717c22951e65305b56a3706e3e86eb01c803bbf915d80edcd64d4d41977fa6f78dc07eecd072aae5bc8a852397e06034dba6a0b570", + "797c3a89b16673c94838d884923b8186ee2db5c98407cab15e13678d072b43e406ad49477c2e45e85e52ca82a94f6df7bbbe7afbed3a3a830029f29090f25217e48d1f42993a640a67916aa7480177354cc7440215ae41e4d02eae9a1912", + "33a6d4922a792c1b7244aa879fefdb4628dc8b0923568869a983b8c661ffab9b2ed2c149e38d41fba090b94155adbed32f8b18142ff0d7de4eeef2b04adf26f2456b46775c6c20b37602df7da179e2332feba8329bbb8d727a138b4ba7a5", + "03215eda2ef1e953d89383a382c11d3f2cad37a4ee59a91236a3e56dcf89f6ac81dd4159989c317bd649d9cbc617f73fe10033bd288c60977481a09b343d3f676070e67da757b86de27bfca74392bac2996f7822a7d8f71a489ec6180390", + "089ea80a8fcd6526413ec6c9a339115f111d78ef21d456660aa85f790910ffa2dc58d6a5b93705caef1091474938bd312427021ad1eeafbd19e0d916ddb111fabd8dcab5ad6a6ec3a9c6973809580cb2c164e26686b5b98cfb017a337968", + "c7daaa14ae5152a067277b1b3902677d979f8e39cc2aafb3bc06fcf69160a853e6869dcc09a11b5009f91e6b89e5b927ab1527a735660faa6012b420dd926d940d742be6a64fb01cdc0cff9faa323f02ba41436871a0eab851e7f5782d10", + "fbefde2a7e9ae9dc1e5c2c48f74f6c824ce9ef3c89f68800d44587bedc4ab417cfb3e7447d90e1e417e6e05d30e87239d3a5d1d45993d4461e60a0192831640aa32dedde185a371ded2ae15f8a93dba8809482ce49225daadfbb0fec629e", + "23880789bdf9ed73be57fa84d555134630e8d0f7df48349f29869a477c13ccca9cd555ac42ad7f568416c3d61959d0ed568b2b81c7771e9088ad7fd55fd4386bafbf5a528c30f107139249357368ffa980de2c76ddd9ce4191376be0e6b5", + "170010067e2e75ebe2d2904aeb1f89d5dc98cd4a6f2faaa8be6d03354c990fd895a97feb54668473e9d942bb99e196d897e8f1b01625cf48a7b78d249bb4985c065aa8cd1402ed2ba1b6f908f63dcd84b66425df00000000000000000000" + ]; + + expect(fragmentsHex, equals(expectedFragments)); + + var rejoinedMessage = + FountainDecoder.joinFragments(fragments, message.length); + expect(message, equals(rejoinedMessage)); + }); + + test('Choose degree', () { + var message = makeMessage(1024); + var fragmentLen = + FountainEncoder.findNominalFragmentLength(message.length, 10, 100); + var fragments = FountainEncoder.partitionMessage(message, fragmentLen); + var degrees = []; + + for (var nonce = 1; nonce <= 200; nonce++) { + var partRng = Xoshiro256.fromString("Wolf-$nonce"); + degrees.add(chooseDegree(fragments.length, partRng)); + } + + var expectedDegrees = [ + 11, + 3, + 6, + 5, + 2, + 1, + 2, + 11, + 1, + 3, + 9, + 10, + 10, + 4, + 2, + 1, + 1, + 2, + 1, + 1, + 5, + 2, + 4, + 10, + 3, + 2, + 1, + 1, + 3, + 11, + 2, + 6, + 2, + 9, + 9, + 2, + 6, + 7, + 2, + 5, + 2, + 4, + 3, + 1, + 6, + 11, + 2, + 11, + 3, + 1, + 6, + 3, + 1, + 4, + 5, + 3, + 6, + 1, + 1, + 3, + 1, + 2, + 2, + 1, + 4, + 5, + 1, + 1, + 9, + 1, + 1, + 6, + 4, + 1, + 5, + 1, + 2, + 2, + 3, + 1, + 1, + 5, + 2, + 6, + 1, + 7, + 11, + 1, + 8, + 1, + 5, + 1, + 1, + 2, + 2, + 6, + 4, + 10, + 1, + 2, + 5, + 5, + 5, + 1, + 1, + 4, + 1, + 1, + 1, + 3, + 5, + 5, + 5, + 1, + 4, + 3, + 3, + 5, + 1, + 11, + 3, + 2, + 8, + 1, + 2, + 1, + 1, + 4, + 5, + 2, + 1, + 1, + 1, + 5, + 6, + 11, + 10, + 7, + 4, + 7, + 1, + 5, + 3, + 1, + 1, + 9, + 1, + 2, + 5, + 5, + 2, + 2, + 3, + 10, + 1, + 3, + 2, + 3, + 3, + 1, + 1, + 2, + 1, + 3, + 2, + 2, + 1, + 3, + 8, + 4, + 1, + 11, + 6, + 3, + 1, + 1, + 1, + 1, + 1, + 3, + 1, + 2, + 1, + 10, + 1, + 1, + 8, + 2, + 7, + 1, + 2, + 1, + 9, + 2, + 10, + 2, + 1, + 3, + 4, + 10 + ]; + expect(degrees, equals(expectedDegrees)); + }); + + test('Choose Fragments', () { + var message = makeMessage(1024); + var checksum = crc32Int(message); + var fragmentLen = + FountainEncoder.findNominalFragmentLength(message.length, 10, 100); + var fragments = FountainEncoder.partitionMessage(message, fragmentLen); + var fragmentIndexes = >[]; + for (var seqNum = 1; seqNum <= 30; seqNum++) { + var indexesSet = chooseFragments(seqNum, fragments.length, checksum); + var indexes = indexesSet.toList()..sort(); + fragmentIndexes.add(indexes); + } + + var expectedFragmentIndexes = [ + [0], + [1], + [2], + [3], + [4], + [5], + [6], + [7], + [8], + [9], + [10], + [9], + [2, 5, 6, 8, 9, 10], + [8], + [1, 5], + [1], + [0, 2, 4, 5, 8, 10], + [5], + [2], + [2], + [0, 1, 3, 4, 5, 7, 9, 10], + [0, 1, 2, 3, 5, 6, 8, 9, 10], + [0, 2, 4, 5, 7, 8, 9, 10], + [3, 5], + [4], + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + [0, 1, 3, 4, 5, 6, 7, 9, 10], + [6], + [5, 6], + [7] + ]; + expect(fragmentIndexes, equals(expectedFragmentIndexes)); + }); + + test('XOR', () { + var rng = Xoshiro256.fromString("Wolf"); + var data1 = rng.nextData(10); + expect(dataToHex(data1), equals("916ec65cf77cadf55cd7")); + var data2 = rng.nextData(10); + expect(dataToHex(data2), equals("f9cda1a1030026ddd42e")); + var data3 = Uint8List.fromList(data1); + xorInto(data3, data2); + expect(dataToHex(data3), equals("68a367fdf47c8b2888f9")); + xorInto(data3, data1); + expect(data3, equals(data2)); + }); + + test('Fountain Encoder', () { + var message = makeMessage(256); + var encoder = FountainEncoder(message, 30); + var parts = []; + for (var i = 0; i < 20; i++) { + parts.add(encoder.nextPart().description()); + } + + var expectedParts = [ + "seqNum:1, seqLen:9, messageLen:256, checksum:23570951, data:916ec65cf77cadf55cd7f9cda1a1030026ddd42e905b77adc36e4f2d3c", + "seqNum:2, seqLen:9, messageLen:256, checksum:23570951, data:cba44f7f04f2de44f42d84c374a0e149136f25b01852545961d55f7f7a", + "seqNum:3, seqLen:9, messageLen:256, checksum:23570951, data:8cde6d0e2ec43f3b2dcb644a2209e8c9e34af5c4747984a5e873c9cf5f", + "seqNum:4, seqLen:9, messageLen:256, checksum:23570951, data:965e25ee29039fdf8ca74f1c769fc07eb7ebaec46e0695aea6cbd60b3e", + "seqNum:5, seqLen:9, messageLen:256, checksum:23570951, data:c4bbff1b9ffe8a9e7240129377b9d3711ed38d412fbb4442256f1e6f59", + "seqNum:6, seqLen:9, messageLen:256, checksum:23570951, data:5e0fc57fed451fb0a0101fb76b1fb1e1b88cfdfdaa946294a47de8fff1", + "seqNum:7, seqLen:9, messageLen:256, checksum:23570951, data:73f021c0e6f65b05c0a494e50791270a0050a73ae69b6725505a2ec8a5", + "seqNum:8, seqLen:9, messageLen:256, checksum:23570951, data:791457c9876dd34aadd192a53aa0dc66b556c0c215c7ceb8248b717c22", + "seqNum:9, seqLen:9, messageLen:256, checksum:23570951, data:951e65305b56a3706e3e86eb01c803bbf915d80edcd64d4d0000000000", + "seqNum:10, seqLen:9, messageLen:256, checksum:23570951, data:330f0f33a05eead4f331df229871bee733b50de71afd2e5a79f196de09", + "seqNum:11, seqLen:9, messageLen:256, checksum:23570951, data:3b205ce5e52d8c24a52cffa34c564fa1af3fdffcd349dc4258ee4ee828", + "seqNum:12, seqLen:9, messageLen:256, checksum:23570951, data:dd7bf725ea6c16d531b5f03254783803048ca08b87148daacd1cd7a006", + "seqNum:13, seqLen:9, messageLen:256, checksum:23570951, data:760be7ad1c6187902bbc04f539b9ee5eb8ea6833222edea36031306c01", + "seqNum:14, seqLen:9, messageLen:256, checksum:23570951, data:5bf4031217d2c3254b088fa7553778b5003632f46e21db129416f65b55", + "seqNum:15, seqLen:9, messageLen:256, checksum:23570951, data:73f021c0e6f65b05c0a494e50791270a0050a73ae69b6725505a2ec8a5", + "seqNum:16, seqLen:9, messageLen:256, checksum:23570951, data:b8546ebfe2048541348910267331c643133f828afec9337c318f71b7df", + "seqNum:17, seqLen:9, messageLen:256, checksum:23570951, data:23dedeea74e3a0fb052befabefa13e2f80e4315c9dceed4c8630612e64", + "seqNum:18, seqLen:9, messageLen:256, checksum:23570951, data:d01a8daee769ce34b6b35d3ca0005302724abddae405bdb419c0a6b208", + "seqNum:19, seqLen:9, messageLen:256, checksum:23570951, data:3171c5dc365766eff25ae47c6f10e7de48cfb8474e050e5fe997a6dc24", + "seqNum:20, seqLen:9, messageLen:256, checksum:23570951, data:e055c2433562184fa71b4be94f262e200f01c6f74c284b0dc6fae6673f" + ]; + expect(parts, equals(expectedParts)); + }); + test('Fountain Encoder CBOR', () { + var message = makeMessage(256); + var encoder = FountainEncoder(message, 30); + var parts = []; + for (var i = 0; i < 20; i++) { + parts.add(dataToHex(encoder.nextPart().cbor())); + } + + var expectedParts = [ + "8501091901001a0167aa07581d916ec65cf77cadf55cd7f9cda1a1030026ddd42e905b77adc36e4f2d3c", + "8502091901001a0167aa07581dcba44f7f04f2de44f42d84c374a0e149136f25b01852545961d55f7f7a", + "8503091901001a0167aa07581d8cde6d0e2ec43f3b2dcb644a2209e8c9e34af5c4747984a5e873c9cf5f", + "8504091901001a0167aa07581d965e25ee29039fdf8ca74f1c769fc07eb7ebaec46e0695aea6cbd60b3e", + "8505091901001a0167aa07581dc4bbff1b9ffe8a9e7240129377b9d3711ed38d412fbb4442256f1e6f59", + "8506091901001a0167aa07581d5e0fc57fed451fb0a0101fb76b1fb1e1b88cfdfdaa946294a47de8fff1", + "8507091901001a0167aa07581d73f021c0e6f65b05c0a494e50791270a0050a73ae69b6725505a2ec8a5", + "8508091901001a0167aa07581d791457c9876dd34aadd192a53aa0dc66b556c0c215c7ceb8248b717c22", + "8509091901001a0167aa07581d951e65305b56a3706e3e86eb01c803bbf915d80edcd64d4d0000000000", + "850a091901001a0167aa07581d330f0f33a05eead4f331df229871bee733b50de71afd2e5a79f196de09", + "850b091901001a0167aa07581d3b205ce5e52d8c24a52cffa34c564fa1af3fdffcd349dc4258ee4ee828", + "850c091901001a0167aa07581ddd7bf725ea6c16d531b5f03254783803048ca08b87148daacd1cd7a006", + "850d091901001a0167aa07581d760be7ad1c6187902bbc04f539b9ee5eb8ea6833222edea36031306c01", + "850e091901001a0167aa07581d5bf4031217d2c3254b088fa7553778b5003632f46e21db129416f65b55", + "850f091901001a0167aa07581d73f021c0e6f65b05c0a494e50791270a0050a73ae69b6725505a2ec8a5", + "8510091901001a0167aa07581db8546ebfe2048541348910267331c643133f828afec9337c318f71b7df", + "8511091901001a0167aa07581d23dedeea74e3a0fb052befabefa13e2f80e4315c9dceed4c8630612e64", + "8512091901001a0167aa07581dd01a8daee769ce34b6b35d3ca0005302724abddae405bdb419c0a6b208", + "8513091901001a0167aa07581d3171c5dc365766eff25ae47c6f10e7de48cfb8474e050e5fe997a6dc24", + "8514091901001a0167aa07581de055c2433562184fa71b4be94f262e200f01c6f74c284b0dc6fae6673f" + ]; + expect(parts, equals(expectedParts)); + }); + + test('Fountain Encoder Is Complete', () { + var message = makeMessage(256); + var encoder = FountainEncoder(message, 30); + var generatedPartsCount = 0; + while (!encoder.isComplete) { + encoder.nextPart(); + generatedPartsCount++; + } + + expect(encoder.seqLen, equals(generatedPartsCount)); + }); + + test('Fountain Decoder', () { + var messageSeed = "Wolf"; + var messageSize = 32767; + var maxFragmentLen = 1000; + + var message = makeMessage(messageSize, seed: messageSeed); + var encoder = FountainEncoder(message, maxFragmentLen, firstSeqNum: 100); + var decoder = FountainDecoder(); + + while (true) { + var part = encoder.nextPart(); + decoder.receivePart(part); + if (decoder.isComplete()) { + break; + } + } + + if (decoder.isSuccess()) { + expect(decoder.resultMessage(), equals(message)); + } else { + fail(decoder.resultError().toString()); + } + }); + + test('Fountain CBOR', () { + var part = FountainEncoderPart( + 12, 8, 100, 0x12345678, Uint8List.fromList([1, 5, 3, 3, 5])); + var cbor = part.cbor(); + var part2 = FountainEncoderPart.fromCbor(cbor); + var cbor2 = part2.cbor(); + expect(cbor, equals(cbor2)); + }); + + test('Single Part UR', () { + var ur = makeMessageUR(50); + var encoded = UREncoder.encode(ur); + var expected = + "ur:bytes/hdeymejtswhhylkepmykhhtsytsnoyoyaxaedsuttydmmhhpktpmsrjtgwdpfnsboxgwlbaawzuefywkdplrsrjynbvygabwjldapfcsdwkbrkch"; + expect(encoded, equals(expected)); + var decoded = URDecoder.decode(encoded); + expect(ur, equals(decoded)); + }); + + test('Short CRC32', () { + var fragment = + "ur:crypto-psbt/20-29/lpbbcscacfcmcpcybbrsptskhdssdtsbtkdechrhpkhkvwmdmnksgdaoaeaeaeaeaechptbbeodkpletlbldamjopmbnpeplwmfzltzthgoeqzteltlgaychaeaeaeaeaechptbbfxsawkidltenbskpjlfeflmnclpkemtpwpmhmhioltkecsamaeaeaeaeaechptbbintkmwzmynknkezcbgkthdfezopaynprmefthpkgltaeaeaeaeadaddnemmdaaaeaeaeaeaecpaecxhdgwfdvtsphdolbnkigeteeclkosoxlpjssnfxsgclahesjsvturdyjzcwsrkndtadahtkghclaofstdlnuysaasiesfdnrkhsmnjztonlpsldwftdmninoxehhnkotodrwpchrorhdaclaeetwdvl"; + + var decoder = URDecoder(); + var status = decoder.receivePart(fragment); + expect(status, isTrue); + }); + + test('UR Encoder', () { + var ur = makeMessageUR(256); + var encoder = UREncoder(ur, 30); + var parts = []; + for (var i = 0; i < 20; i++) { + parts.add(encoder.nextPart()); + } + + var expectedParts = [ + "ur:bytes/1-9/lpadascfadaxcywenbpljkhdcahkadaemejtswhhylkepmykhhtsytsnoyoyaxaedsuttydmmhhpktpmsrjtdkgslpgh", + "ur:bytes/2-9/lpaoascfadaxcywenbpljkhdcagwdpfnsboxgwlbaawzuefywkdplrsrjynbvygabwjldapfcsgmghhkhstlrdcxaefz", + "ur:bytes/3-9/lpaxascfadaxcywenbpljkhdcahelbknlkuejnbadmssfhfrdpsbiegecpasvssovlgeykssjykklronvsjksopdzmol", + "ur:bytes/4-9/lpaaascfadaxcywenbpljkhdcasotkhemthydawydtaxneurlkosgwcekonertkbrlwmplssjtammdplolsbrdzcrtas", + "ur:bytes/5-9/lpahascfadaxcywenbpljkhdcatbbdfmssrkzmcwnezelennjpfzbgmuktrhtejscktelgfpdlrkfyfwdajldejokbwf", + "ur:bytes/6-9/lpamascfadaxcywenbpljkhdcackjlhkhybssklbwefectpfnbbectrljectpavyrolkzczcpkmwidmwoxkilghdsowp", + "ur:bytes/7-9/lpatascfadaxcywenbpljkhdcavszmwnjkwtclrtvaynhpahrtoxmwvwatmedibkaegdosftvandiodagdhthtrlnnhy", + "ur:bytes/8-9/lpayascfadaxcywenbpljkhdcadmsponkkbbhgsoltjntegepmttmoonftnbuoiyrehfrtsabzsttorodklubbuyaetk", + "ur:bytes/9-9/lpasascfadaxcywenbpljkhdcajskecpmdckihdyhphfotjojtfmlnwmadspaxrkytbztpbauotbgtgtaeaevtgavtny", + "ur:bytes/10-9/lpbkascfadaxcywenbpljkhdcahkadaemejtswhhylkepmykhhtsytsnoyoyaxaedsuttydmmhhpktpmsrjtwdkiplzs", + "ur:bytes/11-9/lpbdascfadaxcywenbpljkhdcahelbknlkuejnbadmssfhfrdpsbiegecpasvssovlgeykssjykklronvsjkvetiiapk", + "ur:bytes/12-9/lpbnascfadaxcywenbpljkhdcarllaluzmdmgstospeyiefmwejlwtpedamktksrvlcygmzemovovllarodtmtbnptrs", + "ur:bytes/13-9/lpbtascfadaxcywenbpljkhdcamtkgtpknghchchyketwsvwgwfdhpgmgtylctotzopdrpayoschcmhplffziachrfgd", + "ur:bytes/14-9/lpbaascfadaxcywenbpljkhdcapazewnvonnvdnsbyleynwtnsjkjndeoldydkbkdslgjkbbkortbelomueekgvstegt", + "ur:bytes/15-9/lpbsascfadaxcywenbpljkhdcaynmhpddpzmversbdqdfyrehnqzlugmjzmnmtwmrouohtstgsbsahpawkditkckynwt", + "ur:bytes/16-9/lpbeascfadaxcywenbpljkhdcawygekobamwtlihsnpalnsghenskkiynthdzotsimtojetprsttmukirlrsbtamjtpd", + "ur:bytes/17-9/lpbyascfadaxcywenbpljkhdcamklgftaxykpewyrtqzhydntpnytyisincxmhtbceaykolduortotiaiaiafhiaoyce", + "ur:bytes/18-9/lpbgascfadaxcywenbpljkhdcahkadaemejtswhhylkepmykhhtsytsnoyoyaxaedsuttydmmhhpktpmsrjtntwkbkwy", + "ur:bytes/19-9/lpbwascfadaxcywenbpljkhdcadekicpaajootjzpsdrbalpeywllbdsnbinaerkurspbncxgslgftvtsrjtksplcpeo", + "ur:bytes/20-9/lpbbascfadaxcywenbpljkhdcayapmrleeleaxpasfrtrdkncffwjyjzgyetdmlewtkpktgllepfrltataztksmhkbot" + ]; + expect(parts, equals(expectedParts)); + }); + + test('Multipart UR', () { + var ur = makeMessageUR(32767); + var maxFragmentLen = 1000; + var firstSeqNum = 100; + var encoder = UREncoder(ur, maxFragmentLen, firstSeqNum: firstSeqNum); + var decoder = URDecoder(); + while (true) { + var part = encoder.nextPart(); + decoder.receivePart(part); + if (decoder.isComplete()) { + break; + } + } + + if (decoder.isSuccess()) { + expect(decoder.result, equals(ur)); + } else { + fail(decoder.resultError().toString()); + } + }); + + test('UR Encode json', () { + var sourceJson = { + "int": 123, + "bool": true, + "str": "hello", + "list": [1, 2, 3], + "map": {"a": 1, "b": 2}, + "null": null + }; + var sourceBytes = utf8.encode(json.encode(sourceJson)); + var cborEncoder = CBOREncoder(); + cborEncoder.encodeBytes(sourceBytes); + var ur = UR("bytes", cborEncoder.getBytes()); + var encoded = UREncoder.encode(ur); + expect( + encoded, + equals( + 'ur:bytes/hdghkgcpinjtjycpfteheyeodwcpidjljljzcpftjyjpkpihdwcpjkjyjpcpftcpisihjzjzjlcpdwcpjzinjkjycpfthpehdweydweohldwcpjnhsjocpftkgcphscpftehdwcpidcpfteykidwcpjtkpjzjzcpftjtkpjzjzkidndrpmhe')); + }); + + test('UR Decode json', () { + var source = + 'ur:bytes/hdghkgcpinjtjycpfteheyeodwcpidjljljzcpftjyjpkpihdwcpjkjyjpcpftcpisihjzjzjlcpdwcpjzinjkjycpfthpehdweydweohldwcpjnhsjocpftkgcphscpftehdwcpidcpfteykidwcpjtkpjzjzcpftjtkpjzjzkidndrpmhe'; + var ur = URDecoder.decode(source); + var cborDecorder = CBORDecoder(ur.cbor); + var (bytes, length) = cborDecorder.decodeBytes(); + var decoded = utf8.decode(bytes); + expect( + json.decode(decoded), + equals({ + "int": 123, + "bool": true, + "str": "hello", + "list": [1, 2, 3], + "map": {"a": 1, "b": 2}, + "null": null + })); + }); + }); +} + +bool checkCRC32(String input, String expectedHex) { + int checksum = crc32Int(Uint8List.fromList(input.codeUnits)); + String hex = checksum.toRadixString(16).padLeft(8, '0'); + return hex == expectedHex; +} diff --git a/packages/isar/pubspec.lock b/packages/isar/pubspec.lock index 589962aeb..619016d71 100644 --- a/packages/isar/pubspec.lock +++ b/packages/isar/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.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.1" + bs58check: + dependency: transitive + description: + name: bs58check + sha256: c4a164d42b25c2f6bc88a8beccb9fc7d01440f3c60ba23663a20a70faf484ea9 + url: "https://pub.dev" + source: hosted + version: "1.0.2" build: dependency: transitive description: @@ -121,6 +154,22 @@ packages: url: "https://pub.dev" source: hosted version: "8.9.2" + cbor: + dependency: transitive + description: + name: cbor + sha256: "259230d0c7f3ae58cb68cbc17b95484a038b2f63b15963b019d4bd9d28bf3fe0" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + 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: transitive description: @@ -359,14 +416,14 @@ packages: path: "../ndk" relative: true source: path - version: "0.6.1-dev.7" + version: "0.7.1-dev.2" ndk_cache_manager_test_suite: dependency: "direct dev" description: path: "../ndk_cache_manager_test_suite" relative: true source: path - version: "1.0.0-dev.2" + version: "1.0.1-dev.2" node_preamble: dependency: transitive description: @@ -423,6 +480,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" rxdart: dependency: transitive description: @@ -575,6 +640,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unorm_dart: + dependency: transitive + description: + name: unorm_dart + sha256: "0c69186b03ca6addab0774bcc0f4f17b88d4ce78d9d4d8f0619e30a99ead58e7" + url: "https://pub.dev" + source: hosted + version: "0.3.2" vm_service: dependency: transitive description: @@ -648,4 +721,4 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.6.0-0 <4.0.0" + dart: ">=3.7.0 <4.0.0" diff --git a/packages/ndk/docs/NUT-16-ANIMATED-QR.md b/packages/ndk/docs/NUT-16-ANIMATED-QR.md new file mode 100644 index 000000000..ac4c31ceb --- /dev/null +++ b/packages/ndk/docs/NUT-16-ANIMATED-QR.md @@ -0,0 +1,154 @@ +# NUT-16: Animated QR Codes for Cashu Tokens + +This document describes the implementation of NUT-16 (Animated QR codes) in the NDK library using the UR (Uniform Resources) protocol. + +## Overview + +The `CashuTokenUrEncoder` class provides encoding and decoding functionality for Cashu tokens using the UR protocol, enabling: +- **Static QR codes** for small tokens (≤2 proofs) +- **Animated QR codes** for larger tokens that don't fit in a single QR code + +## Implementation + +### Library Used +- **bc-ur-dart**: Dart implementation of the UR protocol from [bukata-sa/bc-ur-dart](https://github.com/bukata-sa/bc-ur-dart) +- Based on the [UR specification](https://developer.blockchaincommons.com/ur/) by Blockchain Commons + +### How It Works + +1. **Encoding**: + - Cashu token is serialized to CBOR (same as V4 tokens) + - CBOR bytes are encoded using UR protocol + - For large tokens, data is split into fountain-encoded parts + +2. **Decoding**: + - UR strings are decoded back to CBOR + - CBOR is deserialized to Cashu token + - Multi-part decoding supports out-of-order reception + +## Usage + +### Single-Part UR (Static QR Code) + +```dart +import 'package:ndk/domain_layer/usecases/cashu/cashu_token_ur_encoder.dart'; + +// Encode token to UR +final urString = CashuTokenUrEncoder.encodeSinglePart(token: token); +// Returns: "ur:bytes/..." + +// Display as QR code... + +// Decode scanned UR +final decodedToken = CashuTokenUrEncoder.decodeSinglePart(urString); +``` + +### Multi-Part UR (Animated QR Code) + +**Sender (Display animated QR):** +```dart +// Create encoder +final encoder = CashuTokenUrEncoder.createMultiPartEncoder( + token: token, + maxFragmentLen: 100, // Adjust based on QR capacity +); + +// Generate parts and display as animated QR +while (!encoder.isComplete) { + final part = encoder.nextPart(); + // Display 'part' as QR code frame + // Wait 200-500ms before next frame +} +``` + +**Receiver (Scan animated QR):** +```dart +// Create decoder +final decoder = CashuTokenUrEncoder.createMultiPartDecoder(); + +// Feed scanned parts +while (!decoder.isComplete()) { + final scannedPart = scanQRCode(); // Your QR scanning logic + decoder.receivePart(scannedPart); + + // Optional: Show progress + final progress = decoder.estimatedPercentComplete(); + print('${(progress * 100).toFixed(1)}% complete'); +} + +// Decode complete token +final token = CashuTokenUrEncoder.decodeFromMultiPartDecoder(decoder); +``` + +## API Reference + +### `CashuTokenUrEncoder` + +#### Static Methods + +**`encodeSinglePart({required CashuToken token})`** +- Encodes a token to a single UR string +- Returns: `String` (e.g., "ur:bytes/...") +- Use for small tokens that fit in one QR code + +**`decodeSinglePart(String urString)`** +- Decodes a single UR string back to a token +- Returns: `CashuToken?` (null if invalid) + +**`createMultiPartEncoder({required CashuToken token, int maxFragmentLen = 100, ...})`** +- Creates an encoder for animated QR codes +- `maxFragmentLen`: Maximum bytes per fragment (default: 100) +- Returns: `UREncoder` instance +- Call `nextPart()` repeatedly to generate QR frames + +**`createMultiPartDecoder()`** +- Creates a decoder for animated QR codes +- Returns: `URDecoder` instance +- Call `receivePart(String)` for each scanned frame + +**`decodeFromMultiPartDecoder(URDecoder decoder)`** +- Extracts the complete token from a decoder +- Returns: `CashuToken?` (null if not complete or invalid) +- Only call when `decoder.isComplete()` is true + +### UREncoder Methods +- `nextPart()`: Get next UR part to display +- `isComplete`: Check if all parts have been generated +- `isSinglePart`: Check if token fits in single part + +### URDecoder Methods +- `receivePart(String)`: Feed a scanned UR part +- `isComplete()`: Check if all parts received +- `isSuccess()`: Check if decoding succeeded +- `estimatedPercentComplete()`: Get progress (0.0-1.0) + +## Features + +✅ **Fountain Encoding**: Parts can be received in any order +✅ **Progress Tracking**: Know how much of the token is scanned +✅ **Error Resilience**: Handles missed or duplicate frames +✅ **Standard Compliant**: Uses standard UR protocol +✅ **CBOR Encoding**: Compatible with existing V4 tokens + +## Example + +See `example/cashu_animated_qr_example.dart` for a complete working example demonstrating both single-part and multi-part encoding/decoding. + +## Specification + +This implements [NUT-16](https://github.com/cashubtc/nuts/blob/main/16.md) from the Cashu specification. + +## Testing + +Comprehensive test suite in `test/cashu/cashu_token_ur_encoder_test.dart` covering: +- Single-part encoding/decoding +- Multi-part encoding/decoding +- Progress tracking +- Out-of-order reception +- Edge cases (empty tokens, unicode, long memos) +- Error handling + +Run tests: +```bash +dart test test/cashu/cashu_token_ur_encoder_test.dart +``` 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..248f776dd --- /dev/null +++ b/packages/ndk/lib/config/cashu_config.dart @@ -0,0 +1,13 @@ +// 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); + + /// Timeout for network requests to mint - fails fast if mint is offline + static const Duration NETWORK_TIMEOUT = Duration(seconds: 10); +} 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..d5d237871 100644 --- a/packages/ndk/lib/data_layer/data_sources/http_request.dart +++ b/packages/ndk/lib/data_layer/data_sources/http_request.dart @@ -18,7 +18,7 @@ class HttpRequestDS { if (response.statusCode != 200) { return throw Exception( - "error fetching STATUS: ${response.statusCode}, Link: $url"); + "error fetching STATUS: ${response.statusCode}, ${response.body},Link: $url"); } return jsonDecode(response.body); } @@ -36,15 +36,18 @@ class HttpRequestDS { if (response.statusCode != 200) { throw Exception( - "error fetching STATUS: ${response.statusCode}, Link: $url"); + "error fetching STATUS: ${response.statusCode}, ${response.body}, Link: $url"); } return response; } + /// Future post({ required Uri url, - required Uint8List body, + + /// String, Uint8List + required Object body, required headers, }) async { http.Response response = await _client.post( @@ -55,7 +58,7 @@ class HttpRequestDS { if (response.statusCode != 200) { throw Exception( - "error fetching STATUS: ${response.statusCode}, Link: $url"); + "error fetching STATUS: ${response.statusCode}, ${response.body}, Link: $url, "); } return response; @@ -72,7 +75,7 @@ class HttpRequestDS { if (response.statusCode != 200) { throw Exception( - "error fetching STATUS: ${response.statusCode}, Link: $url"); + "error fetching STATUS: ${response.statusCode}, ${response.body}, Link: $url"); } return response; @@ -89,7 +92,7 @@ class HttpRequestDS { if (response.statusCode != 200) { throw Exception( - "error fetching STATUS: ${response.statusCode}, Link: $url"); + "error fetching STATUS: ${response.statusCode}, ${response.body}, Link: $url"); } return response; @@ -106,7 +109,7 @@ class HttpRequestDS { if (response.statusCode != 200) { throw Exception( - "error fetching STATUS: ${response.statusCode}, Link: $url"); + "error fetching STATUS: ${response.statusCode}, ${response.body}, Link: $url"); } return response; 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 d006b3fb2..39a7775b6 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,5 +1,8 @@ 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/filter_fetched_ranges.dart'; import '../../../domain_layer/entities/nip_01_event.dart'; @@ -7,6 +10,9 @@ 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 @@ -31,6 +37,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 = {}; + /// In memory storage for filter fetched range records /// Key is filterHash:relayUrl:rangeStart Map filterFetchedRangeRecords = {}; @@ -252,7 +275,9 @@ class MemCacheManager implements CacheManager { continue; } // Filter by pubKeys - if (pubKeys != null && pubKeys.isNotEmpty && !pubKeys.contains(event.pubKey)) { + if (pubKeys != null && + pubKeys.isNotEmpty && + !pubKeys.contains(event.pubKey)) { continue; } // Filter by kinds @@ -343,6 +368,15 @@ class MemCacheManager implements CacheManager { 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()); + } + } + // ===================== // Filter Fetched Ranges // ===================== @@ -361,6 +395,189 @@ class MemCacheManager implements CacheManager { } } + @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; + } + @override Future> loadFilterFetchedRangeRecords( String filterHash) async { 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..e254ecb74 --- /dev/null +++ b/packages/ndk/lib/data_layer/repositories/cashu/cashu_repo_impl.dart @@ -0,0 +1,514 @@ +import 'dart:convert'; + +import '../../../config/cashu_config.dart'; +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, + ) + .timeout( + CashuConfig.NETWORK_TIMEOUT, + onTimeout: () => throw Exception( + 'Network timeout: Unable to reach mint at $mintUrl. The mint may be offline.', + ), + ); + + 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, + ) + .timeout( + CashuConfig.NETWORK_TIMEOUT, + onTimeout: () => throw Exception( + 'Network timeout: Unable to reach mint at $mintUrl. The mint may be offline.', + ), + ); + + 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, + ) + .timeout( + CashuConfig.NETWORK_TIMEOUT, + onTimeout: () => throw Exception( + 'Network timeout: Unable to reach mint at $mintUrl. The mint may be offline.', + ), + ); + 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, + ) + .timeout( + CashuConfig.NETWORK_TIMEOUT, + onTimeout: () => throw Exception( + 'Network timeout: Unable to reach mint at $mintUrl. The mint may be offline.', + ), + ); + + 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, + ) + .timeout( + CashuConfig.NETWORK_TIMEOUT, + onTimeout: () => throw Exception( + 'Network timeout: Unable to reach mint at $mintUrl. The mint may be offline.', + ), + ); + + 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, + ) + .timeout( + CashuConfig.NETWORK_TIMEOUT, + onTimeout: () => throw Exception( + 'Network timeout: Unable to reach mint at $mintUrl. The mint may be offline.', + ), + ); + 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(); + } + + @override + Future<(List, List)> restore({ + required String mintUrl, + required List outputs, + }) async { + final url = CashuTools.composeUrl(mintUrl: mintUrl, path: 'restore'); + + final body = { + '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 restoring from mint: ${response.statusCode}, ${response.body}', + ); + } + + final responseBody = jsonDecode(response.body); + if (responseBody is! Map) { + throw Exception('Invalid response format: $responseBody'); + } + + // Parse outputs if present (NUT-09 spec includes them) + final List outputsUnparsed = responseBody['outputs'] ?? []; + final List restoredOutputs = outputsUnparsed + .map((e) => CashuBlindedMessage.fromServerMap(e)) + .toList(); + + // Parse signatures + final List signaturesUnparsed = responseBody['signatures'] ?? []; + final List signatures = signaturesUnparsed + .map((e) => CashuBlindedSignature.fromServerMap(e)) + .toList(); + + return (restoredOutputs, signatures); + } +} diff --git a/packages/ndk/lib/data_layer/repositories/cashu_seed_secret_generator/dart_cashu_key_derivation.dart b/packages/ndk/lib/data_layer/repositories/cashu_seed_secret_generator/dart_cashu_key_derivation.dart new file mode 100644 index 000000000..9b0eed9a2 --- /dev/null +++ b/packages/ndk/lib/data_layer/repositories/cashu_seed_secret_generator/dart_cashu_key_derivation.dart @@ -0,0 +1,228 @@ +import 'dart:typed_data'; +import 'dart:convert'; +import 'package:crypto/crypto.dart'; +import 'package:bip32_keys/bip32_keys.dart'; +import 'package:convert/convert.dart'; + +import '../../../domain_layer/repositories/cashu_key_derivation.dart'; +import '../../../domain_layer/usecases/cashu/cashu_seed.dart'; + +enum DerivationType { + secret(0), + blindingFactor(1); + + final int value; + const DerivationType(this.value); +} + +class DartCashuKeyDerivation implements CashuKeyDerivation { + static const int derivationPurpose = 129372; + static const int derivationCoinType = 0; + + static final BigInt secp256k1N = BigInt.parse( + 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141', + radix: 16, + ); + + DartCashuKeyDerivation(); + + @override + Future deriveSecret({ + required Uint8List seedBytes, + required int counter, + required String keysetId, + }) async { + // Validate keysetId format + final isValidHex = RegExp(r'^[a-fA-F0-9]+$').hasMatch(keysetId); + if (!isValidHex) { + throw Exception('Keyset ID must be valid hex'); + } + + // Choose derivation method based on keyset version + if (keysetId.startsWith('00')) { + return _deriveDeprecatedWithSeed( + seed: seedBytes, keysetId: keysetId, counter: counter); + } else if (keysetId.startsWith('01')) { + return _deriveModernWithSeed( + seed: seedBytes, keysetId: keysetId, counter: counter); + } + + throw Exception( + 'Unrecognized keyset ID version ${keysetId.substring(0, 2)}'); + } + + /// Modern derivation method with explicit seed parameter + static CashuSeedDeriveSecretResult _deriveModernWithSeed({ + required Uint8List seed, + required String keysetId, + required int counter, + }) { + final secret = _deriveV01WithSeed( + seed: seed, + keysetId: keysetId, + counter: counter, + derivationType: DerivationType.secret, + ); + + final blinding = _deriveV01WithSeed( + seed: seed, + keysetId: keysetId, + counter: counter, + derivationType: DerivationType.blindingFactor, + ); + + return CashuSeedDeriveSecretResult( + secretHex: hex.encode(secret), + blindingHex: hex.encode(blinding), + ); + } + + /// Modern derivation method with explicit seed parameter + static Uint8List _deriveV01WithSeed({ + required Uint8List seed, + required String keysetId, + required int counter, + required DerivationType derivationType, + }) { + // Build message: "Cashu_KDF_HMAC_SHA256" || keysetId || counter || type + final messageBuilder = BytesBuilder(); + + // Add domain separator + messageBuilder.add(utf8.encode('Cashu_KDF_HMAC_SHA256')); + + // Add keyset ID (hex to bytes) + messageBuilder.add(_hexToBytes(keysetId)); + + // Add counter as big-endian 64-bit integer + messageBuilder.add(_bigUint64BE(counter)); + + // Add derivation type + switch (derivationType) { + case DerivationType.secret: + messageBuilder.add([0x00]); + break; + case DerivationType.blindingFactor: + messageBuilder.add([0x01]); + break; + } + + final message = messageBuilder.toBytes(); + + // Compute HMAC-SHA256 + final hmacSha256 = Hmac(sha256, seed); + final hmacDigest = Uint8List.fromList(hmacSha256.convert(message).bytes); + + // For blinding factor, ensure it's a valid secp256k1 scalar + if (derivationType == DerivationType.blindingFactor) { + final x = _bytesToBigInt(hmacDigest); + + // Optimization: single subtraction instead of modulo + // Probability of HMAC >= SECP256K1_N is ~2^-128 + if (x >= secp256k1N) { + return _bigIntToBytes(x - secp256k1N); + } + + if (x == BigInt.zero) { + throw Exception('Derived invalid blinding scalar r == 0'); + } + } + + return hmacDigest; + } + + /// Deprecated BIP32-based derivation with explicit seed parameter + static CashuSeedDeriveSecretResult _deriveDeprecatedWithSeed({ + required Uint8List seed, + required String keysetId, + required int counter, + }) { + final masterKey = Bip32Keys.fromSeed(seed); + + final keysetIdInt = _keysetIdToIntStatic(keysetId); + + // Derive shared parent path once + final sharedParent = masterKey.derivePath( + "m/$derivationPurpose'/$derivationCoinType'/$keysetIdInt'/$counter'", + ); + + // Then derive final step separately + final pathKeySecret = sharedParent.derivePath("0"); + final pathKeyBlinding = sharedParent.derivePath("1"); + + final pathKeySecretHex = hex.encode(pathKeySecret.private!.toList()); + final pathKeyBlindingHex = hex.encode(pathKeyBlinding.private!.toList()); + + return CashuSeedDeriveSecretResult( + secretHex: pathKeySecretHex, + blindingHex: pathKeyBlindingHex, + ); + } + + static int _keysetIdToIntStatic(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(); + } + + /// Convert hex string to bytes + static Uint8List _hexToBytes(String hexString) { + return Uint8List.fromList(hex.decode(hexString)); + } + + /// Convert bytes to BigInt (big-endian) + static BigInt _bytesToBigInt(Uint8List bytes) { + BigInt result = BigInt.zero; + for (int i = 0; i < bytes.length; i++) { + result = (result << 8) | BigInt.from(bytes[i]); + } + return result; + } + + /// Convert BigInt to bytes (big-endian, 32 bytes) + static Uint8List _bigIntToBytes(BigInt value) { + final result = []; + var temp = value; + + while (temp > BigInt.zero) { + result.insert(0, (temp & BigInt.from(0xff)).toInt()); + temp = temp >> 8; + } + + // Pad to 32 bytes + while (result.length < 32) { + result.insert(0, 0); + } + + return Uint8List.fromList(result); + } + + /// Convert integer to big-endian 64-bit bytes - web-compatible + static Uint8List _bigUint64BE(int value) { + final buffer = Uint8List(8); + + // Manually split into high and low 32-bit parts + // This works on both VM and Web + final high = (value ~/ 0x100000000) & 0xFFFFFFFF; + final low = value & 0xFFFFFFFF; + + // Write high 32 bits (big-endian) + buffer[0] = (high >> 24) & 0xff; + buffer[1] = (high >> 16) & 0xff; + buffer[2] = (high >> 8) & 0xff; + buffer[3] = high & 0xff; + + // Write low 32 bits (big-endian) + buffer[4] = (low >> 24) & 0xff; + buffer[5] = (low >> 16) & 0xff; + buffer[6] = (low >> 8) & 0xff; + buffer[7] = low & 0xff; + + return buffer; + } +} diff --git a/packages/ndk/lib/data_layer/repositories/cashu_seed_secret_generator/fake_cashu_seed_generator.dart b/packages/ndk/lib/data_layer/repositories/cashu_seed_secret_generator/fake_cashu_seed_generator.dart new file mode 100644 index 000000000..cbe8680c8 --- /dev/null +++ b/packages/ndk/lib/data_layer/repositories/cashu_seed_secret_generator/fake_cashu_seed_generator.dart @@ -0,0 +1,25 @@ +import 'dart:typed_data'; + +import 'package:ndk/domain_layer/usecases/cashu/cashu_seed.dart'; + +import '../../../domain_layer/repositories/cashu_key_derivation.dart'; + +class FakeCashuSeedGenerator implements CashuKeyDerivation { + @override + Future deriveSecret({ + required Uint8List seedBytes, + required int counter, + required String keysetId, + }) { + // Generate fake secret and blinding values based on the counter + final fakeSecretHex = + 'deadbeef${counter.toRadixString(16).padLeft(24, '0')}'; + final fakeBlindingHex = + 'cafebabe${counter.toRadixString(16).padLeft(24, '0')}'; + + return Future.value(CashuSeedDeriveSecretResult( + secretHex: fakeSecretHex, + blindingHex: fakeBlindingHex, + )); + } +} diff --git a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport.dart b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport.dart index 01bd62c04..216e535ae 100644 --- a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport.dart +++ b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport.dart @@ -19,7 +19,8 @@ class WebSocketClientNostrTransport implements NostrTransport { /// Creates a new WebSocketNostrTransport instance. /// /// [_websocketDS] is the WebSocket data source to be used for communication. - WebSocketClientNostrTransport(this._websocketDS, {Function? onReconnect, Function(int?,Object?,String?)? onDisconnect}) { + WebSocketClientNostrTransport(this._websocketDS, + {Function? onReconnect, Function(int?, Object?, String?)? onDisconnect}) { Completer completer = Completer(); ready = completer.future; _stateStreamSubscription = _websocketDS.ws.connection.listen((state) { diff --git a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart index 418d6eb79..4c0cc36d3 100644 --- a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart +++ b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_client_nostr_transport_factory.dart @@ -7,7 +7,8 @@ import '../../data_sources/websocket_client.dart'; class WebSocketClientNostrTransportFactory implements NostrTransportFactory { @override - NostrTransport call(String url, {Function? onReconnect, Function(int?,Object?,String?)? onDisconnect}) { + NostrTransport call(String url, + {Function? onReconnect, Function(int?, Object?, String?)? onDisconnect}) { final myUrl = cleanRelayUrl(url); if (myUrl == null) { @@ -16,15 +17,14 @@ class WebSocketClientNostrTransportFactory implements NostrTransportFactory { final backoff = BinaryExponentialBackoff( initial: Duration(milliseconds: 500), maximumStep: 4); - final client = WebSocket( - Uri.parse(myUrl), + final client = WebSocket(Uri.parse(myUrl), backoff: backoff, timeout: Duration(seconds: 3600), pingInterval: Duration(seconds: 10), - binaryType: 'arraybuffer' - ); + binaryType: 'arraybuffer'); final WebsocketDSClient myDataSource = WebsocketDSClient(client, myUrl); - return WebSocketClientNostrTransport(myDataSource, onReconnect: onReconnect, onDisconnect: onDisconnect); + return WebSocketClientNostrTransport(myDataSource, + onReconnect: onReconnect, onDisconnect: onDisconnect); } } diff --git a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_nostr_transport_factory.dart b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_nostr_transport_factory.dart index 073b3d00b..baca32e03 100644 --- a/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_nostr_transport_factory.dart +++ b/packages/ndk/lib/data_layer/repositories/nostr_transport/websocket_nostr_transport_factory.dart @@ -7,7 +7,8 @@ import 'websocket_nostr_transport.dart'; class WebSocketNostrTransportFactory implements NostrTransportFactory { @override - NostrTransport call(String url, {Function? onReconnect, Function? onDisconnect}) { + NostrTransport call(String url, + {Function? onReconnect, Function? onDisconnect}) { final myUrl = cleanRelayUrl(url); if (myUrl == null) { diff --git a/packages/ndk/lib/data_layer/repositories/verifiers/bip340_event_verifier.dart b/packages/ndk/lib/data_layer/repositories/verifiers/bip340_event_verifier.dart index ee8ed62e7..772368216 100644 --- a/packages/ndk/lib/data_layer/repositories/verifiers/bip340_event_verifier.dart +++ b/packages/ndk/lib/data_layer/repositories/verifiers/bip340_event_verifier.dart @@ -18,10 +18,11 @@ class Bip340EventVerifier implements EventVerifier { return false; } if (!Nip01Utils.isIdValid(event)) return false; - return useIsolate? await IsolateManager.instance.runInComputeIsolate((event) { - return bip340.verify(event.pubKey, event.id, event.sig!); - }, - event - ) : bip340.verify(event.pubKey, event.id, event.sig!); + return useIsolate + ? await IsolateManager.instance.runInComputeIsolate( + (event) { + return bip340.verify(event.pubKey, event.id, event.sig!); + }, event) + : bip340.verify(event.pubKey, event.id, event.sig!); } } 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..d0a896c4c --- /dev/null +++ b/packages/ndk/lib/data_layer/repositories/wallets/wallets_repo_impl.dart @@ -0,0 +1,287 @@ +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..c611a0e5c --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_blinded_message.dart @@ -0,0 +1,50 @@ +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: json['amount'] is int + ? json['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_restore_result.dart b/packages/ndk/lib/domain_layer/entities/cashu/cashu_restore_result.dart new file mode 100644 index 000000000..f1c5bdf08 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/cashu/cashu_restore_result.dart @@ -0,0 +1,24 @@ +import 'cashu_proof.dart'; + +class CashuRestoreResult { + final List keysetResults; + final int totalProofsRestored; + + CashuRestoreResult({ + required this.keysetResults, + required this.totalProofsRestored, + }); +} + +/// Result of a restore operation for a single keyset +class CashuRestoreKeysetResult { + final String keysetId; + final List restoredProofs; + final int lastUsedCounter; + + CashuRestoreKeysetResult({ + required this.keysetId, + required this.restoredProofs, + required this.lastUsedCounter, + }); +} 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/nip_01_event.dart b/packages/ndk/lib/domain_layer/entities/nip_01_event.dart index ce07f2b04..da1a22121 100644 --- a/packages/ndk/lib/domain_layer/entities/nip_01_event.dart +++ b/packages/ndk/lib/domain_layer/entities/nip_01_event.dart @@ -54,13 +54,13 @@ class Nip01Event { this.createdAt = (createdAt == 0) ? DateTime.now().millisecondsSinceEpoch ~/ 1000 : createdAt; - this.id = id ?? Nip01Utils.calculateEventIdSync( - pubKey: pubKey, - createdAt: createdAt, - kind: kind, - tags: tags, - content: content - ); + this.id = id ?? + Nip01Utils.calculateEventIdSync( + pubKey: pubKey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content); } Nip01Event copyWith({ 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..a73983bed --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart @@ -0,0 +1,257 @@ +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: metadata['proofPubKeys'] != null + ? (metadata['proofPubKeys'] as List) + .map((p) => p.toString()) + .toList() + : 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 3a2111f3c..80025ec44 100644 --- a/packages/ndk/lib/domain_layer/repositories/cache_manager.dart +++ b/packages/ndk/lib/domain_layer/repositories/cache_manager.dart @@ -1,3 +1,6 @@ +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/filter_fetched_ranges.dart'; import '../entities/nip_01_event.dart'; @@ -5,6 +8,9 @@ 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 \ @@ -14,6 +20,7 @@ abstract class CacheManager { Future saveEvent(Nip01Event event); Future saveEvents(List events); Future loadEvent(String id); + /// Load events from cache with flexible filtering \ /// [ids] - list of event ids \ /// [pubKeys] - list of authors pubKeys \ @@ -94,6 +101,73 @@ abstract class CacheManager { 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, + }); // ===================== // Filter Fetched Ranges // ===================== @@ -114,8 +188,8 @@ abstract class CacheManager { String filterHash, String relayUrl); /// Load all fetched range records for a relay (all filters) - Future> loadFilterFetchedRangeRecordsByRelayUrl( - String relayUrl); + Future> + loadFilterFetchedRangeRecordsByRelayUrl(String relayUrl); /// Remove all fetched range records for a filter hash Future removeFilterFetchedRangeRecords(String filterHash); diff --git a/packages/ndk/lib/domain_layer/repositories/cashu_key_derivation.dart b/packages/ndk/lib/domain_layer/repositories/cashu_key_derivation.dart new file mode 100644 index 000000000..eba80e50f --- /dev/null +++ b/packages/ndk/lib/domain_layer/repositories/cashu_key_derivation.dart @@ -0,0 +1,13 @@ +import 'dart:typed_data'; + +import 'package:bip39_mnemonic/bip39_mnemonic.dart'; + +import '../usecases/cashu/cashu_seed.dart'; + +abstract class CashuKeyDerivation { + Future deriveSecret({ + required Uint8List seedBytes, + required int counter, + required String keysetId, + }); +} 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..424e8fc36 --- /dev/null +++ b/packages/ndk/lib/domain_layer/repositories/cashu_repo.dart @@ -0,0 +1,103 @@ +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, + }); + + /// [mintUrl] is the URL of the mint \ + /// [outputs] is a list of [CashuBlindedMessage] to restore \ + /// Returns a tuple of (outputs, signatures) - the matched blinded messages and their signatures. + /// According to NUT-09, the response includes both outputs (with amounts filled in) and signatures. + /// This endpoint is used for wallet restoration (NUT-09). + Future<(List, List)> restore({ + required String mintUrl, + required List outputs, + }); +} diff --git a/packages/ndk/lib/domain_layer/repositories/nostr_transport.dart b/packages/ndk/lib/domain_layer/repositories/nostr_transport.dart index 8c68ee947..488980921 100644 --- a/packages/ndk/lib/domain_layer/repositories/nostr_transport.dart +++ b/packages/ndk/lib/domain_layer/repositories/nostr_transport.dart @@ -17,5 +17,6 @@ abstract class NostrTransport { } abstract class NostrTransportFactory { - NostrTransport call(String url, {Function? onReconnect, Function(int?, Object?, String?)? onDisconnect} ); + NostrTransport call(String url, + {Function? onReconnect, Function(int?, Object?, String?)? onDisconnect}); } 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..7e9045056 --- /dev/null +++ b/packages/ndk/lib/domain_layer/repositories/wallets_repo.dart @@ -0,0 +1,24 @@ +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..6b0934a8d --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart @@ -0,0 +1,1365 @@ +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_restore_result.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_key_derivation.dart'; +import '../../repositories/cashu_repo.dart'; + +import 'cashu_bdhke.dart'; +import 'cashu_cache_decorator.dart'; +import 'cashu_keysets.dart'; +import 'cashu_restore.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; + + final CashuKeyDerivation _cashuKeyDerivation; + + Cashu({ + required CashuRepo cashuRepo, + required CacheManager cacheManager, + required CashuKeyDerivation cashuKeyDerivation, + CashuUserSeedphrase? cashuUserSeedphrase, + }) : _cashuRepo = cashuRepo, + _cacheManager = cacheManager, + _cashuKeyDerivation = cashuKeyDerivation { + _cashuKeysets = CashuKeysets( + cashuRepo: _cashuRepo, + cacheManager: _cacheManager, + ); + _cashuWalletProofSelect = CashuProofSelect( + cashuRepo: _cashuRepo, + cashuSeedSecretGenerator: _cashuKeyDerivation, + ); + _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 = {}; + BehaviorSubject>? _pendingTransactionsSubject; + + /// 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, + ); + } + + /// Get the cashu seed instance + CashuSeed getCashuSeed() { + return _cashuSeed; + } + + /// Restores proofs from a mint using the wallet's seed phrase. + /// + /// This implements NUT-09 (Restore) using NUT-13 (Deterministic Secrets). + /// It will scan the mint for proofs that belong to this wallet's seed. + /// + /// [mintUrl] - The URL of the mint to restore from + /// [unit] - The unit to restore proofs for (default: 'sat') + /// [startCounter] - The counter to start scanning from (default: 0) + /// [batchSize] - How many secrets to check in each batch (default: 100) + /// [gapLimit] - How many consecutive empty batches before stopping (default: 2) + /// + /// Yields [CashuRestoreResult] updates as proofs are discovered during scanning. + /// The final yielded result contains all restored proofs. + Stream restore({ + required String mintUrl, + String unit = 'sat', + int startCounter = 0, + int batchSize = 100, + int gapLimit = 2, + }) async* { + Logger.log.i('Starting restore from $mintUrl'); + + // Get keysets for this mint and unit + final allKeysets = await _cashuKeysets.getKeysetsFromMint(mintUrl); + final keysets = allKeysets.where((keyset) => keyset.unit == unit).toList(); + + if (keysets.isEmpty) { + throw Exception('No keysets found for mint $mintUrl with unit $unit'); + } + + Logger.log.i('Found ${keysets.length} keysets for unit $unit'); + + // Create restore instance + final cashuRestore = CashuRestore( + cashuRepo: _cashuRepo, + cashuKeyDerivation: _cashuKeyDerivation, + cacheManager: _cacheManagerCashu, + cashuSeed: _cashuSeed, + ); + + // Restore all keysets and yield progress + await for (final result in cashuRestore.restoreAllKeysets( + mintUrl: mintUrl, + keysets: keysets, + startCounter: startCounter, + batchSize: batchSize, + gapLimit: gapLimit, + )) { + // Save restored proofs incrementally as they're discovered + final newProofs = result.keysetResults + .expand((keysetResult) => keysetResult.restoredProofs) + .toList(); + + if (newProofs.isNotEmpty) { + // Check which proofs are actually unspent + try { + final proofStates = await _cashuRepo.checkTokenState( + proofPubkeys: newProofs.map((p) => p.Y).toList(), + mintUrl: mintUrl, + ); + + // Filter out spent proofs + final unspentProofs = []; + for (int i = 0; i < newProofs.length; i++) { + if (i < proofStates.length && + proofStates[i].state == CashuProofState.unspend) { + unspentProofs.add(newProofs[i]); + } + } + + if (unspentProofs.isNotEmpty) { + await _cacheManagerCashu.saveProofs( + proofs: unspentProofs, + mintUrl: mintUrl, + ); + Logger.log.i( + 'Saved ${unspentProofs.length} unspent proofs to cache (filtered out ${newProofs.length - unspentProofs.length} spent proofs)'); + + // Update balance stream + await _updateBalances(); + } else { + Logger.log.i( + 'All ${newProofs.length} restored proofs were already spent, skipping save'); + } + } catch (e) { + Logger.log.e('Error checking proof states during restore: $e'); + // If we can't check state, save the proofs anyway (better to have duplicates than lose proofs) + await _cacheManagerCashu.saveProofs( + proofs: newProofs, + mintUrl: mintUrl, + ); + Logger.log.w( + 'Saved ${newProofs.length} proofs without state check due to error'); + + // Update balance stream + await _updateBalances(); + } + } + + // Yield progress update + yield result; + } + + Logger.log.i('Restore completed'); + } + + 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 { + if (_pendingTransactionsSubject == null) { + _pendingTransactionsSubject = + BehaviorSubject>.seeded( + _pendingTransactions.toList(), + ); + _getPendingTransactionsDb().then((transactions) { + _pendingTransactions.clear(); + _pendingTransactions.addAll(transactions); + _pendingTransactionsSubject?.add(_pendingTransactions.toList()); + }).catchError((error) { + _pendingTransactionsSubject?.addError( + Exception('Failed to load pending transactions: $error'), + ); + }); + } + + 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 = 50, + }) async { + final transactions = await _cacheManagerCashu.getTransactions( + limit: limit, + ); + + // Filter to exclude draft and pending transactions (includes completed, failed, canceled) + final fTransactions = transactions + .whereType() + .where((tx) => !tx.state.isPending) + .toList(); + + return fTransactions; + } + + Future> _getPendingTransactionsDb() async { + final transactions = await _cacheManagerCashu.getTransactions( + limit: 20, + ); + + // Filter to only include draft and pending transactions + final pendingTransactions = transactions + .whereType() + .where((tx) => tx.state.isPending) + .toList(); + + return pendingTransactions; + } + + 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 + await _addAndSavePendingTransaction(draftTransaction); + + // save draft transaction to cache + await _cacheManagerCashu.saveTransactions(transactions: [draftTransaction]); + + 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); + + // Remove draft transaction from pending if it exists + _removePendingTransaction(draftTransaction); + + CashuQuoteState payStatus; + + final pendingTransaction = draftTransaction.copyWith( + state: WalletTransactionState.pending, + ); + + // update pending transactions + await _addAndSavePendingTransaction(pendingTransaction); + + // save pending state to cache + await _cacheManagerCashu + .saveTransactions(transactions: [pendingTransaction]); + + 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', + transactionDate: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + + // remove from pending and save to latest transactions + _removePendingTransaction(expiredTransaction); + await _addAndSaveLatestTransaction(expiredTransaction); + + Logger.log.w('Quote expired before payment was received'); + yield expiredTransaction; + 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, + cashuSeedSecretGenerator: _cashuKeyDerivation, + ); + + 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', + transactionDate: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + + // remove from pending and save to latest transactions + _removePendingTransaction(failedTransaction); + await _addAndSaveLatestTransaction(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', + transactionDate: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + + // remove from pending and save to latest transactions + _removePendingTransaction(failedTransaction); + await _addAndSaveLatestTransaction(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, + cashuSeedSecretGenerator: _cashuKeyDerivation); + 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, + cashuSeedSecretGenerator: _cashuKeyDerivation); + myOutputs.addAll(blankOutputs); + } + + myOutputs.sort( + (a, b) => b.amount.compareTo(a.amount)); // sort outputs by amount desc + + // Remove draft transaction from pending if it exists + _removePendingTransaction(draftRedeemTransaction); + + final pendingTransaction = draftRedeemTransaction.copyWith( + state: WalletTransactionState.pending, + ); + await _addAndSavePendingTransaction(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) { + // Check if proofs were actually spent on the mint + try { + final proofStates = await _cashuRepo.checkTokenState( + proofPubkeys: selectionResult.selectedProofs.map((p) => p.Y).toList(), + mintUrl: mintUrl, + ); + + final allSpent = + proofStates.every((state) => state.state == CashuProofState.spend); + + if (allSpent) { + // Proofs were spent on mint side, mark them as spent locally + _changeProofState( + proofs: selectionResult.selectedProofs, + state: CashuProofState.spend, + ); + await _cacheManagerCashu.saveProofs( + proofs: selectionResult.selectedProofs, + mintUrl: mintUrl, + ); + + final failedTransaction = pendingTransaction.copyWith( + state: WalletTransactionState.failed, + completionMsg: + 'Proofs were spent but melt failed: $e. Proofs marked as spent to prevent reuse.', + ); + _removePendingTransaction(failedTransaction); + yield failedTransaction; + } else { + // Proofs were not spent, safe to release them + _changeProofState( + proofs: selectionResult.selectedProofs, + state: CashuProofState.unspend, + ); + await _cacheManagerCashu.saveProofs( + proofs: selectionResult.selectedProofs, + mintUrl: mintUrl, + ); + + final failedTransaction = pendingTransaction.copyWith( + state: WalletTransactionState.failed, + completionMsg: 'Redeeming failed: $e', + ); + _removePendingTransaction(failedTransaction); + yield failedTransaction; + } + } catch (stateCheckError) { + // If we can't check the state, assume proofs might be spent and mark them as such to be safe + _changeProofState( + proofs: selectionResult.selectedProofs, + state: CashuProofState.spend, + ); + await _cacheManagerCashu.saveProofs( + proofs: selectionResult.selectedProofs, + mintUrl: mintUrl, + ); + + final failedTransaction = pendingTransaction.copyWith( + state: WalletTransactionState.failed, + completionMsg: + 'Redeeming failed: $e. Could not verify proof state: $stateCheckError. Proofs marked as spent for safety.', + ); + _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 + await _addAndSavePendingTransaction(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(), + ); + await _addAndSavePendingTransaction(pendingTransaction); + + await _updateBalances(); + + checkSpendingState( + transaction: pendingTransaction, + ); + + final token = proofsToToken( + proofs: proofsToReturn, + mintUrl: mintUrl, + unit: unit, + memo: memo ?? '', + ); + + pendingTransaction = pendingTransaction.copyWith( + token: token.toV4TokenString(), + ); + await _addAndSavePendingTransaction(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'); + } + + try { + 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); + } + } catch (e) { + Logger.log.e( + 'Error checking spending state for transaction ${transaction.id}: $e'); + // Mark transaction as failed + final failedTransaction = transaction.copyWith( + state: WalletTransactionState.failed, + transactionDate: DateTime.now().millisecondsSinceEpoch ~/ 1000, + completionMsg: 'Failed to verify spending state: $e', + ); + _removePendingTransaction(transaction); + await _addAndSaveLatestTransaction(failedTransaction); + } + } + + /// 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, + ); + + await _addAndSavePendingTransaction(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, + cashuSeedSecretGenerator: _cashuKeyDerivation); + + 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; + } + + Future _addAndSavePendingTransaction( + CashuWalletTransaction transaction, + ) async { + // update transaction + _pendingTransactions.removeWhere((t) => t.id == transaction.id); + + _pendingTransactions.add(transaction); + _pendingTransactionsSubject?.add(_pendingTransactions.toList()); + // save pending transaction to cache + await _cacheManagerCashu.saveTransactions(transactions: [transaction]); + } + + 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..c72d137bb --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_bdhke.dart @@ -0,0 +1,164 @@ +import 'dart:math'; +import 'dart:typed_data'; + +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 '../../repositories/cashu_key_derivation.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, + required CashuKeyDerivation cashuSeedSecretGenerator, + }) async { + List items = []; + + final seedBytes = Uint8List.fromList(cashuSeed.getSeedBytes()); + + for (final amount in amounts) { + try { + final myCount = await cacheManager.getAndIncrementDerivationCounter( + keysetId: keysetId, + mintUrl: mintUrl, + ); + + final mySecret = await cashuSeedSecretGenerator.deriveSecret( + seedBytes: seedBytes, + 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}) { + final Y = CashuTools.hashToCurve(secret); + + Random random = Random.secure(); + r ??= BigInt.from(random.nextInt(1000000)) + BigInt.one; + + // Use fast multiplication (10-20x faster!) + final rG = CashuTools.fastGMultiply(r); + final ECPoint? blindedMessage = Y + rG; + + 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'); + } + + final keysByAmount = {}; + for (final keyPair in mintPublicKeys.mintKeyPairs) { + keysByAmount[keyPair.amount] = keyPair; + } + + /// 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 mintPubKey = keysByAmount[blindedMsg.amount]; + + if (mintPubKey == null) { + throw Exception('No mint public key for amount ${blindedMsg.amount}'); + } + + 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..630376ffd --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_cache_decorator.dart @@ -0,0 +1,153 @@ +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; + }); + } + + Future setDerivationCounter({ + required String keysetId, + required String mintUrl, + required int counter, + }) async { + await _delegate.setCashuSecretCounter( + mintUrl: mintUrl, + keysetId: keysetId, + counter: counter, + ); + } +} 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..7eec1fa3b --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_proof_select.dart @@ -0,0 +1,474 @@ +import '../../entities/cashu/cashu_keyset.dart'; +import '../../entities/cashu/cashu_blinded_message.dart'; +import '../../entities/cashu/cashu_proof.dart'; +import '../../repositories/cashu_key_derivation.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; + + final CashuKeyDerivation _cashuSeedSecretGenerator; + + CashuProofSelect({ + required CashuRepo cashuRepo, + required CashuKeyDerivation cashuSeedSecretGenerator, + }) : _cashuRepo = cashuRepo, + _cashuSeedSecretGenerator = cashuSeedSecretGenerator; + + /// 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, + cashuSeedSecretGenerator: _cashuSeedSecretGenerator, + ); + + // 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); + + // For large amounts, skip exact match search (it's exponential and slow) + // Only try exact match for smaller amounts with fewer proofs + if (targetAmount < 1000 && proofs.length <= 15) { + 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'], + ); + } + } + + // Use optimized greedy selection + return _selectGreedy( + sortedProofs: sortedProofs, + targetAmount: targetAmount, + keysets: keysets, + ); + } + + /// Fast greedy selection - optimized for performance + static ProofSelectionResult _selectGreedy({ + required List sortedProofs, + required int targetAmount, + required List keysets, + }) { + // Use index-based selection to avoid expensive list operations + final selected = []; // indices into sortedProofs + final used = []; // track which proofs are used + for (int i = 0; i < sortedProofs.length; i++) { + used.add(false); + } + + int currentTotal = 0; + int estimatedFees = 0; + + // Greedy selection: keep adding largest available proofs until we exceed target + fees + for (int i = 0; i < sortedProofs.length; i++) { + if (used[i]) continue; + + final proof = sortedProofs[i]; + final proofKeyset = _findKeysetById(keysets, proof.keysetId); + if (proofKeyset == null) continue; + + // Quick fee estimate (will calculate exactly later) + final proofFeePPK = proofKeyset.inputFeePPK; + final proofFeeEstimate = (proofFeePPK + 999) ~/ 1000; + + final projectedTotal = currentTotal + proof.amount; + final projectedFees = estimatedFees + proofFeeEstimate; + + selected.add(i); + used[i] = true; + currentTotal = projectedTotal; + estimatedFees = projectedFees; + + // Check if we have enough + if (currentTotal >= targetAmount + estimatedFees) { + break; + } + } + + // Now calculate exact fees with selected proofs + final selectedProofs = selected.map((i) => sortedProofs[i]).toList(); + final feeData = + calculateFeesWithBreakdown(proofs: selectedProofs, keysets: keysets); + final exactFees = feeData['totalFees']; + final exactTotal = + selectedProofs.fold(0, (sum, proof) => sum + proof.amount); + + // Check if we need more proofs (fees were underestimated) + if (exactTotal < targetAmount + exactFees) { + // Add one more proof + for (int i = 0; i < sortedProofs.length; i++) { + if (!used[i]) { + selectedProofs.add(sortedProofs[i]); + used[i] = true; + break; + } + } + + // Recalculate + final newFeeData = + calculateFeesWithBreakdown(proofs: selectedProofs, keysets: keysets); + final newFees = newFeeData['totalFees']; + final newTotal = + selectedProofs.fold(0, (sum, proof) => sum + proof.amount); + + if (newTotal < targetAmount + newFees) { + throw Exception( + 'Insufficient funds: need $targetAmount + fees ($newFees), have $newTotal selected'); + } + + final splitAmount = newTotal - targetAmount - newFees; + return ProofSelectionResult( + selectedProofs: selectedProofs, + totalSelected: newTotal, + fees: newFees, + splitAmount: splitAmount.toInt(), + needsSplit: splitAmount > 0, + feesByKeyset: newFeeData['feesByKeyset'], + ); + } + + // We have enough + final splitAmount = exactTotal - targetAmount - exactFees; + return ProofSelectionResult( + selectedProofs: selectedProofs, + totalSelected: exactTotal, + fees: exactFees, + splitAmount: splitAmount.toInt(), + needsSplit: splitAmount > 0, + feesByKeyset: feeData['feesByKeyset'], + ); + } + + /// 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 = 3, // Reduced from 5 for performance + }) { + // Stricter limits for performance + if (proofs.length > 15) 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_restore.dart b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_restore.dart new file mode 100644 index 000000000..f80669c01 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_restore.dart @@ -0,0 +1,362 @@ +import 'dart:typed_data'; + +import '../../../shared/logger/logger.dart'; +import '../../entities/cashu/cashu_blinded_message.dart'; +import '../../entities/cashu/cashu_blinded_signature.dart'; +import '../../entities/cashu/cashu_keyset.dart'; +import '../../entities/cashu/cashu_proof.dart'; +import '../../entities/cashu/cashu_restore_result.dart'; +import '../../repositories/cashu_key_derivation.dart'; +import '../../repositories/cashu_repo.dart'; +import 'cashu_bdhke.dart'; +import 'cashu_cache_decorator.dart'; +import 'cashu_seed.dart'; +import 'cashu_tools.dart'; + +/// Implements NUT-09 (Restore) and uses NUT-13 (Deterministic Secrets) +/// to restore proofs from a seed phrase. +class CashuRestore { + final CashuRepo cashuRepo; + final CashuKeyDerivation cashuKeyDerivation; + final CashuCacheDecorator cacheManager; + final CashuSeed cashuSeed; + + CashuRestore({ + required this.cashuRepo, + required this.cashuKeyDerivation, + required this.cacheManager, + required this.cashuSeed, + }); + + /// Restores proofs for a single keyset from a mint. + /// + /// This implements NUT-09 restore protocol: + /// 1. Generates deterministic blinded messages using NUT-13 + /// 2. Calls the mint's restore endpoint with these messages + /// 3. Receives back blind signatures for proofs that exist + /// 4. Unblinds the signatures to get the actual proofs + /// + /// [mintUrl] - The URL of the mint to restore from + /// [keyset] - The keyset to restore proofs for + /// [startCounter] - The counter to start scanning from (default: 0) + /// [batchSize] - How many secrets to check in each batch (default: 100) + /// [gapLimit] - How many consecutive empty batches before stopping (default: 2) + Future restoreKeyset({ + required String mintUrl, + required CahsuKeyset keyset, + int startCounter = 0, + int batchSize = 100, + int gapLimit = 2, + }) async { + final String keysetId = keyset.id; + final seedBytes = Uint8List.fromList(cashuSeed.getSeedBytes()); + + List allRestoredProofs = []; + int currentCounter = startCounter; + int consecutiveEmptyBatches = 0; + int lastUsedCounter = startCounter - 1; + + Logger.log + .i('Starting restore for keyset $keysetId from counter $startCounter'); + + while (consecutiveEmptyBatches < gapLimit) { + // Generate blinded messages for this batch + final List blindedMessageItems = []; + + for (int i = 0; i < batchSize; i++) { + final counter = currentCounter + i; + + try { + // Derive secret and blinding factor using NUT-13 + final derivedSecret = await cashuKeyDerivation.deriveSecret( + seedBytes: seedBytes, + counter: counter, + keysetId: keysetId, + ); + + final secret = derivedSecret.secretHex; + final r = BigInt.parse(derivedSecret.blindingHex, radix: 16); + + // Create blinded message + // ignore: non_constant_identifier_names, constant_identifier_names + final (B_, rActual) = CashuBdhke.blindMessage(secret, r: r); + + if (B_.isEmpty) { + Logger.log.w('Empty blinded message for counter $counter'); + continue; + } + + // We don't know the amount for restore, so we use 0 as placeholder + // The mint will return the actual amount in the signature + final blindedMessage = CashuBlindedMessage( + id: keysetId, + amount: 0, // Amount is unknown during restore + blindedMessage: B_, + ); + + blindedMessageItems.add(CashuBlindedMessageItem( + blindedMessage: blindedMessage, + secret: secret, + r: rActual, + amount: 0, + )); + } catch (e) { + Logger.log + .w('Error creating blinded message for counter $counter: $e'); + } + } + + if (blindedMessageItems.isEmpty) { + Logger.log.w( + 'No valid blinded messages created for batch starting at $currentCounter'); + consecutiveEmptyBatches++; + currentCounter += batchSize; + continue; + } + + // Call restore endpoint + try { + final blindedMessages = + blindedMessageItems.map((item) => item.blindedMessage).toList(); + + final (restoredOutputs, signatures) = await cashuRepo.restore( + mintUrl: mintUrl, + outputs: blindedMessages, + ); + + if (signatures.isEmpty) { + // No signatures returned for this batch + Logger.log.d( + 'No signatures returned for batch starting at $currentCounter'); + consecutiveEmptyBatches++; + } else { + // Found some proofs! Reset empty batch counter + consecutiveEmptyBatches = 0; + + Logger.log.i( + 'Found ${signatures.length} signatures in batch starting at $currentCounter'); + + // Unblind the signatures to get proofs + final proofs = _unblindRestoreSignatures( + restoredOutputs: restoredOutputs, + signatures: signatures, + blindedMessageItems: blindedMessageItems, + keyset: keyset, + ); + + allRestoredProofs.addAll(proofs); + + // Update last used counter + lastUsedCounter = currentCounter + batchSize - 1; + } + } catch (e) { + Logger.log.e( + 'Error calling restore endpoint for batch starting at $currentCounter: $e'); + // On error, we consider this batch as empty and continue + consecutiveEmptyBatches++; + } + + currentCounter += batchSize; + } + + Logger.log.i('Restore completed for keyset $keysetId. ' + 'Found ${allRestoredProofs.length} proofs. ' + 'Last used counter: $lastUsedCounter'); + + return CashuRestoreKeysetResult( + keysetId: keysetId, + restoredProofs: allRestoredProofs, + lastUsedCounter: lastUsedCounter, + ); + } + + /// Unblinds restore signatures to create proofs + /// + /// According to NUT-09, the mint returns both outputs and signatures. + /// The outputs contain the B_ values that were matched (with amounts filled in). + /// We match outputs[i] with signatures[i], then find the corresponding + /// blinded message item from our original list by matching the B_ value. + List _unblindRestoreSignatures({ + required List restoredOutputs, + required List signatures, + required List blindedMessageItems, + required CahsuKeyset keyset, + }) { + final List proofs = []; + + // Create a map of blinded message hex (B_) to its item for lookup + final Map messageMap = {}; + for (final item in blindedMessageItems) { + messageMap[item.blindedMessage.blindedMessage] = item; + } + + // Create a map of amount to public key + final Map keysByAmount = {}; + for (final keyPair in keyset.mintKeyPairs) { + keysByAmount[keyPair.amount] = keyPair.pubkey; + } + + // If we have restoredOutputs, match by B_ value + // Otherwise fall back to positional matching + if (restoredOutputs.isNotEmpty && + restoredOutputs.length == signatures.length) { + // Match outputs and signatures by index, then find the blinded message item by B_ + for (int i = 0; i < signatures.length; i++) { + final output = restoredOutputs[i]; + final signature = signatures[i]; + + // Find the corresponding blinded message item by B_ value + final blindedItem = messageMap[output.blindedMessage]; + if (blindedItem == null) { + Logger.log.w( + 'Could not find blinded message item for B_: ${output.blindedMessage}'); + continue; + } + + final mintPubKey = keysByAmount[signature.amount]; + if (mintPubKey == null) { + Logger.log.w('No mint public key for amount ${signature.amount}'); + continue; + } + + try { + // Unblind the signature + final unblindedSig = CashuBdhke.unblindingSignature( + cHex: signature.blindedSignature, + kHex: mintPubKey, + r: blindedItem.r, + ); + + if (unblindedSig == null) { + Logger.log.w( + 'Failed to unblind signature for amount ${signature.amount}'); + continue; + } + + // Create the proof + final proof = CashuProof( + keysetId: signature.id, + amount: signature.amount, + secret: blindedItem.secret, + unblindedSig: CashuTools.ecPointToHex(unblindedSig), + ); + + proofs.add(proof); + } catch (e) { + Logger.log.e('Error unblinding signature: $e'); + } + } + } else { + // Fallback: try to match by attempting unblinding with each blinded message + Logger.log.w( + 'No outputs in restore response or length mismatch, using fallback matching'); + + final Set usedBlindedMessages = {}; + + for (final signature in signatures) { + final mintPubKey = keysByAmount[signature.amount]; + if (mintPubKey == null) { + Logger.log.w('No mint public key for amount ${signature.amount}'); + continue; + } + + bool matched = false; + + // Try each unused blinded message + for (final blindedItem in blindedMessageItems) { + final blindedMsgHex = blindedItem.blindedMessage.blindedMessage; + + if (usedBlindedMessages.contains(blindedMsgHex)) { + continue; + } + + try { + final unblindedSig = CashuBdhke.unblindingSignature( + cHex: signature.blindedSignature, + kHex: mintPubKey, + r: blindedItem.r, + ); + + if (unblindedSig != null) { + final proof = CashuProof( + keysetId: signature.id, + amount: signature.amount, + secret: blindedItem.secret, + unblindedSig: CashuTools.ecPointToHex(unblindedSig), + ); + + proofs.add(proof); + usedBlindedMessages.add(blindedMsgHex); + matched = true; + break; + } + } catch (e) { + continue; + } + } + + if (!matched) { + Logger.log.w( + 'Could not find matching blinded message for signature with amount ${signature.amount}'); + } + } + } + + return proofs; + } + + /// Restores proofs for all keysets from a mint. + /// + /// This is the main restore method that should be called by wallets. + /// It will restore proofs for all active keysets in the mint. + /// + /// [mintUrl] - The URL of the mint to restore from + /// [keysets] - The list of keysets to restore proofs for + /// [startCounter] - The counter to start scanning from (default: 0) + /// [batchSize] - How many secrets to check in each batch (default: 100) + /// [gapLimit] - How many consecutive empty batches before stopping (default: 2) + /// + /// Yields [CashuRestoreResult] updates after each keyset is processed. + Stream restoreAllKeysets({ + required String mintUrl, + required List keysets, + int startCounter = 0, + int batchSize = 100, + int gapLimit = 2, + }) async* { + final List keysetResults = []; + int totalProofs = 0; + + for (final keyset in keysets) { + try { + final result = await restoreKeyset( + mintUrl: mintUrl, + keyset: keyset, + startCounter: startCounter, + batchSize: batchSize, + gapLimit: gapLimit, + ); + + keysetResults.add(result); + totalProofs += result.restoredProofs.length; + + // Update the derivation counter in cache + if (result.lastUsedCounter >= startCounter) { + await cacheManager.setDerivationCounter( + keysetId: keyset.id, + mintUrl: mintUrl, + counter: result.lastUsedCounter + 1, + ); + } + + // Yield progress after each keyset is processed + yield CashuRestoreResult( + keysetResults: List.from(keysetResults), + totalProofsRestored: totalProofs, + ); + } catch (e) { + Logger.log.e('Error restoring keyset ${keyset.id}: $e'); + } + } + } +} 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..cc61e994f --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_seed.dart @@ -0,0 +1,98 @@ +import 'package:bip39_mnemonic/bip39_mnemonic.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; + List _cachedSeed = []; + + CashuSeed({ + CashuUserSeedphrase? userSeedPhrase, + }) { + if (userSeedPhrase != null) { + setSeedPhrase( + seedPhrase: userSeedPhrase.seedPhrase, + language: userSeedPhrase.language, + passphrase: userSeedPhrase.passphrase, + ); + } + } + + /// set the user seed phrase + /// ? calling this function is expensive because it computes the seed + /// throws an exception if the seed phrase is invalid + Future setSeedPhrase({ + required String seedPhrase, + Language language = Language.english, + String passphrase = '', + }) async { + _userSeedPhrase = Mnemonic.fromSentence( + seedPhrase, + language, + passphrase: passphrase, + ); + + _cachedSeed = _userSeedPhrase!.seed; + } + + Mnemonic getSeedPhrase() { + _seedCheck(); + return _userSeedPhrase!; + } + + List getSeedBytes() { + _seedCheck(); + return _cachedSeed; + } + + /// 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'); + } + if (_cachedSeed.isEmpty) { + throw Exception('Seed bytes are not computed'); + } + } + + 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(); + } +} 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..8b1378917 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_swap.dart @@ -0,0 +1 @@ + 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..e2d2886e7 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/cashu/cashu_tools.dart @@ -0,0 +1,221 @@ +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'; + } + + static final List _gPowersOf2 = _precomputeGPowersOf2(); + + static List _precomputeGPowersOf2() { + final powers = []; + var current = getG(); + + for (var i = 0; i < 256; i++) { + powers.add(current); + current = (current + current)!; // Double the point + } + + return powers; + } + + /// Fast G multiplication using pre-computed table + static ECPoint fastGMultiply(BigInt scalar) { + ECPoint? result; + + // Binary method: iterate through bits of scalar + var tempScalar = scalar; + var bitIndex = 0; + + while (tempScalar > BigInt.zero) { + // Check if least significant bit is 1 + if ((tempScalar & BigInt.one) == BigInt.one) { + result = result == null + ? _gPowersOf2[bitIndex] + : (result + _gPowersOf2[bitIndex])!; + } + + tempScalar = tempScalar >> 1; // Right shift (divide by 2) + bitIndex++; + } + + return result ?? getG(); + } + + /// 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_connection.dart b/packages/ndk/lib/domain_layer/usecases/nwc/nwc_connection.dart index b3dfd453d..40d7bc5f8 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/nwc_connection.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/nwc_connection.dart @@ -80,7 +80,8 @@ class NwcConnection { /// does this connection only support legacy notifications bool isLegacyNotifications() { return (supportedVersions.length == 1 && supportedVersions.first == "0.0" || - !supportedVersions.any((e) => e != "0.0")) && !supportedEncryptions.any((e) => e != "nip04"); + !supportedVersions.any((e) => e != "0.0")) && + !supportedEncryptions.any((e) => e != "nip04"); } @override 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 1f6f8af1a..20bfc3405 100644 --- a/packages/ndk/lib/domain_layer/usecases/relay_manager.dart +++ b/packages/ndk/lib/domain_layer/usecases/relay_manager.dart @@ -179,7 +179,8 @@ class RelayManager { Logger.log.i("connecting to relay $dirtyUrl"); - relayConnectivity.relayTransport = nostrTransportFactory(url, onReconnect: () { + relayConnectivity.relayTransport = + nostrTransportFactory(url, onReconnect: () { reSubscribeInFlightSubscriptions(relayConnectivity!); updateRelayConnectivity(); }, onDisconnect: (code, error, reason) { @@ -516,7 +517,8 @@ class RelayManager { } if (accountsToAuth.isEmpty) { - Logger.log.w("Received an AUTH challenge but no accounts to authenticate"); + Logger.log + .w("Received an AUTH challenge but no accounts to authenticate"); return; } @@ -545,7 +547,8 @@ class RelayManager { ["challenge", challenge] ]); account.signer.sign(auth).then((signedAuth) { - send(relayConnectivity, ClientMsg(ClientMsgType.kAuth, event: signedAuth)); + send(relayConnectivity, + ClientMsg(ClientMsgType.kAuth, event: signedAuth)); Logger.log.d( "AUTH sent for ${account.pubkey.substring(0, 8)} to ${relayConnectivity.url}"); }); @@ -573,8 +576,7 @@ class RelayManager { return; } - Logger.log.d( - "Late AUTH for ${accounts.length} accounts on $relayUrl"); + Logger.log.d("Late AUTH for ${accounts.length} accounts on $relayUrl"); _authenticateAccounts(relayConnectivity, challenge, accounts.toSet()); } @@ -655,7 +657,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/requests/requests.dart b/packages/ndk/lib/domain_layer/usecases/requests/requests.dart index b6ae2300d..d9415e77f 100644 --- a/packages/ndk/lib/domain_layer/usecases/requests/requests.dart +++ b/packages/ndk/lib/domain_layer/usecases/requests/requests.dart @@ -82,7 +82,8 @@ class Requests { /// Returns an [NdkResponse] containing the query result stream, future NdkResponse query({ Filter? filter, - @Deprecated('Use filter instead. Multiple filters support will be removed in a future version.') + @Deprecated( + 'Use filter instead. Multiple filters support will be removed in a future version.') List? filters, String name = '', RelaySet? relaySet, @@ -134,7 +135,8 @@ class Requests { /// Returns an [NdkResponse] containing the subscription results as stream NdkResponse subscription({ Filter? filter, - @Deprecated('Use filter instead. Multiple filters support will be removed in a future version.') + @Deprecated( + 'Use filter instead. Multiple filters support will be removed in a future version.') List? filters, String name = '', String? id, 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..d74fc2742 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart @@ -0,0 +1,366 @@ +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 22a9f871a..adb40c9e1 100644 --- a/packages/ndk/lib/entities.dart +++ b/packages/ndk/lib/entities.dart @@ -31,6 +31,25 @@ export 'domain_layer/entities/user_relay_list.dart'; export 'domain_layer/entities/blossom_blobs.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'; +export 'domain_layer/entities/cashu/cashu_blinded_message.dart'; +export 'domain_layer/entities/cashu/cashu_blinded_signature.dart'; +export 'domain_layer/entities/cashu/cashu_restore_result.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'; + /// models export 'data_layer/models/nip_01_event_model.dart'; export 'domain_layer/entities/ndk_file.dart'; diff --git a/packages/ndk/lib/ndk.dart b/packages/ndk/lib/ndk.dart index cd572d8bb..7dfb7fb3b 100644 --- a/packages/ndk/lib/ndk.dart +++ b/packages/ndk/lib/ndk.dart @@ -79,6 +79,12 @@ 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/entities/cashu/cashu_blinded_message.dart'; +export 'domain_layer/entities/cashu/cashu_blinded_signature.dart'; +export 'domain_layer/entities/cashu/cashu_restore_result.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 6a09a3261..326350f01 100644 --- a/packages/ndk/lib/presentation_layer/init.dart +++ b/packages/ndk/lib/presentation_layer/init.dart @@ -1,16 +1,23 @@ import 'package:http/http.dart' as http; +import '../data_layer/repositories/cashu_seed_secret_generator/dart_cashu_key_derivation.dart'; 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'; @@ -18,6 +25,7 @@ import '../domain_layer/usecases/proof_of_work/proof_of_work.dart'; import '../domain_layer/usecases/cache_read/cache_read.dart'; import '../domain_layer/usecases/fetched_ranges/fetched_ranges.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'; @@ -37,6 +45,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'; @@ -81,6 +90,8 @@ class Initialization { late Search search; late GiftWrap giftWrap; late Connectivy connectivity; + late Cashu cashu; + late Wallets wallets; late FetchedRanges fetchedRanges; late ProofOfWork proofOfWork; @@ -143,6 +154,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); @@ -257,6 +274,23 @@ class Initialization { connectivity = Connectivy(relayManager); + cashu = Cashu( + cashuRepo: cashuRepo, + cacheManager: _ndkConfig.cache, + cashuUserSeedphrase: _ndkConfig.cashuUserSeedphrase, + cashuKeyDerivation: DartCashuKeyDerivation(), + ); + + final WalletsRepo walletsRepo = WalletsRepoImpl( + cashuUseCase: cashu, + nwcUseCase: nwc, + cacheManager: _ndkConfig.cache, + ); + + wallets = Wallets( + walletsRepository: walletsRepo, + walletsOperationsRepository: walletsOperationsRepo, + ); proofOfWork = ProofOfWork(); /// set the user configured log level diff --git a/packages/ndk/lib/presentation_layer/ndk.dart b/packages/ndk/lib/presentation_layer/ndk.dart index 337a2282f..744d26e57 100644 --- a/packages/ndk/lib/presentation_layer/ndk.dart +++ b/packages/ndk/lib/presentation_layer/ndk.dart @@ -1,11 +1,13 @@ import 'package:meta/meta.dart'; import '../data_layer/repositories/cache_manager/mem_cache_manager.dart'; +import '../data_layer/repositories/cashu_seed_secret_generator/fake_cashu_seed_generator.dart'; import '../data_layer/repositories/verifiers/bip340_event_verifier.dart'; 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/fetched_ranges/fetched_ranges.dart'; import '../domain_layer/usecases/files/blossom.dart'; @@ -23,6 +25,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'; @@ -151,6 +154,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; + /// Fetched ranges tracking /// Track which time ranges have been fetched from which relays for each filter @experimental diff --git a/packages/ndk/lib/presentation_layer/ndk_config.dart b/packages/ndk/lib/presentation_layer/ndk_config.dart index 5f9a75ece..a70a23874 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; + /// whether to save broadcasted events to cache by default bool defaultBroadcastSaveToCache; @@ -71,6 +76,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, @@ -85,6 +91,7 @@ class NdkConfig { this.defaultBroadcastSaveToCache = BroadcastDefaults.SAVE_TO_CACHE, this.logLevel = defaultLogLevel, this.userAgent = RequestDefaults.DEFAULT_USER_AGENT, + this.cashuUserSeedphrase, this.fetchedRangesEnabled = false, }); } 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/lib/shared/isolates/isolate_manager.dart b/packages/ndk/lib/shared/isolates/isolate_manager.dart index b1552f22c..df1592506 100644 --- a/packages/ndk/lib/shared/isolates/isolate_manager.dart +++ b/packages/ndk/lib/shared/isolates/isolate_manager.dart @@ -1 +1,2 @@ -export 'isolate_manager_stub.dart' if (dart.library.io) 'isolate_manager_io.dart'; +export 'isolate_manager_stub.dart' + if (dart.library.io) 'isolate_manager_io.dart'; diff --git a/packages/ndk/lib/src/version.dart b/packages/ndk/lib/src/version.dart index edcece001..f60eb1046 100644 --- a/packages/ndk/lib/src/version.dart +++ b/packages/ndk/lib/src/version.dart @@ -1,2 +1,2 @@ // Generated code. Do not modify. -const packageVersion = '0.6.0'; +const packageVersion = '0.7.1-dev.2'; diff --git a/packages/ndk/pubspec.lock b/packages/ndk/pubspec.lock index 8611453d7..2025a873c 100644 --- a/packages/ndk/pubspec.lock +++ b/packages/ndk/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a url: "https://pub.dev" source: hosted - version: "91.0.0" + version: "88.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: a40a0cee526a7e1f387c6847bd8a5ccbf510a75952ef8a28338e989558072cb0 + sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f" url: "https://pub.dev" source: hosted - version: "8.4.0" + version: "8.1.1" args: dependency: transitive description: @@ -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: @@ -424,6 +489,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: @@ -576,6 +649,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: @@ -649,4 +730,4 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.8.0 <4.0.0" diff --git a/packages/ndk/pubspec.yaml b/packages/ndk/pubspec.yaml index db85ebd96..6c4830525 100644 --- a/packages/ndk/pubspec.yaml +++ b/packages/ndk/pubspec.yaml @@ -34,6 +34,14 @@ 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_bdhke_test.dart b/packages/ndk/test/cashu/cashu_bdhke_test.dart new file mode 100644 index 000000000..11944555f --- /dev/null +++ b/packages/ndk/test/cashu/cashu_bdhke_test.dart @@ -0,0 +1,94 @@ +import 'package:test/test.dart'; +import 'package:ndk/domain_layer/usecases/cashu/cashu_bdhke.dart'; + +void main() { + group('CashuBdhke.blindMessage', () { + // Note: The NUT-00 test vectors assume secrets are raw bytes, + // but this implementation treats secrets as hex strings that get UTF-8 encoded. + // These tests verify the actual behavior of the implementation. + + test('Test Vector 1 - blindMessage with known secret and r', () { + // Using a test secret (hex string representation) + // This tests that the function produces consistent output + final secret = + 'd341ee4871f1f889041e63cf0d3823c713eea6aff01e80f1719f08f9e5be98f6'; + final r = BigInt.parse( + '99fce58439fc37412ab3468b73db0569322588f62fb3a49182d67e23d877824a', + radix: 16); + + final (blindedMessage, returnedR) = CashuBdhke.blindMessage(secret, r: r); + + // Verify the function returns a valid blinded message + expect(blindedMessage, isNotEmpty); + expect(blindedMessage.length, + equals(66)); // Compressed EC point (33 bytes = 66 hex chars) + expect(returnedR, equals(r)); + }); + + test('Test Vector 2 - blindMessage with known secret and r', () { + // Using another test secret (hex string representation) + final secret = + 'f1aaf16c2239746f369572c0784d9dd3d032d952c2d992175873fb58fae31a60'; + final r = BigInt.parse( + 'f78476ea7cc9ade20f9e05e58a804cf19533f03ea805ece5fee88c8e2874ba50', + radix: 16); + + final (blindedMessage, returnedR) = CashuBdhke.blindMessage(secret, r: r); + + // Verify the function returns a valid blinded message + expect(blindedMessage, isNotEmpty); + expect(blindedMessage.length, equals(66)); + expect(returnedR, equals(r)); + }); + + test('blindMessage returns valid blinded message without r', () { + // Test that the function works when r is not provided + final secret = + 'd341ee4871f1f889041e63cf0d3823c713eea6aff01e80f1719f08f9e5be98f6'; + + final (blindedMessage, r) = CashuBdhke.blindMessage(secret); + + // Should return a valid hex string for the blinded message + expect(blindedMessage, isNotEmpty); + expect(blindedMessage.length, + greaterThan(60)); // Compressed EC point should be 66 chars (33 bytes) + + // Should return a valid r value + expect(r, isNotNull); + expect(r, greaterThan(BigInt.zero)); + }); + + test('blindMessage produces consistent results with same inputs', () { + // Test determinism: same secret and r should produce same output + final secret = + 'd341ee4871f1f889041e63cf0d3823c713eea6aff01e80f1719f08f9e5be98f6'; + final r = BigInt.parse( + '99fce58439fc37412ab3468b73db0569322588f62fb3a49182d67e23d877824a', + radix: 16); + + final (blindedMessage1, r1) = CashuBdhke.blindMessage(secret, r: r); + final (blindedMessage2, r2) = CashuBdhke.blindMessage(secret, r: r); + + expect(blindedMessage1, equals(blindedMessage2)); + expect(r1, equals(r2)); + expect(r1, equals(r)); + }); + + test('blindMessage produces different results with different r values', () { + // Test that different r values produce different blinded messages + final secret = + 'd341ee4871f1f889041e63cf0d3823c713eea6aff01e80f1719f08f9e5be98f6'; + final r1 = BigInt.parse( + '99fce58439fc37412ab3468b73db0569322588f62fb3a49182d67e23d877824a', + radix: 16); + final r2 = BigInt.parse( + 'f78476ea7cc9ade20f9e05e58a804cf19533f03ea805ece5fee88c8e2874ba50', + radix: 16); + + final (blindedMessage1, _) = CashuBdhke.blindMessage(secret, r: r1); + final (blindedMessage2, _) = CashuBdhke.blindMessage(secret, r: r2); + + expect(blindedMessage1, isNot(equals(blindedMessage2))); + }); + }); +} diff --git a/packages/ndk/test/cashu/cashu_dart_key_derivation_test.dart b/packages/ndk/test/cashu/cashu_dart_key_derivation_test.dart new file mode 100644 index 000000000..182221e03 --- /dev/null +++ b/packages/ndk/test/cashu/cashu_dart_key_derivation_test.dart @@ -0,0 +1,528 @@ +import 'dart:typed_data'; + +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart'; +import 'package:ndk/data_layer/repositories/cashu_seed_secret_generator/dart_cashu_key_derivation.dart'; +import 'package:test/test.dart'; + +import 'package:ndk/domain_layer/usecases/cashu/cashu_seed.dart'; +import '../tools/simple_profiler.dart'; + +void main() { + group('NUT-13 Test Vectors', () { + const testMnemonic = + "half depart obvious quality work element tank gorilla view sugar picture humble"; + late CashuSeed cashuSeed; + late Uint8List seedBytes; + + setUp(() async { + cashuSeed = CashuSeed(); + await cashuSeed.setSeedPhrase(seedPhrase: testMnemonic); + seedBytes = Uint8List.fromList(cashuSeed.getSeedBytes()); + }); + + group('Version 1: Deprecated BIP32 Derivation (keyset ID 009a1f293253e41e)', + () { + const keysetId = "009a1f293253e41e"; + const keysetIdInt = 864559728; + + test('keyset ID integer representation', () { + // Test that the conversion matches expected value + // The internal conversion should match keysetIdInt + expect(keysetIdInt, equals(864559728)); + }); + + test('secret derivation for counters 0-4', () async { + final derivation = DartCashuKeyDerivation(); + + final expectedSecrets = { + 0: "485875df74771877439ac06339e284c3acfcd9be7abf3bc20b516faeadfe77ae", + 1: "8f2b39e8e594a4056eb1e6dbb4b0c38ef13b1b2c751f64f810ec04ee35b77270", + 2: "bc628c79accd2364fd31511216a0fab62afd4a18ff77a20deded7b858c9860c8", + 3: "59284fd1650ea9fa17db2b3acf59ecd0f2d52ec3261dd4152785813ff27a33bf", + 4: "576c23393a8b31cc8da6688d9c9a96394ec74b40fdaf1f693a6bb84284334ea0", + }; + + for (var counter = 0; counter <= 4; counter++) { + final result = await derivation.deriveSecret( + seedBytes: seedBytes, + counter: counter, + keysetId: keysetId, + ); + + expect( + result.secretHex, + equals(expectedSecrets[counter]), + reason: 'Secret mismatch for counter $counter (Version 1)', + ); + } + }); + + test('blinding factor derivation for counters 0-4', () async { + final derivation = DartCashuKeyDerivation(); + + final expectedBlindingFactors = { + 0: "ad00d431add9c673e843d4c2bf9a778a5f402b985b8da2d5550bf39cda41d679", + 1: "967d5232515e10b81ff226ecf5a9e2e2aff92d66ebc3edf0987eb56357fd6248", + 2: "b20f47bb6ae083659f3aa986bfa0435c55c6d93f687d51a01f26862d9b9a4899", + 3: "fb5fca398eb0b1deb955a2988b5ac77d32956155f1c002a373535211a2dfdc29", + 4: "5f09bfbfe27c439a597719321e061e2e40aad4a36768bb2bcc3de547c9644bf9", + }; + + for (var counter = 0; counter <= 4; counter++) { + final result = await derivation.deriveSecret( + seedBytes: seedBytes, + counter: counter, + keysetId: keysetId, + ); + + expect( + result.blindingHex, + equals(expectedBlindingFactors[counter]), + reason: 'Blinding factor mismatch for counter $counter (Version 1)', + ); + } + }); + + test('combined secret and blinding factor derivation', () async { + final derivation = DartCashuKeyDerivation(); + + final testCases = [ + { + 'counter': 0, + 'secret': + "485875df74771877439ac06339e284c3acfcd9be7abf3bc20b516faeadfe77ae", + 'r': + "ad00d431add9c673e843d4c2bf9a778a5f402b985b8da2d5550bf39cda41d679", + 'path': "m/129372'/0'/864559728'/0'", + }, + { + 'counter': 1, + 'secret': + "8f2b39e8e594a4056eb1e6dbb4b0c38ef13b1b2c751f64f810ec04ee35b77270", + 'r': + "967d5232515e10b81ff226ecf5a9e2e2aff92d66ebc3edf0987eb56357fd6248", + 'path': "m/129372'/0'/864559728'/1'", + }, + { + 'counter': 2, + 'secret': + "bc628c79accd2364fd31511216a0fab62afd4a18ff77a20deded7b858c9860c8", + 'r': + "b20f47bb6ae083659f3aa986bfa0435c55c6d93f687d51a01f26862d9b9a4899", + 'path': "m/129372'/0'/864559728'/2'", + }, + { + 'counter': 3, + 'secret': + "59284fd1650ea9fa17db2b3acf59ecd0f2d52ec3261dd4152785813ff27a33bf", + 'r': + "fb5fca398eb0b1deb955a2988b5ac77d32956155f1c002a373535211a2dfdc29", + 'path': "m/129372'/0'/864559728'/3'", + }, + { + 'counter': 4, + 'secret': + "576c23393a8b31cc8da6688d9c9a96394ec74b40fdaf1f693a6bb84284334ea0", + 'r': + "5f09bfbfe27c439a597719321e061e2e40aad4a36768bb2bcc3de547c9644bf9", + 'path': "m/129372'/0'/864559728'/4'", + }, + ]; + + for (var testCase in testCases) { + final counter = testCase['counter'] as int; + final result = await derivation.deriveSecret( + seedBytes: seedBytes, + counter: counter, + keysetId: keysetId, + ); + + expect( + result.secretHex, + equals(testCase['secret']), + reason: 'Secret mismatch for counter $counter', + ); + expect( + result.blindingHex, + equals(testCase['r']), + reason: 'Blinding factor mismatch for counter $counter', + ); + } + }); + }); + + group('Version 2: Modern HMAC-SHA256 Derivation (keyset ID 015ba18a...)', + () { + const keysetId = + "015ba18a8adcd02e715a58358eb618da4a4b3791151a4bee5e968bb88406ccf76a"; + + test('secret derivation for counters 0-4', () async { + final derivation = DartCashuKeyDerivation(); + + final expectedSecrets = { + 0: "db5561a07a6e6490f8dadeef5be4e92f7cebaecf2f245356b5b2a4ec40687298", + 1: "b70e7b10683da3bf1cdf0411206f8180c463faa16014663f39f2529b2fda922e", + 2: "78a7ac32ccecc6b83311c6081b89d84bb4128f5a0d0c5e1af081f301c7a513f5", + 3: "094a2b6c63bfa7970bc09cda0e1cfc9cd3d7c619b8e98fabcfc60aea9e4963e5", + 4: "5e89fc5d30d0bf307ddf0a3ac34aa7a8ee3702169dafa3d3fe1d0cae70ecd5ef", + }; + + for (var counter = 0; counter <= 4; counter++) { + final result = await derivation.deriveSecret( + seedBytes: seedBytes, + counter: counter, + keysetId: keysetId, + ); + + expect( + result.secretHex, + equals(expectedSecrets[counter]), + reason: 'Secret mismatch for counter $counter (Version 2)', + ); + } + }); + + test('blinding factor derivation for counters 0-4', () async { + final derivation = DartCashuKeyDerivation(); + + final expectedBlindingFactors = { + 0: "6d26181a3695e32e9f88b80f039ba1ae2ab5a200ad4ce9dbc72c6d3769f2b035", + 1: "bde4354cee75545bea1a2eee035a34f2d524cee2bb01613823636e998386952e", + 2: "f40cc1218f085b395c8e1e5aaa25dccc851be3c6c7526a0f4e57108f12d6dac4", + 3: "099ed70fc2f7ac769bc20b2a75cb662e80779827b7cc358981318643030577d0", + 4: "5550337312d223ba62e3f75cfe2ab70477b046d98e3e71804eade3956c7b98cf", + }; + + for (var counter = 0; counter <= 4; counter++) { + final result = await derivation.deriveSecret( + seedBytes: seedBytes, + counter: counter, + keysetId: keysetId, + ); + + expect( + result.blindingHex, + equals(expectedBlindingFactors[counter]), + reason: 'Blinding factor mismatch for counter $counter (Version 2)', + ); + } + }); + + test('combined secret and blinding factor derivation', () async { + final derivation = DartCashuKeyDerivation(); + + final testCases = [ + { + 'counter': 0, + 'secret': + "db5561a07a6e6490f8dadeef5be4e92f7cebaecf2f245356b5b2a4ec40687298", + 'r': + "6d26181a3695e32e9f88b80f039ba1ae2ab5a200ad4ce9dbc72c6d3769f2b035", + }, + { + 'counter': 1, + 'secret': + "b70e7b10683da3bf1cdf0411206f8180c463faa16014663f39f2529b2fda922e", + 'r': + "bde4354cee75545bea1a2eee035a34f2d524cee2bb01613823636e998386952e", + }, + { + 'counter': 2, + 'secret': + "78a7ac32ccecc6b83311c6081b89d84bb4128f5a0d0c5e1af081f301c7a513f5", + 'r': + "f40cc1218f085b395c8e1e5aaa25dccc851be3c6c7526a0f4e57108f12d6dac4", + }, + { + 'counter': 3, + 'secret': + "094a2b6c63bfa7970bc09cda0e1cfc9cd3d7c619b8e98fabcfc60aea9e4963e5", + 'r': + "099ed70fc2f7ac769bc20b2a75cb662e80779827b7cc358981318643030577d0", + }, + { + 'counter': 4, + 'secret': + "5e89fc5d30d0bf307ddf0a3ac34aa7a8ee3702169dafa3d3fe1d0cae70ecd5ef", + 'r': + "5550337312d223ba62e3f75cfe2ab70477b046d98e3e71804eade3956c7b98cf", + }, + ]; + + for (var testCase in testCases) { + final counter = testCase['counter'] as int; + final result = await derivation.deriveSecret( + seedBytes: seedBytes, + counter: counter, + keysetId: keysetId, + ); + + expect( + result.secretHex, + equals(testCase['secret']), + reason: 'Secret mismatch for counter $counter (Version 2)', + ); + expect( + result.blindingHex, + equals(testCase['r']), + reason: 'Blinding factor mismatch for counter $counter (Version 2)', + ); + } + }); + }); + + group('Error Handling', () { + test('should throw on invalid keyset ID format', () async { + final derivation = DartCashuKeyDerivation(); + + expect( + () async => await derivation.deriveSecret( + seedBytes: seedBytes, + counter: 0, + keysetId: "invalid_hex", + ), + throwsException, + ); + }); + + test('should throw on unrecognized keyset version', () async { + final derivation = DartCashuKeyDerivation(); + + expect( + () async => await derivation.deriveSecret( + seedBytes: seedBytes, + counter: 0, + keysetId: "99a1f293253e41e", + ), + throwsException, + ); + }); + }); + + group('Performance Profiling', () { + test('Version 1 (BIP32) derivation speed', () async { + final profiler = SimpleProfiler('Version 1 BIP32 Derivation'); + final derivation = DartCashuKeyDerivation(); + const keysetId = "009a1f293253e41e"; + + profiler.checkpoint('Setup complete'); + + // Derive 10 secrets to get a better average + for (var counter = 0; counter < 10; counter++) { + await derivation.deriveSecret( + seedBytes: seedBytes, + counter: counter, + keysetId: keysetId, + ); + } + + profiler.checkpoint('Derived 10 secrets (Version 1)'); + profiler.end(); + }); + + test('Version 2 (HMAC-SHA256) derivation speed', () async { + final profiler = SimpleProfiler('Version 2 HMAC-SHA256 Derivation'); + final derivation = DartCashuKeyDerivation(); + const keysetId = + "015ba18a8adcd02e715a58358eb618da4a4b3791151a4bee5e968bb88406ccf76a"; + + profiler.checkpoint('Setup complete'); + + // Derive 10 secrets to get a better average + for (var counter = 0; counter < 10; counter++) { + await derivation.deriveSecret( + seedBytes: seedBytes, + counter: counter, + keysetId: keysetId, + ); + } + + profiler.checkpoint('Derived 10 secrets (Version 2)'); + profiler.end(); + }); + + test('Comparison: Version 1 vs Version 2 (100 iterations)', () async { + final derivation = DartCashuKeyDerivation(); + const keysetIdV1 = "009a1f293253e41e"; + const keysetIdV2 = + "015ba18a8adcd02e715a58358eb618da4a4b3791151a4bee5e968bb88406ccf76a"; + + final profiler = SimpleProfiler('Performance Comparison'); + + // Version 1 - 100 iterations + for (var counter = 0; counter < 100; counter++) { + await derivation.deriveSecret( + seedBytes: seedBytes, + counter: counter, + keysetId: keysetIdV1, + ); + } + + profiler.checkpoint('Version 1: 100 derivations'); + + // Version 2 - 100 iterations + for (var counter = 0; counter < 100; counter++) { + await derivation.deriveSecret( + seedBytes: seedBytes, + counter: counter, + keysetId: keysetIdV2, + ); + } + + profiler.checkpoint('Version 2: 100 derivations'); + profiler.end(); + }); + + test('Single derivation detailed timing', () async { + final derivation = DartCashuKeyDerivation(); + const keysetIdV1 = "009a1f293253e41e"; + const keysetIdV2 = + "015ba18a8adcd02e715a58358eb618da4a4b3791151a4bee5e968bb88406ccf76a"; + + final profiler = SimpleProfiler('Single Derivation Timing'); + + await derivation.deriveSecret( + seedBytes: seedBytes, + counter: 0, + keysetId: keysetIdV1, + ); + + profiler.checkpoint('Version 1: Single derivation'); + + await derivation.deriveSecret( + seedBytes: seedBytes, + counter: 0, + keysetId: keysetIdV2, + ); + + profiler.checkpoint('Version 2: Single derivation'); + profiler.end(); + }); + + test('Profile each step of V2 derivation', () { + final cachedSeed = seedBytes; + // ignore: unused_local_variable + final derivation = DartCashuKeyDerivation(); + const keysetId = + "015ba18a8adcd02e715a58358eb618da4a4b3791151a4bee5e968bb88406ccf76a"; + + final timings = {}; + + for (var counter = 0; counter < 100; counter++) { + var sw = Stopwatch()..start(); + final keysetBytes = hex.decode(keysetId); + timings['hex_decode'] = + (timings['hex_decode'] ?? 0) + sw.elapsedMicroseconds; + + sw = Stopwatch()..start(); + final counterBytes = Uint8List(8) + ..buffer.asByteData().setUint64(0, counter, Endian.big); + // Use counterBytes to avoid warning + expect(counterBytes.length, 8); + timings['counter_encode'] = + (timings['counter_encode'] ?? 0) + sw.elapsedMicroseconds; + + sw = Stopwatch()..start(); + final message = Uint8List(21 + keysetBytes.length + 8 + 1); + timings['allocate'] = + (timings['allocate'] ?? 0) + sw.elapsedMicroseconds; + + sw = Stopwatch()..start(); + final hmac = Hmac(sha256, cachedSeed); + final result = hmac.convert(message); + timings['hmac'] = (timings['hmac'] ?? 0) + sw.elapsedMicroseconds; + + sw = Stopwatch()..start(); + final x = BigInt.parse(hex.encode(result.bytes), radix: 16); + timings['bigint_parse'] = + (timings['bigint_parse'] ?? 0) + sw.elapsedMicroseconds; + + sw = Stopwatch()..start(); + final r = x % DartCashuKeyDerivation.secp256k1N; + timings['modulo'] = (timings['modulo'] ?? 0) + sw.elapsedMicroseconds; + + sw = Stopwatch()..start(); + final hexResult = r.toRadixString(16).padLeft(64, '0'); + // Use hexResult to avoid warning + expect(hexResult.length, 64); + timings['bigint_to_hex'] = + (timings['bigint_to_hex'] ?? 0) + sw.elapsedMicroseconds; + } + + print('\nPer-operation timings (100 iterations):'); + timings.forEach((key, value) { + print(' $key: ${value / 100}μs avg, ${value / 1000}ms total'); + }); + }); + + test('Diagnose mnemonic.seed performance', () { + final profiler = SimpleProfiler('Mnemonic Seed Access'); + + // Access cashuSeed.getSeedBytes() 10 times + for (var i = 0; i < 10; i++) { + final seed = Uint8List.fromList(cashuSeed.getSeedBytes()); + print('Seed length: ${seed.length}'); + } + + profiler.checkpoint('Accessed cashuSeed.getSeedBytes() 10 times'); + + // Now cache it and access 10 times + final cachedSeed = seedBytes; + for (var i = 0; i < 10; i++) { + final seed = Uint8List.fromList(cachedSeed); + print('Seed length: ${seed.length}'); + } + + profiler.checkpoint('Accessed cached seed 10 times'); + profiler.end(); + }); + + test('Verify caching works correctly', () { + final profiler = SimpleProfiler('Caching Verification'); + + // Test 1: Uncached access + final derivation1 = DartCashuKeyDerivation(); + const keysetId = + "015ba18a8adcd02e715a58358eb618da4a4b3791151a4bee5e968bb88406ccf76a"; + + for (var i = 0; i < 10; i++) { + derivation1.deriveSecret( + seedBytes: seedBytes, + counter: i, + keysetId: keysetId, + ); + } + + profiler.checkpoint('10 derivations WITHOUT cache'); + + // Test 2: With caching - reuse the same instance + final derivation2 = DartCashuKeyDerivation(); + + for (var i = 0; i < 10; i++) { + derivation2.deriveSecret( + seedBytes: seedBytes, + counter: i, + keysetId: keysetId, + ); + } + + profiler.checkpoint('10 derivations WITH cache (same instance)'); + + // Test 3: Using pre-seeded instance + // final cachedSeed = Uint8List.fromList(mnemonic.seed); + // final derivation3 = DartCashuKeyDerivation(seed: cachedSeed); + + // for (var i = 0; i < 10; i++) { + // derivation3.deriveSecretAndBlinding( + // counter: i, + // keysetId: keysetId, + // ); + // } + + //profiler.checkpoint('10 derivations with pre-seeded instance'); + profiler.end(); + }); + }); + }); +} 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..45f75094d --- /dev/null +++ b/packages/ndk/test/cashu/cashu_fund_test.dart @@ -0,0 +1,456 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:ndk/data_layer/data_sources/http_request.dart'; +import 'package:ndk/data_layer/repositories/cashu_seed_secret_generator/dart_cashu_key_derivation.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, + seedPhrase: CashuUserSeedphrase( + seedPhrase: + "reduce invest lunch step couch traffic measure civil want steel trip jar"), + ); + + 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, + seedPhrase: CashuUserSeedphrase( + seedPhrase: + "reduce invest lunch step couch traffic measure civil want steel trip jar"), + ); + + 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(); + ndk.cashu.setCashuSeedPhrase( + CashuUserSeedphrase(seedPhrase: CashuSeed.generateSeedPhrase()), + ); + 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(); + ndk.cashu.setCashuSeedPhrase( + CashuUserSeedphrase(seedPhrase: CashuSeed.generateSeedPhrase()), + ); + 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 derivation = DartCashuKeyDerivation(); + + final cashu = Cashu( + cashuRepo: cashuRepo, + cacheManager: cache, + cashuKeyDerivation: derivation); + + 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..fbda3c636 --- /dev/null +++ b/packages/ndk/test/cashu/cashu_proof_select_test.dart @@ -0,0 +1,537 @@ +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())); + }); + + test('large value - should converge quickly', () { + // Create many medium-sized proofs that need to be combined for a large amount + final largeValueProofs = [ + // Add some larger proofs + ...List.generate( + 10, + (i) => CashuProof( + amount: 1000, + keysetId: 'test-keyset', + secret: "proof1000-$i", + unblindedSig: "")), + // Add medium proofs + ...List.generate( + 20, + (i) => CashuProof( + amount: 100, + keysetId: 'test-keyset', + secret: "proof100-$i", + unblindedSig: "")), + // Add smaller proofs + ...List.generate( + 30, + (i) => CashuProof( + amount: 10, + keysetId: 'test-keyset', + secret: "proof10-$i", + unblindedSig: "")), + ]; + + // Target a large amount (8000 sats) - should converge without hitting max iterations + final result = CashuProofSelect.selectProofsForSpending( + proofs: largeValueProofs, + targetAmount: 8000, + keysets: keysets, + ); + + expect(result.selectedProofs.isNotEmpty, true); + expect(result.totalSelected - result.fees, greaterThanOrEqualTo(8000)); + }); + + test('very large value with smaller proofs - stress test', () { + // This test reproduces the convergence issue + final manyProofs = [ + ...List.generate( + 10, + (i) => CashuProof( + amount: 500, + keysetId: 'test-keyset', + secret: "proof500-$i", + unblindedSig: "")), + ...List.generate( + 50, + (i) => CashuProof( + amount: 50, + keysetId: 'test-keyset', + secret: "proof50-$i", + unblindedSig: "")), + ]; + + // Target 7000 sats - this would previously fail to converge + final result = CashuProofSelect.selectProofsForSpending( + proofs: manyProofs, + targetAmount: 7000, + keysets: keysets, + ); + + expect(result.selectedProofs.isNotEmpty, true); + expect(result.totalSelected - result.fees, greaterThanOrEqualTo(7000)); + }); + + test('extreme large value - 50k sats', () { + // Extreme test with very large target amount + final extremeProofs = [ + ...List.generate( + 30, + (i) => CashuProof( + amount: 2000, + keysetId: 'test-keyset', + secret: "proof2000-$i", + unblindedSig: "")), + ...List.generate( + 100, + (i) => CashuProof( + amount: 100, + keysetId: 'test-keyset', + secret: "proof100-$i", + unblindedSig: "")), + ]; + + // Target 50000 sats - should still converge quickly with optimized algorithm + final result = CashuProofSelect.selectProofsForSpending( + proofs: extremeProofs, + targetAmount: 50000, + keysets: keysets, + ); + + expect(result.selectedProofs.isNotEmpty, true); + expect(result.totalSelected - result.fees, greaterThanOrEqualTo(50000)); + // Should converge in very few iterations thanks to greedy initial selection + }); + + test('performance benchmark - 200 proofs, 100k sats', () { + // Performance test with many proofs + final manyProofs = [ + ...List.generate( + 50, + (i) => CashuProof( + amount: 5000, + keysetId: 'test-keyset', + secret: "proof5000-$i", + unblindedSig: "")), + ...List.generate( + 150, + (i) => CashuProof( + amount: 100, + keysetId: 'test-keyset', + secret: "proof100-$i", + unblindedSig: "")), + ]; + + final stopwatch = Stopwatch()..start(); + + final result = CashuProofSelect.selectProofsForSpending( + proofs: manyProofs, + targetAmount: 100000, + keysets: keysets, + ); + + stopwatch.stop(); + + expect(result.selectedProofs.isNotEmpty, true); + expect(result.totalSelected - result.fees, greaterThanOrEqualTo(100000)); + + // Should be fast (under 50ms for 200 proofs) + print( + 'Selection time for 200 proofs: ${stopwatch.elapsedMilliseconds}ms'); + expect(stopwatch.elapsedMilliseconds, lessThan(100)); + }); + }); +} 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..8abfcb93e --- /dev/null +++ b/packages/ndk/test/cashu/cashu_receive_test.dart @@ -0,0 +1,132 @@ +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/data_layer/repositories/cashu_seed_secret_generator/dart_cashu_key_derivation.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 derivation = DartCashuKeyDerivation(); + + final cashu = Cashu( + cashuRepo: cashuRepo, + cacheManager: cache, + cashuKeyDerivation: derivation, + cashuUserSeedphrase: + CashuUserSeedphrase(seedPhrase: CashuSeed.generateSeedPhrase()), + ); + + final cashu2 = Cashu( + cashuRepo: cashuRepo2, + cacheManager: cache2, + cashuKeyDerivation: derivation, + cashuUserSeedphrase: + CashuUserSeedphrase(seedPhrase: CashuSeed.generateSeedPhrase()), + ); + + 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..e57c3db0f --- /dev/null +++ b/packages/ndk/test/cashu/cashu_redeem_test.dart @@ -0,0 +1,409 @@ +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/data_layer/repositories/cashu_seed_secret_generator/dart_cashu_key_derivation.dart'; +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'; +import 'mocks/cashu_repo_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("redeem - offline mint should fail immediately on initiateRedeem", + () async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + // This should throw an exception quickly (not hang) + expect( + () async => await ndk.cashu.initiateRedeem( + mintUrl: 'https://offline.mint.example.com', + request: "lnbc1...", + unit: "sat", + method: "bolt11", + ), + throwsA(isA()), + ); + }); + + test("redeem - offline mint should fail immediately on redeem stream", + () async { + final cache = MemCacheManager(); + + // Save mint info so it doesn't try to fetch from network + await cache.saveMintInfo( + mintInfo: CashuMintInfo( + name: 'Test Offline Mint', + pubkey: null, + version: null, + description: null, + descriptionLong: null, + contact: const [], + nuts: const {}, + motd: null, + urls: const ['https://offline.mint.example.com'], + ), + ); + + await cache.saveKeyset( + CahsuKeyset( + id: 'testKeyset', + mintUrl: 'https://offline.mint.example.com', + unit: 'sat', + active: true, + inputFeePPK: 0, + fetchedAt: + DateTime.now().millisecondsSinceEpoch ~/ 1000, // mark as fresh + mintKeyPairs: { + CahsuMintKeyPair( + amount: 1, + pubkey: 'testPubKey-1', + ), + CahsuMintKeyPair( + amount: 2, + pubkey: 'testPubKey-2', + ), + }, + ), + ); + + await cache.saveProofs( + proofs: [ + CashuProof( + keysetId: 'testKeyset', + amount: 1, + secret: 'testSecret-1', + unblindedSig: '', + ), + CashuProof( + keysetId: 'testKeyset', + amount: 2, + secret: 'testSecret-2', + unblindedSig: '', + ), + ], + mintUrl: 'https://offline.mint.example.com', + ); + + // Use a Cashu instance with a real HTTP client (not mock) and our custom cache + // This will attempt real network calls and timeout quickly + final cashu = Cashu( + cashuRepo: CashuRepoImpl(client: HttpRequestDS(http.Client())), + cacheManager: cache, + cashuKeyDerivation: DartCashuKeyDerivation(), + cashuUserSeedphrase: CashuUserSeedphrase( + seedPhrase: + "reduce invest lunch step couch traffic measure civil want steel trip jar", + ), + ); + + final draftTransaction = CashuWalletTransaction( + id: "test-redeem-offline", + mintUrl: 'https://offline.mint.example.com', + walletId: 'https://offline.mint.example.com', + changeAmount: -2, + unit: "sat", + walletType: WalletType.CASHU, + state: WalletTransactionState.draft, + method: "bolt11", + qouteMelt: CashuQuoteMelt( + quoteId: 'test-quote', + amount: 2, + feeReserve: null, + paid: false, + expiry: null, + mintUrl: 'https://offline.mint.example.com', + state: CashuQuoteState.unpaid, + unit: 'sat', + request: 'lnbc1...', + ), + ); + + final redeemStream = + cashu.redeem(draftRedeemTransaction: draftTransaction); + + // The stream should emit pending first, then fail with a failed transaction + // This should not hang - it should fail quickly (within timeout period) + final transactions = await redeemStream.toList(); + + // Should have at least 2 transactions: pending and failed + expect(transactions.length, greaterThanOrEqualTo(2)); + expect(transactions.first.state, WalletTransactionState.pending); + expect(transactions.last.state, WalletTransactionState.failed); + expect(transactions.last.completionMsg, isNotNull); + expect(transactions.last.completionMsg, contains('failed')); + }); + + 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( + seedPhrase: CashuUserSeedphrase( + seedPhrase: + "reduce invest lunch step couch traffic measure civil want steel trip jar"), + 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), + ], + )); + }); + + test("redeem fails after proofs spent on mint - proofs marked as spent", + () async { + // This test verifies the fix for the broken proofs issue: + // When meltTokens() fails AFTER the mint has already spent the proofs, + // the proofs should be marked as spent locally (not released back to the wallet). + // Without this fix, proofs would be marked as unspent and become "broken" - + // appearing available in the wallet but actually already burned on the mint. + + final cache = MemCacheManager(); + final myHttpMock = MockCashuHttpClient(); + + // Use the mock repo that simulates melt failure after proofs are spent + final mockRepo = CashuRepoMeltFailAfterSpendMock( + client: HttpRequestDS(myHttpMock), + ); + + final cashu = CashuTestTools.mockHttpCashu( + seedPhrase: CashuUserSeedphrase( + seedPhrase: + "reduce invest lunch step couch traffic measure civil want steel trip jar", + ), + customMockClient: myHttpMock, + customCache: cache, + customRepo: mockRepo, + ); + + // Add test proofs to cache + final testProofs = [ + CashuProof( + keysetId: '00c726786980c4d9', + amount: 1, + secret: 'proof-s-1', + unblindedSig: 'sig1', + ), + CashuProof( + keysetId: '00c726786980c4d9', + amount: 2, + secret: 'proof-s-2', + unblindedSig: 'sig2', + ), + CashuProof( + keysetId: '00c726786980c4d9', + amount: 4, + secret: 'proof-s-4', + unblindedSig: 'sig4', + ), + ]; + + await cache.saveProofs(proofs: testProofs, mintUrl: mockMintUrl); + + // Verify proofs are initially in unspent state + final initialProofs = await cache.getProofs(mintUrl: mockMintUrl); + expect( + initialProofs.every((p) => p.state == CashuProofState.unspend), + isTrue, + reason: "All proofs should initially be unspent", + ); + + final meltQuoteTransaction = await cashu.initiateRedeem( + mintUrl: mockMintUrl, + request: "lnbc1...", + unit: "sat", + method: "bolt11", + ); + + final redeemStream = + cashu.redeem(draftRedeemTransaction: meltQuoteTransaction); + + // Collect all events from the stream + final events = await redeemStream.toList(); + + // Should emit pending then failed + expect(events.length, equals(2)); + expect(events[0].state, equals(WalletTransactionState.pending)); + expect(events[1].state, equals(WalletTransactionState.failed)); + expect( + events[1].completionMsg, + contains("Proofs were spent but melt failed"), + ); + + // CRITICAL ASSERTION: Verify selected proofs are marked as spent (not unspent/broken) + // This is the key behavior that prevents broken proofs + final spentProofs = await cache.getProofs( + mintUrl: mockMintUrl, + state: CashuProofState.spend, + ); + + // The melt quote has amount=1 + fee_reserve=2 = 3 sats total + // With proofs [1, 2, 4], selection should pick 1+2=3, so 2 proofs + expect( + spentProofs.length, + equals(2), + reason: + "Selected proofs should be marked as spent since they were burned on the mint", + ); + expect( + spentProofs.every((p) => p.state == CashuProofState.spend), + isTrue, + reason: "All selected proofs should be in spent state", + ); + + // Verify unselected proofs remain unspent + final unspentProofs = await cache.getProofs( + mintUrl: mockMintUrl, + state: CashuProofState.unspend, + ); + expect( + unspentProofs.length, + equals(1), + reason: "Unselected proofs should remain unspent", + ); + }); + }); +} diff --git a/packages/ndk/test/cashu/cashu_restore_test.dart b/packages/ndk/test/cashu/cashu_restore_test.dart new file mode 100644 index 000000000..875a5d581 --- /dev/null +++ b/packages/ndk/test/cashu/cashu_restore_test.dart @@ -0,0 +1,190 @@ +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/data_layer/repositories/cashu_seed_secret_generator/dart_cashu_key_derivation.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_user_seedphrase.dart'; +import 'package:ndk/domain_layer/usecases/cashu/cashu_restore.dart'; +import 'package:ndk/entities.dart'; +import 'package:ndk/ndk.dart'; +import 'package:test/test.dart'; + +const devMintUrl = 'https://dev.mint.camelus.app'; +const mockMintUrl = 'http://mock.mint'; + +void main() { + group('Cashu Restore Tests', () { + test('restore - fund wallet1 and restore to wallet2 with real mint', + () async { + // Create a shared seed phrase for both wallets + final seedPhrase = CashuSeed.generateSeedPhrase(); + final userSeedPhrase = CashuUserSeedphrase(seedPhrase: seedPhrase); + + print( + 'Using seed phrase for test (first 5 words): ${seedPhrase.split(' ').take(5).join(' ')}...'); + + // Create wallet1 + final httpClient1 = http.Client(); + final httpRequestDS1 = HttpRequestDS(httpClient1); + final cashuRepo1 = CashuRepoImpl(client: httpRequestDS1); + final cacheManager1 = MemCacheManager(); + final keyDerivation1 = DartCashuKeyDerivation(); + + final wallet1 = Cashu( + cashuRepo: cashuRepo1, + cacheManager: cacheManager1, + cashuKeyDerivation: keyDerivation1, + cashuUserSeedphrase: userSeedPhrase, + ); + + const fundAmount = 21; + const mintUrl = devMintUrl; + const unit = "sat"; + + print('Step 1: Funding wallet1 with $fundAmount $unit...'); + + // Fund wallet1 + final draftTransaction = await wallet1.initiateFund( + mintUrl: mintUrl, + amount: fundAmount, + unit: unit, + method: "bolt11", + ); + + print('Quote created: ${draftTransaction.qoute!.quoteId}'); + print('Payment request: ${draftTransaction.qoute!.request}'); + print('Waiting for payment...'); + + final transactionStream = + wallet1.retrieveFunds(draftTransaction: draftTransaction); + + await expectLater( + transactionStream, + emitsInOrder([ + isA() + .having((t) => t.state, 'state', WalletTransactionState.pending), + isA().having( + (t) => t.state, 'state', WalletTransactionState.completed), + ]), + ); + + print('Wallet1 funded successfully!'); + // cycle proofs 2 + final spendResult = + await wallet1.initiateSpend(mintUrl: mintUrl, amount: 2, unit: unit); + + final stream = wallet1.receive(spendResult.token.toV4TokenString()); + + await expectLater( + stream, + emitsInOrder([ + isA() + .having((t) => t.state, 'state', WalletTransactionState.pending), + isA().having( + (t) => t.state, 'state', WalletTransactionState.completed), + ]), + ); + + print('Proofs cycled successfully!'); + + // Check wallet1 balance + final wallet1Balances = await wallet1.getBalances(); + final wallet1Balance = wallet1Balances + .where((element) => element.mintUrl == mintUrl) + .first + .balances[unit]!; + + print('Step 2: Wallet1 funded successfully!'); + print('Wallet1 balance: $wallet1Balance $unit'); + expect(wallet1Balance, equals(fundAmount)); + + // Get wallet1 proofs to verify later + final wallet1Proofs = await cacheManager1.getProofs(mintUrl: mintUrl); + print('Wallet1 has ${wallet1Proofs.length} proofs'); + + // Create wallet2 with the SAME seed phrase but DIFFERENT cache + print('\nStep 3: Creating wallet2 with same seed phrase...'); + + final httpClient2 = http.Client(); + final httpRequestDS2 = HttpRequestDS(httpClient2); + final cashuRepo2 = CashuRepoImpl(client: httpRequestDS2); + final cacheManager2 = MemCacheManager(); // Fresh cache - empty! + final keyDerivation2 = DartCashuKeyDerivation(); + + final wallet2 = Cashu( + cashuRepo: cashuRepo2, + cacheManager: cacheManager2, + cashuKeyDerivation: keyDerivation2, + cashuUserSeedphrase: userSeedPhrase, // SAME seed phrase! + ); + + // Wallet2 should have 0 balance before restore (fresh cache) + final wallet2BalancesBefore = await wallet2.getBalances(); + final wallet2BalanceBefore = wallet2BalancesBefore + .where((element) => element.mintUrl == mintUrl) + .firstOrNull + ?.balances[unit]; + + print( + 'Wallet2 balance before restore: ${wallet2BalanceBefore ?? 0} $unit'); + expect(wallet2BalanceBefore, anyOf(isNull, equals(0))); + + // Restore wallet2 using the seed phrase + print('\nStep 4: Restoring wallet2 from seed phrase...'); + + CashuRestoreResult? restoreResult; + final restoreStream = wallet2.restore( + mintUrl: mintUrl, + unit: unit, + ); + + await for (final result in restoreStream) { + restoreResult = result; + print( + ' Progress: ${result.totalProofsRestored} proofs restored so far...'); + } + + print('Restore completed!'); + print('Total proofs restored: ${restoreResult!.totalProofsRestored}'); + for (final keysetResult in restoreResult.keysetResults) { + print( + ' Keyset ${keysetResult.keysetId}: ${keysetResult.restoredProofs.length} proofs'); + } + + // Check wallet2 balance after restore + final wallet2BalancesAfter = await wallet2.getBalances(); + final wallet2BalanceAfter = wallet2BalancesAfter + .where((element) => element.mintUrl == mintUrl) + .first + .balances[unit]!; + + print('\nStep 5: Verification'); + print('Wallet1 balance: $wallet1Balance $unit'); + print('Wallet2 balance after restore: $wallet2BalanceAfter $unit'); + + // Verify both wallets have the same balance + expect(wallet2BalanceAfter, equals(wallet1Balance), + reason: + 'Wallet2 should have the same balance as wallet1 after restore'); + expect(wallet2BalanceAfter, equals(fundAmount), + reason: 'Wallet2 should have the funded amount'); + + // Verify wallet2 has proofs + final wallet2Proofs = await cacheManager2.getProofs(mintUrl: mintUrl); + print('Wallet2 has ${wallet2Proofs.length} proofs after restore'); + expect(wallet2Proofs.length, greaterThan(0), + reason: 'Wallet2 should have proofs after restore'); + + // Verify that proofs have different secrets (the bug we fixed!) + final secrets = wallet2Proofs.map((p) => p.secret).toSet(); + print('Wallet2 has ${secrets.length} unique secrets'); + expect(secrets.length, equals(wallet2Proofs.length), + reason: 'Each proof should have a unique secret'); + + print('\n✅ Test passed! Restore functionality works correctly.'); + print('Wallet1 and Wallet2 both have $fundAmount $unit'); + + httpClient1.close(); + httpClient2.close(); + }, skip: false); + }); +} 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..022a99c74 --- /dev/null +++ b/packages/ndk/test/cashu/cashu_spend_test.dart @@ -0,0 +1,264 @@ +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/data_layer/repositories/cashu_seed_secret_generator/dart_cashu_key_derivation.dart'; +import 'package:ndk/domain_layer/usecases/cashu/cashu_seed.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 - offline mint should fail immediately", () async { + final cache = MemCacheManager(); + + await cache.saveKeyset( + CahsuKeyset( + id: 'testKeyset', + mintUrl: 'https://offline.mint.example.com', + unit: 'sat', + active: true, + inputFeePPK: 0, + mintKeyPairs: { + CahsuMintKeyPair( + amount: 1, + pubkey: 'testPubKey-1', + ), + CahsuMintKeyPair( + amount: 2, + pubkey: 'testPubKey-2', + ), + CahsuMintKeyPair( + amount: 4, + pubkey: 'testPubKey-4', + ), + }, + ), + ); + + await cache.saveProofs( + proofs: [ + CashuProof( + keysetId: 'testKeyset', + amount: 1, + secret: 'testSecret-1', + unblindedSig: '', + ), + CashuProof( + keysetId: 'testKeyset', + amount: 2, + secret: 'testSecret-2', + unblindedSig: '', + ), + CashuProof( + keysetId: 'testKeyset', + amount: 4, + secret: 'testSecret-4', + unblindedSig: '', + ), + ], + mintUrl: 'https://offline.mint.example.com', + ); + + final cashu = CashuTestTools.mockHttpCashu( + customCache: cache, + seedPhrase: CashuUserSeedphrase( + seedPhrase: + "reduce invest lunch step couch traffic measure civil want steel trip jar"), + ); + + // This should throw an exception quickly (not hang) + expect( + () async => await cashu.initiateSpend( + mintUrl: 'https://offline.mint.example.com', + amount: 3, + unit: 'sat', + ), + throwsA(isA()), + ); + }); + + 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( + seedPhrase: CashuUserSeedphrase( + seedPhrase: + "reduce invest lunch step couch traffic measure civil want steel trip jar"), + ); + + 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, + seedPhrase: CashuUserSeedphrase( + seedPhrase: + "reduce invest lunch step couch traffic measure civil want steel trip jar"), + ); + + expect( + () async => await cashu.initiateSpend( + mintUrl: mockMintUrl, + amount: 4, + unit: 'sat', + ), + throwsA(isA()), + ); + }); + }); + + group('spend', () { + test("spend - initiateSpend", () async { + // Generate unique seed phrases for each test run to ensure unique blinded messages + // This prevents "Blinded Message is already signed" errors from the mint + final seedPhrase1 = CashuSeed.generateSeedPhrase(); + final seedPhrase2 = CashuSeed.generateSeedPhrase(); + + final cache = MemCacheManager(); + final cache2 = MemCacheManager(); + + final client = HttpRequestDS(http.Client()); + final cashuRepo = CashuRepoImpl(client: client); + final cashuRepo2 = CashuRepoImpl(client: client); + final derivation = DartCashuKeyDerivation(); + final cashu = Cashu( + cashuRepo: cashuRepo, + cacheManager: cache, + cashuKeyDerivation: derivation, + cashuUserSeedphrase: CashuUserSeedphrase(seedPhrase: seedPhrase1), + ); + + final cashu2 = Cashu( + cashuRepo: cashuRepo2, + cacheManager: cache2, + cashuKeyDerivation: derivation, + cashuUserSeedphrase: CashuUserSeedphrase(seedPhrase: seedPhrase2), + ); + + 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..da8fcd0da --- /dev/null +++ b/packages/ndk/test/cashu/cashu_test_tools.dart @@ -0,0 +1,37 @@ +import 'package:ndk/data_layer/data_sources/http_request.dart'; +import 'package:ndk/data_layer/repositories/cashu/cashu_repo_impl.dart'; +import 'package:ndk/data_layer/repositories/cashu_seed_secret_generator/dart_cashu_key_derivation.dart'; +import 'package:ndk/domain_layer/repositories/cashu_repo.dart'; +import 'package:ndk/entities.dart'; +import 'package:ndk/ndk.dart'; + +import 'mocks/cashu_http_client_mock.dart'; + +class CashuTestTools { + static Cashu mockHttpCashu({ + MockCashuHttpClient? customMockClient, + CacheManager? customCache, + CashuUserSeedphrase? seedPhrase, + CashuRepo? customRepo, + }) { + final MockCashuHttpClient mockClient = + customMockClient ?? MockCashuHttpClient(); + final HttpRequestDS httpRequestDS = HttpRequestDS(mockClient); + + final CashuRepo cashuRepo = customRepo ?? + CashuRepoImpl( + client: httpRequestDS, + ); + + final CacheManager cache = customCache ?? MemCacheManager(); + + final derivation = DartCashuKeyDerivation(); + + final cashu = Cashu( + cashuUserSeedphrase: seedPhrase, + cashuRepo: cashuRepo, + cacheManager: cache, + cashuKeyDerivation: derivation); + 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..242a4c7ae --- /dev/null +++ b/packages/ndk/test/cashu/mocks/cashu_repo_mock.dart @@ -0,0 +1,50 @@ +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_melt_response.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_proof.dart'; +import 'package:ndk/domain_layer/entities/cashu/cashu_token_state_response.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"); + } +} + +/// Mock repo that simulates melt failure after proofs are already spent on the mint +class CashuRepoMeltFailAfterSpendMock extends CashuRepoImpl { + CashuRepoMeltFailAfterSpendMock({required super.client}); + + @override + Future meltTokens({ + required String mintUrl, + required String quoteId, + required List proofs, + required List outputs, + String method = "bolt11", + }) async { + // Simulate that the mint received and burned the proofs, but then an error occurred + throw Exception("Network error during melt response"); + } + + @override + Future> checkTokenState({ + required List proofPubkeys, + required String mintUrl, + }) async { + // Return that all proofs are spent on the mint + return proofPubkeys + .map((y) => CashuTokenStateResponse( + Y: y, + state: CashuProofState.spend, + )) + .toList(); + } +} 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..2a80348c3 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,10 +26,12 @@ 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 // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeNip65_0 extends _i1.SmartFake implements _i2.Nip65 { _FakeNip65_0( @@ -51,10 +53,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,36 +84,18 @@ 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 - set pubKey(String? _pubKey) => super.noSuchMethod( - Invocation.setter( - #pubKey, - _pubKey, - ), - returnValueForMissingStub: null, - ); - @override int get createdAt => (super.noSuchMethod( Invocation.getter(#createdAt), returnValue: 0, ) as int); - @override - set createdAt(int? _createdAt) => super.noSuchMethod( - Invocation.setter( - #createdAt, - _createdAt, - ), - returnValueForMissingStub: null, - ); - @override int get refreshedTimestamp => (super.noSuchMethod( Invocation.getter(#refreshedTimestamp), @@ -99,40 +103,58 @@ class MockUserRelayList extends _i1.Mock implements _i4.UserRelayList { ) as int); @override - set refreshedTimestamp(int? _refreshedTimestamp) => super.noSuchMethod( + 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? value) => super.noSuchMethod( Invocation.setter( - #refreshedTimestamp, - _refreshedTimestamp, + #pubKey, + value, ), returnValueForMissingStub: null, ); @override - Map get relays => (super.noSuchMethod( - Invocation.getter(#relays), - returnValue: {}, - ) as Map); - - @override - set relays(Map? _relays) => super.noSuchMethod( + set createdAt(int? value) => super.noSuchMethod( Invocation.setter( - #relays, - _relays, + #createdAt, + value, ), returnValueForMissingStub: null, ); @override - Iterable get urls => (super.noSuchMethod( - Invocation.getter(#urls), - returnValue: [], - ) as Iterable); + set refreshedTimestamp(int? value) => super.noSuchMethod( + Invocation.setter( + #refreshedTimestamp, + value, + ), + returnValueForMissingStub: null, + ); @override - Iterable get readUrls => (super.noSuchMethod( - Invocation.getter(#readUrls), - returnValue: [], - ) as Iterable); + set relays(Map? value) => super.noSuchMethod( + Invocation.setter( + #relays, + value, + ), + returnValueForMissingStub: null, + ); @override _i2.Nip65 toNip65() => (super.noSuchMethod( @@ -153,47 +175,44 @@ 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); } @override - String get name => (super.noSuchMethod( - Invocation.getter(#name), - returnValue: _i5.dummyValue( + String get id => (super.noSuchMethod( + Invocation.getter(#id), + returnValue: _i7.dummyValue( this, - Invocation.getter(#name), + Invocation.getter(#id), ), ) as String); @override - set name(String? _name) => super.noSuchMethod( - Invocation.setter( - #name, - _name, + Iterable get urls => (super.noSuchMethod( + Invocation.getter(#urls), + returnValue: [], + ) as Iterable); + + @override + String get name => (super.noSuchMethod( + Invocation.getter(#name), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#name), ), - returnValueForMissingStub: null, - ); + ) as String); @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i5.dummyValue( + returnValue: _i7.dummyValue( this, Invocation.getter(#pubKey), ), ) as String); - @override - set pubKey(String? _pubKey) => super.noSuchMethod( - Invocation.setter( - #pubKey, - _pubKey, - ), - returnValueForMissingStub: null, - ); - @override int get relayMinCountPerPubkey => (super.noSuchMethod( Invocation.getter(#relayMinCountPerPubkey), @@ -201,97 +220,98 @@ class MockRelaySet extends _i1.Mock implements _i7.RelaySet { ) as int); @override - set relayMinCountPerPubkey(int? _relayMinCountPerPubkey) => - super.noSuchMethod( + _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 + set name(String? value) => super.noSuchMethod( Invocation.setter( - #relayMinCountPerPubkey, - _relayMinCountPerPubkey, + #name, + value, ), returnValueForMissingStub: null, ); @override - _i8.RelayDirection get direction => (super.noSuchMethod( - Invocation.getter(#direction), - returnValue: _i8.RelayDirection.inbox, - ) as _i8.RelayDirection); + set pubKey(String? value) => super.noSuchMethod( + Invocation.setter( + #pubKey, + value, + ), + returnValueForMissingStub: null, + ); @override - set direction(_i8.RelayDirection? _direction) => super.noSuchMethod( + set relayMinCountPerPubkey(int? value) => super.noSuchMethod( Invocation.setter( - #direction, - _direction, + #relayMinCountPerPubkey, + value, ), returnValueForMissingStub: null, ); @override - Map> get relaysMap => (super.noSuchMethod( - Invocation.getter(#relaysMap), - returnValue: >{}, - ) as Map>); + set direction(_i10.RelayDirection? value) => super.noSuchMethod( + Invocation.setter( + #direction, + value, + ), + returnValueForMissingStub: null, + ); @override - set relaysMap(Map>? _relaysMap) => + set relaysMap(Map>? value) => super.noSuchMethod( Invocation.setter( #relaysMap, - _relaysMap, + value, ), returnValueForMissingStub: null, ); @override - bool get fallbackToBootstrapRelays => (super.noSuchMethod( - Invocation.getter(#fallbackToBootstrapRelays), - returnValue: false, - ) as bool); - - @override - set fallbackToBootstrapRelays(bool? _fallbackToBootstrapRelays) => - super.noSuchMethod( + set fallbackToBootstrapRelays(bool? value) => super.noSuchMethod( Invocation.setter( #fallbackToBootstrapRelays, - _fallbackToBootstrapRelays, + value, ), returnValueForMissingStub: null, ); @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>? value) => super.noSuchMethod( Invocation.setter( #notCoveredPubkeys, - _notCoveredPubkeys, + value, ), 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 +325,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 +338,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,51 +346,24 @@ 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 - set pubKey(String? _pubKey) => super.noSuchMethod( - Invocation.setter( - #pubKey, - _pubKey, - ), - returnValueForMissingStub: null, - ); - @override List get contacts => (super.noSuchMethod( Invocation.getter(#contacts), returnValue: [], ) as List); - @override - set contacts(List? _contacts) => super.noSuchMethod( - Invocation.setter( - #contacts, - _contacts, - ), - returnValueForMissingStub: null, - ); - @override List get contactRelays => (super.noSuchMethod( Invocation.getter(#contactRelays), returnValue: [], ) as List); - @override - set contactRelays(List? _contactRelays) => super.noSuchMethod( - Invocation.setter( - #contactRelays, - _contactRelays, - ), - returnValueForMissingStub: null, - ); - @override List get petnames => (super.noSuchMethod( Invocation.getter(#petnames), @@ -378,95 +371,121 @@ class MockContactList extends _i1.Mock implements _i12.ContactList { ) as List); @override - set petnames(List? _petnames) => super.noSuchMethod( + 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? value) => super.noSuchMethod( Invocation.setter( - #petnames, - _petnames, + #pubKey, + value, ), returnValueForMissingStub: null, ); @override - List get followedTags => (super.noSuchMethod( - Invocation.getter(#followedTags), - returnValue: [], - ) as List); + set contacts(List? value) => super.noSuchMethod( + Invocation.setter( + #contacts, + value, + ), + returnValueForMissingStub: null, + ); @override - set followedTags(List? _followedTags) => super.noSuchMethod( + set contactRelays(List? value) => super.noSuchMethod( Invocation.setter( - #followedTags, - _followedTags, + #contactRelays, + value, ), returnValueForMissingStub: null, ); @override - List get followedCommunities => (super.noSuchMethod( - Invocation.getter(#followedCommunities), - returnValue: [], - ) as List); + set petnames(List? value) => super.noSuchMethod( + Invocation.setter( + #petnames, + value, + ), + returnValueForMissingStub: null, + ); @override - set followedCommunities(List? _followedCommunities) => - super.noSuchMethod( + set followedTags(List? value) => super.noSuchMethod( Invocation.setter( - #followedCommunities, - _followedCommunities, + #followedTags, + value, ), returnValueForMissingStub: null, ); @override - List get followedEvents => (super.noSuchMethod( - Invocation.getter(#followedEvents), - returnValue: [], - ) as List); + set followedCommunities(List? value) => super.noSuchMethod( + Invocation.setter( + #followedCommunities, + value, + ), + returnValueForMissingStub: null, + ); @override - set followedEvents(List? _followedEvents) => super.noSuchMethod( + set followedEvents(List? value) => super.noSuchMethod( Invocation.setter( #followedEvents, - _followedEvents, + value, ), returnValueForMissingStub: null, ); @override - int get createdAt => (super.noSuchMethod( - Invocation.getter(#createdAt), - returnValue: 0, - ) as int); - - @override - set createdAt(int? _createdAt) => super.noSuchMethod( + set createdAt(int? value) => super.noSuchMethod( Invocation.setter( #createdAt, - _createdAt, + value, ), returnValueForMissingStub: null, ); @override - set loadedTimestamp(int? _loadedTimestamp) => super.noSuchMethod( + set loadedTimestamp(int? value) => super.noSuchMethod( Invocation.setter( #loadedTimestamp, - _loadedTimestamp, + value, ), returnValueForMissingStub: null, ); @override - List get sources => (super.noSuchMethod( - Invocation.getter(#sources), - returnValue: [], - ) as List); - - @override - set sources(List? _sources) => super.noSuchMethod( + set sources(List? value) => super.noSuchMethod( Invocation.setter( #sources, - _sources, + value, ), returnValueForMissingStub: null, ); @@ -515,7 +534,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,116 +542,131 @@ 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 - set pubKey(String? _pubKey) => super.noSuchMethod( + List get sources => (super.noSuchMethod( + Invocation.getter(#sources), + returnValue: [], + ) as List); + + @override + set pubKey(String? value) => super.noSuchMethod( Invocation.setter( #pubKey, - _pubKey, + value, ), returnValueForMissingStub: null, ); @override - set name(String? _name) => super.noSuchMethod( + set name(String? value) => super.noSuchMethod( Invocation.setter( #name, - _name, + value, ), returnValueForMissingStub: null, ); @override - set displayName(String? _displayName) => super.noSuchMethod( + set displayName(String? value) => super.noSuchMethod( Invocation.setter( #displayName, - _displayName, + value, ), returnValueForMissingStub: null, ); @override - set picture(String? _picture) => super.noSuchMethod( + set picture(String? value) => super.noSuchMethod( Invocation.setter( #picture, - _picture, + value, ), returnValueForMissingStub: null, ); @override - set banner(String? _banner) => super.noSuchMethod( + set banner(String? value) => super.noSuchMethod( Invocation.setter( #banner, - _banner, + value, ), returnValueForMissingStub: null, ); @override - set website(String? _website) => super.noSuchMethod( + set website(String? value) => super.noSuchMethod( Invocation.setter( #website, - _website, + value, ), returnValueForMissingStub: null, ); @override - set about(String? _about) => super.noSuchMethod( + set about(String? value) => super.noSuchMethod( Invocation.setter( #about, - _about, + value, ), returnValueForMissingStub: null, ); @override - set nip05(String? _nip05) => super.noSuchMethod( + set nip05(String? value) => super.noSuchMethod( Invocation.setter( #nip05, - _nip05, + value, ), returnValueForMissingStub: null, ); @override - set lud16(String? _lud16) => super.noSuchMethod( + set lud16(String? value) => super.noSuchMethod( Invocation.setter( #lud16, - _lud16, + value, ), returnValueForMissingStub: null, ); @override - set lud06(String? _lud06) => super.noSuchMethod( + set lud06(String? value) => super.noSuchMethod( Invocation.setter( #lud06, - _lud06, + value, ), returnValueForMissingStub: null, ); @override - set updatedAt(int? _updatedAt) => super.noSuchMethod( + set updatedAt(int? value) => super.noSuchMethod( Invocation.setter( #updatedAt, - _updatedAt, + value, ), returnValueForMissingStub: null, ); @override - set refreshedTimestamp(int? _refreshedTimestamp) => super.noSuchMethod( + set refreshedTimestamp(int? value) => super.noSuchMethod( Invocation.setter( #refreshedTimestamp, - _refreshedTimestamp, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set sources(List? value) => super.noSuchMethod( + Invocation.setter( + #sources, + value, ), returnValueForMissingStub: null, ); @@ -676,7 +710,7 @@ class MockMetadata extends _i1.Mock implements _i13.Metadata { #getName, [], ), - returnValue: _i5.dummyValue( + returnValue: _i7.dummyValue( this, Invocation.method( #getName, @@ -693,6 +727,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 +800,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 +821,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,81 +833,21 @@ 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( - 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), - returnValue: false, - ) as bool); - @override List get tTags => (super.noSuchMethod( Invocation.getter(#tTags), @@ -851,22 +867,79 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { ) as List); @override - Map toJson() => (super.noSuchMethod( + set id(String? value) => super.noSuchMethod( + Invocation.setter( + #id, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set createdAt(int? value) => super.noSuchMethod( + Invocation.setter( + #createdAt, + value, + ), + returnValueForMissingStub: null, + ); + + @override + _i3.Nip01Event copyWith({ + String? id, + String? pubKey, + int? createdAt, + int? kind, + List>? tags, + String? content, + String? sig, + bool? validSig, + List? sources, + }) => + (super.noSuchMethod( Invocation.method( - #toJson, + #copyWith, [], + { + #id: id, + #pubKey: pubKey, + #createdAt: createdAt, + #kind: kind, + #tags: tags, + #content: content, + #sig: sig, + #validSig: validSig, + #sources: sources, + }, ), - returnValue: {}, - ) as Map); + returnValue: _FakeNip01Event_1( + this, + Invocation.method( + #copyWith, + [], + { + #id: id, + #pubKey: pubKey, + #createdAt: createdAt, + #kind: kind, + #tags: tags, + #content: content, + #sig: sig, + #validSig: validSig, + #sources: sources, + }, + ), + ), + ) as _i3.Nip01Event); @override - void sign(String? privateKey) => super.noSuchMethod( + List getTags(String? tag) => (super.noSuchMethod( Invocation.method( - #sign, - [privateKey], + #getTags, + [tag], ), - returnValueForMissingStub: null, - ); + returnValue: [], + ) as List); @override String? getFirstTag(String? name) => (super.noSuchMethod(Invocation.method( @@ -878,7 +951,7 @@ class MockNip01Event extends _i1.Mock implements _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,69 +959,105 @@ 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 - set pubKey(String? _pubKey) => super.noSuchMethod( - Invocation.setter( - #pubKey, - _pubKey, - ), - returnValueForMissingStub: null, - ); - @override String get nip05 => (super.noSuchMethod( Invocation.getter(#nip05), - returnValue: _i5.dummyValue( + returnValue: _i7.dummyValue( this, Invocation.getter(#nip05), ), ) as String); @override - set nip05(String? _nip05) => super.noSuchMethod( + bool get valid => (super.noSuchMethod( + Invocation.getter(#valid), + returnValue: false, + ) as bool); + + @override + set pubKey(String? value) => super.noSuchMethod( Invocation.setter( - #nip05, - _nip05, + #pubKey, + value, ), returnValueForMissingStub: null, ); @override - bool get valid => (super.noSuchMethod( - Invocation.getter(#valid), - returnValue: false, - ) as bool); + set nip05(String? value) => super.noSuchMethod( + Invocation.setter( + #nip05, + value, + ), + returnValueForMissingStub: null, + ); @override - set valid(bool? _valid) => super.noSuchMethod( + set valid(bool? value) => super.noSuchMethod( Invocation.setter( #valid, - _valid, + value, ), returnValueForMissingStub: null, ); @override - set networkFetchTime(int? _networkFetchTime) => super.noSuchMethod( + set networkFetchTime(int? value) => super.noSuchMethod( Invocation.setter( #networkFetchTime, - _networkFetchTime, + value, ), returnValueForMissingStub: null, ); @override - set relays(List? _relays) => super.noSuchMethod( + set relays(List? value) => super.noSuchMethod( Invocation.setter( #relays, - _relays, + value, ), 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..42ee6f861 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,10 +17,12 @@ 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 // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeWebSocketChannel_0 extends _i1.SmartFake implements _i2.WebSocketChannel { diff --git a/packages/ndk/test/shared/helpers/relay_helper_test.dart b/packages/ndk/test/shared/helpers/relay_helper_test.dart index 97a3d64a7..3aa8a7e84 100644 --- a/packages/ndk/test/shared/helpers/relay_helper_test.dart +++ b/packages/ndk/test/shared/helpers/relay_helper_test.dart @@ -5,7 +5,8 @@ void main() { group('cleanRelayUrl', () { group('valid URLs', () { test('accepts valid wss URL with port + path', () { - expect(cleanRelayUrl('wss://relay.damus.io:5000/abc/aa.co.mm'), 'wss://relay.damus.io:5000/abc/aa.co.mm'); + expect(cleanRelayUrl('wss://relay.damus.io:5000/abc/aa.co.mm'), + 'wss://relay.damus.io:5000/abc/aa.co.mm'); }); test('accepts valid wss URL', () { diff --git a/packages/ndk/test/usecases/cache_read_test.dart b/packages/ndk/test/usecases/cache_read_test.dart index 5cd13be78..e36ccaf2f 100644 --- a/packages/ndk/test/usecases/cache_read_test.dart +++ b/packages/ndk/test/usecases/cache_read_test.dart @@ -10,18 +10,12 @@ void main() async { final CacheManager myCacheManager = MemCacheManager(); final List myEvens = [ - Nip01Event( - pubKey: "pubKey1", kind: 1, tags: [], content: "content1_a"), - Nip01Event( - pubKey: "pubKey1", kind: 1, tags: [], content: "content1_b"), - Nip01Event( - pubKey: "pubKey1", kind: 1, tags: [], content: "content1_c"), - Nip01Event( - pubKey: "pubKey2", kind: 1, tags: [], content: "content2_a"), - Nip01Event( - pubKey: "pubKey2", kind: 1, tags: [], content: "content2_b"), - Nip01Event( - pubKey: "pubKey2", kind: 1, tags: [], content: "content2_c"), + Nip01Event(pubKey: "pubKey1", kind: 1, tags: [], content: "content1_a"), + Nip01Event(pubKey: "pubKey1", kind: 1, tags: [], content: "content1_b"), + Nip01Event(pubKey: "pubKey1", kind: 1, tags: [], content: "content1_c"), + Nip01Event(pubKey: "pubKey2", kind: 1, tags: [], content: "content2_a"), + Nip01Event(pubKey: "pubKey2", kind: 1, tags: [], content: "content2_b"), + Nip01Event(pubKey: "pubKey2", kind: 1, tags: [], content: "content2_c"), ]; setUp(() async { diff --git a/packages/ndk/test/usecases/cache_write_test.dart b/packages/ndk/test/usecases/cache_write_test.dart index 3e2fb101b..79e342faa 100644 --- a/packages/ndk/test/usecases/cache_write_test.dart +++ b/packages/ndk/test/usecases/cache_write_test.dart @@ -9,43 +9,26 @@ void main() async { final CacheManager myCacheManager = MemCacheManager(); final List myEvens = [ - Nip01Event( - pubKey: "pubKey1", kind: 1, tags: [], content: "content1_a"), - Nip01Event( - pubKey: "pubKey1", kind: 1, tags: [], content: "content1_b"), - Nip01Event( - pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), - Nip01Event( - pubKey: "pubKey1", kind: 1, tags: [], content: "content1_c"), - Nip01Event( - pubKey: "pubKey2", kind: 1, tags: [], content: "content2_a"), - Nip01Event( - pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), - Nip01Event( - pubKey: "pubKey2", kind: 1, tags: [], content: "content2_b"), - Nip01Event( - pubKey: "pubKey2", kind: 1, tags: [], content: "content2_c"), - Nip01Event( - pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), - Nip01Event( - pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), + Nip01Event(pubKey: "pubKey1", kind: 1, tags: [], content: "content1_a"), + Nip01Event(pubKey: "pubKey1", kind: 1, tags: [], content: "content1_b"), + Nip01Event(pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), + Nip01Event(pubKey: "pubKey1", kind: 1, tags: [], content: "content1_c"), + Nip01Event(pubKey: "pubKey2", kind: 1, tags: [], content: "content2_a"), + Nip01Event(pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), + Nip01Event(pubKey: "pubKey2", kind: 1, tags: [], content: "content2_b"), + Nip01Event(pubKey: "pubKey2", kind: 1, tags: [], content: "content2_c"), + Nip01Event(pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), + Nip01Event(pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), ]; final List expectedEvents = [ - Nip01Event( - pubKey: "pubKey1", kind: 1, tags: [], content: "content1_a"), - Nip01Event( - pubKey: "pubKey1", kind: 1, tags: [], content: "content1_b"), - Nip01Event( - pubKey: "pubKey1", kind: 1, tags: [], content: "content1_c"), - Nip01Event( - pubKey: "pubKey2", kind: 1, tags: [], content: "content2_a"), - Nip01Event( - pubKey: "pubKey2", kind: 1, tags: [], content: "content2_b"), - Nip01Event( - pubKey: "pubKey2", kind: 1, tags: [], content: "content2_c"), - Nip01Event( - pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), + Nip01Event(pubKey: "pubKey1", kind: 1, tags: [], content: "content1_a"), + Nip01Event(pubKey: "pubKey1", kind: 1, tags: [], content: "content1_b"), + Nip01Event(pubKey: "pubKey1", kind: 1, tags: [], content: "content1_c"), + Nip01Event(pubKey: "pubKey2", kind: 1, tags: [], content: "content2_a"), + Nip01Event(pubKey: "pubKey2", kind: 1, tags: [], content: "content2_b"), + Nip01Event(pubKey: "pubKey2", kind: 1, tags: [], content: "content2_c"), + Nip01Event(pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), ]; setUp(() async {}); diff --git a/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart index 2921e0557..31c776521 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,10 +19,12 @@ 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 // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { _FakeResponse_0( 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..8b14136e5 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,10 +19,12 @@ 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 // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { _FakeResponse_0( diff --git a/packages/ndk/test/usecases/stream_response_cleaner/stream_response_cleaner_test.dart b/packages/ndk/test/usecases/stream_response_cleaner/stream_response_cleaner_test.dart index f044e763b..f3a2673be 100644 --- a/packages/ndk/test/usecases/stream_response_cleaner/stream_response_cleaner_test.dart +++ b/packages/ndk/test/usecases/stream_response_cleaner/stream_response_cleaner_test.dart @@ -12,41 +12,25 @@ void main() async { tags: [], content: "content1_a", ), - Nip01Event( - pubKey: "pubKey1", kind: 1, tags: [], content: "content1_b"), - Nip01Event( - pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), - Nip01Event( - pubKey: "pubKey1", kind: 1, tags: [], content: "content1_c"), - Nip01Event( - pubKey: "pubKey2", kind: 1, tags: [], content: "content2_a"), - Nip01Event( - pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), - Nip01Event( - pubKey: "pubKey2", kind: 1, tags: [], content: "content2_b"), - Nip01Event( - pubKey: "pubKey2", kind: 1, tags: [], content: "content2_c"), - Nip01Event( - pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), - Nip01Event( - pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), + Nip01Event(pubKey: "pubKey1", kind: 1, tags: [], content: "content1_b"), + Nip01Event(pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), + Nip01Event(pubKey: "pubKey1", kind: 1, tags: [], content: "content1_c"), + Nip01Event(pubKey: "pubKey2", kind: 1, tags: [], content: "content2_a"), + Nip01Event(pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), + Nip01Event(pubKey: "pubKey2", kind: 1, tags: [], content: "content2_b"), + Nip01Event(pubKey: "pubKey2", kind: 1, tags: [], content: "content2_c"), + Nip01Event(pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), + Nip01Event(pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), ]; final List myEventsNoDublicate = [ - Nip01Event( - pubKey: "pubKey1", kind: 1, tags: [], content: "content1_a"), - Nip01Event( - pubKey: "pubKey1", kind: 1, tags: [], content: "content1_b"), - Nip01Event( - pubKey: "pubKey1", kind: 1, tags: [], content: "content1_c"), - Nip01Event( - pubKey: "pubKey2", kind: 1, tags: [], content: "content2_a"), - Nip01Event( - pubKey: "pubKey2", kind: 1, tags: [], content: "content2_b"), - Nip01Event( - pubKey: "pubKey2", kind: 1, tags: [], content: "content2_c"), - Nip01Event( - pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), + Nip01Event(pubKey: "pubKey1", kind: 1, tags: [], content: "content1_a"), + Nip01Event(pubKey: "pubKey1", kind: 1, tags: [], content: "content1_b"), + Nip01Event(pubKey: "pubKey1", kind: 1, tags: [], content: "content1_c"), + Nip01Event(pubKey: "pubKey2", kind: 1, tags: [], content: "content2_a"), + Nip01Event(pubKey: "pubKey2", kind: 1, tags: [], content: "content2_b"), + Nip01Event(pubKey: "pubKey2", kind: 1, tags: [], content: "content2_c"), + Nip01Event(pubKey: "duplicate", kind: 1, tags: [], content: "duplicate"), ]; group('stream response cleaner', () { 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..f9138f597 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,10 +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 +// ignore_for_file: invalid_use_of_internal_member + +class _FakeNip01Event_0 extends _i1.SmartFake implements _i2.Nip01Event { + _FakeNip01Event_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} /// A class which mocks [Nip01Event]. /// @@ -37,15 +49,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 +64,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 +76,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,63 +85,12 @@ 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), - returnValue: _i3.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), - returnValue: false, - ) as bool); - @override List get tTags => (super.noSuchMethod( Invocation.getter(#tTags), @@ -176,22 +110,79 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { ) as List); @override - Map toJson() => (super.noSuchMethod( + set id(String? value) => super.noSuchMethod( + Invocation.setter( + #id, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set createdAt(int? value) => super.noSuchMethod( + Invocation.setter( + #createdAt, + value, + ), + returnValueForMissingStub: null, + ); + + @override + _i2.Nip01Event copyWith({ + String? id, + String? pubKey, + int? createdAt, + int? kind, + List>? tags, + String? content, + String? sig, + bool? validSig, + List? sources, + }) => + (super.noSuchMethod( Invocation.method( - #toJson, + #copyWith, [], + { + #id: id, + #pubKey: pubKey, + #createdAt: createdAt, + #kind: kind, + #tags: tags, + #content: content, + #sig: sig, + #validSig: validSig, + #sources: sources, + }, + ), + returnValue: _FakeNip01Event_0( + this, + Invocation.method( + #copyWith, + [], + { + #id: id, + #pubKey: pubKey, + #createdAt: createdAt, + #kind: kind, + #tags: tags, + #content: content, + #sig: sig, + #validSig: validSig, + #sources: sources, + }, + ), ), - returnValue: {}, - ) as Map); + ) as _i2.Nip01Event); @override - void sign(String? privateKey) => super.noSuchMethod( + List getTags(String? tag) => (super.noSuchMethod( Invocation.method( - #sign, - [privateKey], + #getTags, + [tag], ), - returnValueForMissingStub: null, - ); + returnValue: [], + ) as List); @override String? getFirstTag(String? name) => (super.noSuchMethod(Invocation.method( diff --git a/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart b/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart index 3cfe3c00a..7a49df125 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,10 +19,12 @@ 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 // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { _FakeResponse_0( diff --git a/packages/ndk_cache_manager_test_suite/pubspec.lock b/packages/ndk_cache_manager_test_suite/pubspec.lock index a577aa35a..70cca972b 100644 --- a/packages/ndk_cache_manager_test_suite/pubspec.lock +++ b/packages/ndk_cache_manager_test_suite/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,30 @@ 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" + cbor: + dependency: transitive + description: + name: cbor + sha256: "259230d0c7f3ae58cb68cbc17b95484a038b2f63b15963b019d4bd9d28bf3fe0" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" cli_config: dependency: transitive description: @@ -153,6 +202,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + hex: + dependency: transitive + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" http: dependency: transitive description: @@ -280,6 +337,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" rxdart: dependency: transitive description: @@ -408,6 +473,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unorm_dart: + dependency: transitive + description: + name: unorm_dart + sha256: "0c69186b03ca6addab0774bcc0f4f17b88d4ce78d9d4d8f0619e30a99ead58e7" + url: "https://pub.dev" + source: hosted + version: "0.3.2" vm_service: dependency: transitive description: 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 c922149ef..f88fbbb69 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,8 +3,14 @@ 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_filter_fetched_range_record.dart'; import 'schema/db_metadata.dart'; @@ -12,6 +18,8 @@ import 'schema/db_nip_01_event.dart'; import 'schema/db_nip_05.dart'; import 'schema/db_relay_set.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(); @@ -699,4 +707,357 @@ class DbObjectBox implements CacheManager { final box = _objectBox.store.box(); box.removeAll(); } + + @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 e2d5ac69b..055b7fb25 100644 --- a/packages/objectbox/lib/objectbox-model.json +++ b/packages/objectbox/lib/objectbox-model.json @@ -4,63 +4,165 @@ "_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.", "entities": [ { - "id": "1:7267168510261043026", - "lastPropertyId": "11:1117239018948887115", + "id": "1:7592867775544645309", + "lastPropertyId": "12:8939266588558047869", + "name": "DbCashuMintInfo", + "properties": [ + { + "id": "1:1926070934503982709", + "name": "dbId", + "type": 6, + "flags": 1 + }, + { + "id": "2:8202409475444264342", + "name": "name", + "type": 9 + }, + { + "id": "3:5868461213949507083", + "name": "version", + "type": 9 + }, + { + "id": "4:4584113970264026558", + "name": "description", + "type": 9 + }, + { + "id": "5:416469890254505372", + "name": "descriptionLong", + "type": 9 + }, + { + "id": "6:6638283633967316399", + "name": "contactJson", + "type": 9 + }, + { + "id": "7:199246036062464406", + "name": "motd", + "type": 9 + }, + { + "id": "8:5666392655471259045", + "name": "iconUrl", + "type": 9 + }, + { + "id": "9:241356087535411", + "name": "urls", + "type": 30 + }, + { + "id": "10:6574721548278630688", + "name": "time", + "type": 6 + }, + { + "id": "11:4656087220643162481", + "name": "tosUrl", + "type": 9 + }, + { + "id": "12:8939266588558047869", + "name": "nutsJson", + "type": 9 + } + ], + "relations": [] + }, + { + "id": "2:7845454469685391457", + "lastPropertyId": "4:5028433459899776778", + "name": "DbCashuSecretCounter", + "properties": [ + { + "id": "1:5317832390354766454", + "name": "dbId", + "type": 6, + "flags": 1 + }, + { + "id": "2:1847184082512656053", + "name": "mintUrl", + "indexId": "1:1911037218651774884", + "type": 9, + "flags": 2080 + }, + { + "id": "3:7851641439071668450", + "name": "keysetId", + "indexId": "2:8172994742854276515", + "type": 9, + "flags": 2080 + }, + { + "id": "4:5028433459899776778", + "name": "counter", + "type": 6 + } + ], + "relations": [] + }, + { + "id": "3:8238732403728490559", + "lastPropertyId": "11:8725972145149385895", "name": "DbContactList", "properties": [ { - "id": "1:6986744434432699288", + "id": "1:4750851023770703934", "name": "dbId", "type": 6, "flags": 1 }, { - "id": "2:1357400473715190005", + "id": "2:2970850124681178133", "name": "pubKey", "type": 9 }, { - "id": "3:5247455000660751531", + "id": "3:4871624721818733013", "name": "contacts", "type": 30 }, { - "id": "4:7259358756009996880", + "id": "4:1476162521943253975", "name": "contactRelays", "type": 30 }, { - "id": "5:4273369131818739468", + "id": "5:3270384859871645394", "name": "petnames", "type": 30 }, { - "id": "6:2282344906672001423", + "id": "6:2083503341309142897", "name": "followedTags", "type": 30 }, { - "id": "7:4195168652292271612", + "id": "7:8446939288033956129", "name": "followedCommunities", "type": 30 }, { - "id": "8:3312722063406963894", + "id": "8:4144128592711718816", "name": "followedEvents", "type": 30 }, { - "id": "9:1828915220935170355", + "id": "9:6977235861512632889", "name": "createdAt", "type": 6 }, { - "id": "10:662334951917934052", + "id": "10:7166437026316853925", "name": "loadedTimestamp", "type": 6 }, { - "id": "11:1117239018948887115", + "id": "11:8725972145149385895", "name": "sources", "type": 30 } @@ -68,83 +170,121 @@ "relations": [] }, { - "id": "2:530428573583615038", - "lastPropertyId": "15:3659729329624536988", + "id": "4:7794609063824417506", + "lastPropertyId": "5:155541120837720189", + "name": "DbFilterFetchedRangeRecord", + "properties": [ + { + "id": "1:8239293647171901339", + "name": "dbId", + "type": 6, + "flags": 1 + }, + { + "id": "2:7813497467688250578", + "name": "filterHash", + "indexId": "3:1478881509205360758", + "type": 9, + "flags": 2048 + }, + { + "id": "3:5165300811796977703", + "name": "relayUrl", + "indexId": "4:1555333123571689167", + "type": 9, + "flags": 2048 + }, + { + "id": "4:4025236681264587842", + "name": "rangeStart", + "type": 6 + }, + { + "id": "5:155541120837720189", + "name": "rangeEnd", + "type": 6 + } + ], + "relations": [] + }, + { + "id": "5:9064926152786621222", + "lastPropertyId": "15:5509990970229448774", "name": "DbMetadata", "properties": [ { - "id": "1:6311528020388961921", + "id": "1:450683652371700125", "name": "dbId", "type": 6, "flags": 1 }, { - "id": "2:7481035913984486655", + "id": "2:2857497255782734464", "name": "pubKey", "type": 9 }, { - "id": "3:9172997580341748819", + "id": "3:7623815323313259867", "name": "name", "type": 9 }, { - "id": "4:3546373795758858754", + "id": "4:1381810543183688149", "name": "displayName", "type": 9 }, { - "id": "5:3230539604094051327", + "id": "5:4381025848929777664", "name": "picture", "type": 9 }, { - "id": "6:3084473881747351979", + "id": "6:4794682079540816652", "name": "banner", "type": 9 }, { - "id": "7:2993268374284627402", + "id": "7:4238921501933494324", "name": "website", "type": 9 }, { - "id": "8:2895930692931049587", + "id": "8:7241819773163446210", "name": "about", "type": 9 }, { - "id": "9:2125436107011149884", + "id": "9:1286893692493945079", "name": "nip05", "type": 9 }, { - "id": "10:1537952694209901022", + "id": "10:4699193054236086522", "name": "lud16", "type": 9 }, { - "id": "11:4250356651761253102", + "id": "11:5875544113614901314", "name": "lud06", "type": 9 }, { - "id": "12:4824960073052435758", + "id": "12:8376614950670629873", "name": "updatedAt", "type": 6 }, { - "id": "13:1741276455180885874", + "id": "13:4877399826588280167", "name": "refreshedTimestamp", "type": 6 }, { - "id": "14:1086893591781785038", + "id": "14:640129627569564475", "name": "splitDisplayNameWords", "type": 30 }, { - "id": "15:3659729329624536988", + "id": "15:5509990970229448774", "name": "splitNameWords", "type": 30 } @@ -152,58 +292,58 @@ "relations": [] }, { - "id": "3:7160354677947505848", - "lastPropertyId": "10:6188110795031782335", + "id": "6:8334597869599241879", + "lastPropertyId": "10:2871776445535465378", "name": "DbNip01Event", "properties": [ { - "id": "1:1845247413054177411", + "id": "1:962456587806812881", "name": "dbId", "type": 6, "flags": 1 }, { - "id": "2:3881479899267615466", + "id": "2:6945342781559853617", "name": "nostrId", "type": 9 }, { - "id": "3:906982549236467078", + "id": "3:2770474181274533360", "name": "pubKey", "type": 9 }, { - "id": "4:4024855326378855057", + "id": "4:6905901668334297481", "name": "createdAt", "type": 6 }, { - "id": "5:8369487538579223995", + "id": "5:3375852134934975827", "name": "kind", "type": 6 }, { - "id": "6:4676453295471475548", + "id": "6:8176013718481620196", "name": "content", "type": 9 }, { - "id": "7:9113759858694952977", + "id": "7:5760771273711769653", "name": "sig", "type": 9 }, { - "id": "8:2027711114854456160", + "id": "8:5018775056440549164", "name": "validSig", "type": 1 }, { - "id": "9:7564063012610719918", + "id": "9:7626125256230985639", "name": "sources", "type": 30 }, { - "id": "10:6188110795031782335", + "id": "10:2871776445535465378", "name": "dbTags", "type": 30 } @@ -211,106 +351,157 @@ "relations": [] }, { - "id": "4:3637320921488077827", - "lastPropertyId": "5:3179649802820230952", - "name": "DbTag", + "id": "7:5648957138558329159", + "lastPropertyId": "6:9210345680519567987", + "name": "DbNip05", "properties": [ { - "id": "1:2662554970568175356", - "name": "id", + "id": "1:1931366387192332672", + "name": "dbId", "type": 6, "flags": 1 }, { - "id": "2:7256594753475161899", - "name": "key", + "id": "2:1669435324271492158", + "name": "pubKey", "type": 9 }, { - "id": "3:7261401074391147060", - "name": "value", + "id": "3:5232511080851688040", + "name": "nip05", "type": 9 }, { - "id": "4:1024563472021235903", - "name": "marker", - "type": 9 + "id": "4:6899992689051259596", + "name": "valid", + "type": 1 }, { - "id": "5:3179649802820230952", - "name": "elements", + "id": "5:5350198661550387825", + "name": "networkFetchTime", + "type": 6 + }, + { + "id": "6:9210345680519567987", + "name": "relays", "type": 30 } ], "relations": [] }, { - "id": "5:1189162834702422075", - "lastPropertyId": "7:8942013022024139638", - "name": "DbNip05", + "id": "8:798936456745188694", + "lastPropertyId": "8:1950925954697465501", + "name": "DbRelaySet", "properties": [ { - "id": "1:7969165770416025296", + "id": "1:3730866184852689174", "name": "dbId", "type": 6, "flags": 1 }, { - "id": "2:7645157164222799699", + "id": "2:991552949906027655", + "name": "id", + "indexId": "5:6532412047047265516", + "type": 9, + "flags": 2080 + }, + { + "id": "3:5901118780161912283", + "name": "name", + "type": 9 + }, + { + "id": "4:3807698391663325712", "name": "pubKey", "type": 9 }, { - "id": "3:7879974338560469443", - "name": "nip05", + "id": "5:5691193017308438368", + "name": "relayMinCountPerPubkey", + "type": 6 + }, + { + "id": "6:8535256893585175074", + "name": "direction", "type": 9 }, { - "id": "4:5481522983626357888", - "name": "valid", + "id": "7:608203675411349889", + "name": "relaysMapJson", + "type": 9 + }, + { + "id": "8:1950925954697465501", + "name": "fallbackToBootstrapRelays", "type": 1 + } + ], + "relations": [] + }, + { + "id": "9:8083596691263211999", + "lastPropertyId": "5:8749480139901422655", + "name": "DbTag", + "properties": [ + { + "id": "1:8038806218169288086", + "name": "id", + "type": 6, + "flags": 1 }, { - "id": "6:5240456446636403236", - "name": "networkFetchTime", - "type": 6 + "id": "2:7454775389295718067", + "name": "key", + "type": 9 }, { - "id": "7:8942013022024139638", - "name": "relays", + "id": "3:6669959113334473000", + "name": "value", + "type": 9 + }, + { + "id": "4:4324982566098642170", + "name": "marker", + "type": 9 + }, + { + "id": "5:8749480139901422655", + "name": "elements", "type": 30 } ], "relations": [] }, { - "id": "6:263734506821907740", - "lastPropertyId": "5:745081192237571667", + "id": "10:1139647102897446439", + "lastPropertyId": "5:6752102235252204032", "name": "DbUserRelayList", "properties": [ { - "id": "1:1592738392109903014", + "id": "1:8442782738051343599", "name": "dbId", "type": 6, "flags": 1 }, { - "id": "2:4136737139372327801", + "id": "2:1831797428858970543", "name": "pubKey", "type": 9 }, { - "id": "3:3907673358109208090", + "id": "3:3458168862026163187", "name": "createdAt", "type": 6 }, { - "id": "4:6745786677378578982", + "id": "4:9180445425939406931", "name": "refreshedTimestamp", "type": 6 }, { - "id": "5:745081192237571667", + "id": "5:6752102235252204032", "name": "relaysJson", "type": 9 } @@ -318,106 +509,206 @@ "relations": [] }, { - "id": "7:4509209106406578683", - "lastPropertyId": "8:5182878265676463435", - "name": "DbRelaySet", + "id": "11:4875422432875860957", + "lastPropertyId": "6:6187257598208066222", + "name": "DbWallet", "properties": [ { - "id": "1:3992132548215188818", + "id": "1:2463904194719547744", "name": "dbId", "type": 6, "flags": 1 }, { - "id": "2:8697843502175770683", + "id": "2:40915183695241570", "name": "id", - "indexId": "1:3132180412806400476", - "type": 9, - "flags": 2080 + "type": 9 + }, + { + "id": "3:819710555442309728", + "name": "type", + "type": 9 }, { - "id": "3:847921017835631159", + "id": "4:5161138444924265698", + "name": "supportedUnits", + "type": 30 + }, + { + "id": "5:7892071503611618461", "name": "name", "type": 9 }, { - "id": "4:139189138337679953", - "name": "pubKey", + "id": "6:6187257598208066222", + "name": "metadataJsonString", "type": 9 + } + ], + "relations": [] + }, + { + "id": "12:2226120528162100661", + "lastPropertyId": "8:3237734292052434749", + "name": "DbWalletCahsuKeyset", + "properties": [ + { + "id": "1:3277880158710242158", + "name": "dbId", + "type": 6, + "flags": 1 }, { - "id": "5:6817323610958374225", - "name": "relayMinCountPerPubkey", - "type": 6 + "id": "2:7356458643894973159", + "name": "id", + "type": 9 }, { - "id": "6:1830474112617634546", - "name": "direction", + "id": "3:6161811427300449057", + "name": "mintUrl", "type": 9 }, { - "id": "7:1825872609928641993", - "name": "relaysMapJson", + "id": "4:5867231186242706802", + "name": "unit", "type": 9 }, { - "id": "8:5182878265676463435", - "name": "fallbackToBootstrapRelays", + "id": "5:4153305417138419934", + "name": "active", "type": 1 + }, + { + "id": "6:5373428266765836330", + "name": "inputFeePPK", + "type": 6 + }, + { + "id": "7:102274839190695701", + "name": "mintKeyPairs", + "type": 30 + }, + { + "id": "8:3237734292052434749", + "name": "fetchedAt", + "type": 6 } ], "relations": [] }, { - "id": "8:2356961977798202534", - "lastPropertyId": "5:849430668794703479", - "name": "DbFilterFetchedRangeRecord", + "id": "13:4648979430576924242", + "lastPropertyId": "6:1283926461310942662", + "name": "DbWalletCashuProof", "properties": [ { - "id": "1:3939782810311199282", + "id": "1:1983751238131447437", "name": "dbId", "type": 6, "flags": 1 }, { - "id": "2:3545576993042908087", - "name": "filterHash", - "indexId": "2:758461294333956924", - "type": 9, - "flags": 2048 + "id": "2:9109961458435310074", + "name": "keysetId", + "type": 9 }, { - "id": "3:1898730736240140766", - "name": "relayUrl", - "indexId": "3:735436971837042211", - "type": 9, - "flags": 2048 + "id": "3:2692029993503780553", + "name": "amount", + "type": 6 }, { - "id": "4:6718626507552401445", - "name": "rangeStart", + "id": "4:8723877768739229313", + "name": "secret", + "type": 9 + }, + { + "id": "5:1096942106286695045", + "name": "unblindedSig", + "type": 9 + }, + { + "id": "6:1283926461310942662", + "name": "state", + "type": 9 + } + ], + "relations": [] + }, + { + "id": "14:4149711010520675264", + "lastPropertyId": "11:4709299339516840484", + "name": "DbWalletTransaction", + "properties": [ + { + "id": "1:1329597633054929515", + "name": "dbId", + "type": 6, + "flags": 1 + }, + { + "id": "2:1399303250510831756", + "name": "id", + "type": 9 + }, + { + "id": "3:6260811896451838534", + "name": "walletId", + "type": 9 + }, + { + "id": "4:674856260732666819", + "name": "changeAmount", "type": 6 }, { - "id": "5:849430668794703479", - "name": "rangeEnd", + "id": "5:8962906318528279016", + "name": "unit", + "type": 9 + }, + { + "id": "6:5146526142177936726", + "name": "walletType", + "type": 9 + }, + { + "id": "7:9076753469586209630", + "name": "state", + "type": 9 + }, + { + "id": "8:3451456431311591509", + "name": "completionMsg", + "type": 9 + }, + { + "id": "9:5198461071585146211", + "name": "transactionDate", "type": 6 + }, + { + "id": "10:7201684274987088163", + "name": "initiatedDate", + "type": 6 + }, + { + "id": "11:4709299339516840484", + "name": "metadataJsonString", + "type": 9 } ], "relations": [] } ], - "lastEntityId": "8:2356961977798202534", - "lastIndexId": "3:735436971837042211", + "lastEntityId": "14:4149711010520675264", + "lastIndexId": "5:6532412047047265516", "lastRelationId": "0:0", "lastSequenceId": "0:0", "modelVersion": 5, "modelVersionParserMinimum": 5, "retiredEntityUids": [], "retiredIndexUids": [], - "retiredPropertyUids": [ - 4248118904091022656 - ], + "retiredPropertyUids": [], "retiredRelationUids": [], "version": 1 } \ No newline at end of file diff --git a/packages/objectbox/lib/objectbox.g.dart b/packages/objectbox/lib/objectbox.g.dart index 54fbd71c6..c9ba5529a 100644 --- a/packages/objectbox/lib/objectbox.g.dart +++ b/packages/objectbox/lib/objectbox.g.dart @@ -14,6 +14,10 @@ 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_filter_fetched_range_record.dart'; import 'data_layer/db/object_box/schema/db_metadata.dart'; @@ -21,78 +25,198 @@ 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_relay_set.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), + id: const obx_int.IdUid(1, 7592867775544645309), + name: 'DbCashuMintInfo', + lastPropertyId: const obx_int.IdUid(12, 8939266588558047869), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 1926070934503982709), + name: 'dbId', + type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 8202409475444264342), + name: 'name', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 5868461213949507083), + name: 'version', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 4584113970264026558), + name: 'description', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 416469890254505372), + name: 'descriptionLong', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 6638283633967316399), + name: 'contactJson', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 199246036062464406), + name: 'motd', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(8, 5666392655471259045), + name: 'iconUrl', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(9, 241356087535411), + name: 'urls', + type: 30, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(10, 6574721548278630688), + name: 'time', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(11, 4656087220643162481), + name: 'tosUrl', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(12, 8939266588558047869), + name: 'nutsJson', + type: 9, + flags: 0, + ), + ], + relations: [], + backlinks: [], + ), + obx_int.ModelEntity( + id: const obx_int.IdUid(2, 7845454469685391457), + name: 'DbCashuSecretCounter', + lastPropertyId: const obx_int.IdUid(4, 5028433459899776778), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 5317832390354766454), + name: 'dbId', + type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 1847184082512656053), + name: 'mintUrl', + type: 9, + flags: 2080, + indexId: const obx_int.IdUid(1, 1911037218651774884), + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 7851641439071668450), + name: 'keysetId', + type: 9, + flags: 2080, + indexId: const obx_int.IdUid(2, 8172994742854276515), + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 5028433459899776778), + name: 'counter', + type: 6, + flags: 0, + ), + ], + relations: [], + backlinks: [], + ), + obx_int.ModelEntity( + id: const obx_int.IdUid(3, 8238732403728490559), name: 'DbContactList', - lastPropertyId: const obx_int.IdUid(11, 1117239018948887115), + lastPropertyId: const obx_int.IdUid(11, 8725972145149385895), flags: 0, properties: [ obx_int.ModelProperty( - id: const obx_int.IdUid(1, 6986744434432699288), + id: const obx_int.IdUid(1, 4750851023770703934), name: 'dbId', type: 6, flags: 1, ), obx_int.ModelProperty( - id: const obx_int.IdUid(2, 1357400473715190005), + id: const obx_int.IdUid(2, 2970850124681178133), name: 'pubKey', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(3, 5247455000660751531), + id: const obx_int.IdUid(3, 4871624721818733013), name: 'contacts', type: 30, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(4, 7259358756009996880), + id: const obx_int.IdUid(4, 1476162521943253975), name: 'contactRelays', type: 30, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(5, 4273369131818739468), + id: const obx_int.IdUid(5, 3270384859871645394), name: 'petnames', type: 30, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(6, 2282344906672001423), + id: const obx_int.IdUid(6, 2083503341309142897), name: 'followedTags', type: 30, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(7, 4195168652292271612), + id: const obx_int.IdUid(7, 8446939288033956129), name: 'followedCommunities', type: 30, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(8, 3312722063406963894), + id: const obx_int.IdUid(8, 4144128592711718816), name: 'followedEvents', type: 30, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(9, 1828915220935170355), + id: const obx_int.IdUid(9, 6977235861512632889), name: 'createdAt', type: 6, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(10, 662334951917934052), + id: const obx_int.IdUid(10, 7166437026316853925), name: 'loadedTimestamp', type: 6, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(11, 1117239018948887115), + id: const obx_int.IdUid(11, 8725972145149385895), name: 'sources', type: 30, flags: 0, @@ -102,97 +226,139 @@ final _entities = [ backlinks: [], ), obx_int.ModelEntity( - id: const obx_int.IdUid(2, 530428573583615038), + id: const obx_int.IdUid(4, 7794609063824417506), + name: 'DbFilterFetchedRangeRecord', + lastPropertyId: const obx_int.IdUid(5, 155541120837720189), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 8239293647171901339), + name: 'dbId', + type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 7813497467688250578), + name: 'filterHash', + type: 9, + flags: 2048, + indexId: const obx_int.IdUid(3, 1478881509205360758), + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 5165300811796977703), + name: 'relayUrl', + type: 9, + flags: 2048, + indexId: const obx_int.IdUid(4, 1555333123571689167), + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 4025236681264587842), + name: 'rangeStart', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 155541120837720189), + name: 'rangeEnd', + type: 6, + flags: 0, + ), + ], + relations: [], + backlinks: [], + ), + obx_int.ModelEntity( + id: const obx_int.IdUid(5, 9064926152786621222), name: 'DbMetadata', - lastPropertyId: const obx_int.IdUid(15, 3659729329624536988), + lastPropertyId: const obx_int.IdUid(15, 5509990970229448774), flags: 0, properties: [ obx_int.ModelProperty( - id: const obx_int.IdUid(1, 6311528020388961921), + id: const obx_int.IdUid(1, 450683652371700125), name: 'dbId', type: 6, flags: 1, ), obx_int.ModelProperty( - id: const obx_int.IdUid(2, 7481035913984486655), + id: const obx_int.IdUid(2, 2857497255782734464), name: 'pubKey', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(3, 9172997580341748819), + id: const obx_int.IdUid(3, 7623815323313259867), name: 'name', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(4, 3546373795758858754), + id: const obx_int.IdUid(4, 1381810543183688149), name: 'displayName', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(5, 3230539604094051327), + id: const obx_int.IdUid(5, 4381025848929777664), name: 'picture', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(6, 3084473881747351979), + id: const obx_int.IdUid(6, 4794682079540816652), name: 'banner', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(7, 2993268374284627402), + id: const obx_int.IdUid(7, 4238921501933494324), name: 'website', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(8, 2895930692931049587), + id: const obx_int.IdUid(8, 7241819773163446210), name: 'about', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(9, 2125436107011149884), + id: const obx_int.IdUid(9, 1286893692493945079), name: 'nip05', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(10, 1537952694209901022), + id: const obx_int.IdUid(10, 4699193054236086522), name: 'lud16', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(11, 4250356651761253102), + id: const obx_int.IdUid(11, 5875544113614901314), name: 'lud06', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(12, 4824960073052435758), + id: const obx_int.IdUid(12, 8376614950670629873), name: 'updatedAt', type: 6, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(13, 1741276455180885874), + id: const obx_int.IdUid(13, 4877399826588280167), name: 'refreshedTimestamp', type: 6, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(14, 1086893591781785038), + id: const obx_int.IdUid(14, 640129627569564475), name: 'splitDisplayNameWords', type: 30, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(15, 3659729329624536988), + id: const obx_int.IdUid(15, 5509990970229448774), name: 'splitNameWords', type: 30, flags: 0, @@ -202,67 +368,67 @@ final _entities = [ backlinks: [], ), obx_int.ModelEntity( - id: const obx_int.IdUid(3, 7160354677947505848), + id: const obx_int.IdUid(6, 8334597869599241879), name: 'DbNip01Event', - lastPropertyId: const obx_int.IdUid(10, 6188110795031782335), + lastPropertyId: const obx_int.IdUid(10, 2871776445535465378), flags: 0, properties: [ obx_int.ModelProperty( - id: const obx_int.IdUid(1, 1845247413054177411), + id: const obx_int.IdUid(1, 962456587806812881), name: 'dbId', type: 6, flags: 1, ), obx_int.ModelProperty( - id: const obx_int.IdUid(2, 3881479899267615466), + id: const obx_int.IdUid(2, 6945342781559853617), name: 'nostrId', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(3, 906982549236467078), + id: const obx_int.IdUid(3, 2770474181274533360), name: 'pubKey', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(4, 4024855326378855057), + id: const obx_int.IdUid(4, 6905901668334297481), name: 'createdAt', type: 6, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(5, 8369487538579223995), + id: const obx_int.IdUid(5, 3375852134934975827), name: 'kind', type: 6, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(6, 4676453295471475548), + id: const obx_int.IdUid(6, 8176013718481620196), name: 'content', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(7, 9113759858694952977), + id: const obx_int.IdUid(7, 5760771273711769653), name: 'sig', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(8, 2027711114854456160), + id: const obx_int.IdUid(8, 5018775056440549164), name: 'validSig', type: 1, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(9, 7564063012610719918), + id: const obx_int.IdUid(9, 7626125256230985639), name: 'sources', type: 30, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(10, 6188110795031782335), + id: const obx_int.IdUid(10, 2871776445535465378), name: 'dbTags', type: 30, flags: 0, @@ -272,38 +438,44 @@ final _entities = [ backlinks: [], ), obx_int.ModelEntity( - id: const obx_int.IdUid(4, 3637320921488077827), - name: 'DbTag', - lastPropertyId: const obx_int.IdUid(5, 3179649802820230952), + id: const obx_int.IdUid(7, 5648957138558329159), + name: 'DbNip05', + lastPropertyId: const obx_int.IdUid(6, 9210345680519567987), flags: 0, properties: [ obx_int.ModelProperty( - id: const obx_int.IdUid(1, 2662554970568175356), - name: 'id', + id: const obx_int.IdUid(1, 1931366387192332672), + name: 'dbId', type: 6, flags: 1, ), obx_int.ModelProperty( - id: const obx_int.IdUid(2, 7256594753475161899), - name: 'key', + id: const obx_int.IdUid(2, 1669435324271492158), + name: 'pubKey', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(3, 7261401074391147060), - name: 'value', + id: const obx_int.IdUid(3, 5232511080851688040), + name: 'nip05', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(4, 1024563472021235903), - name: 'marker', - type: 9, + id: const obx_int.IdUid(4, 6899992689051259596), + name: 'valid', + type: 1, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(5, 3179649802820230952), - name: 'elements', + id: const obx_int.IdUid(5, 5350198661550387825), + name: 'networkFetchTime', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 9210345680519567987), + name: 'relays', type: 30, flags: 0, ), @@ -312,44 +484,97 @@ final _entities = [ backlinks: [], ), obx_int.ModelEntity( - id: const obx_int.IdUid(5, 1189162834702422075), - name: 'DbNip05', - lastPropertyId: const obx_int.IdUid(7, 8942013022024139638), + id: const obx_int.IdUid(8, 798936456745188694), + name: 'DbRelaySet', + lastPropertyId: const obx_int.IdUid(8, 1950925954697465501), flags: 0, properties: [ obx_int.ModelProperty( - id: const obx_int.IdUid(1, 7969165770416025296), + id: const obx_int.IdUid(1, 3730866184852689174), name: 'dbId', type: 6, flags: 1, ), obx_int.ModelProperty( - id: const obx_int.IdUid(2, 7645157164222799699), + id: const obx_int.IdUid(2, 991552949906027655), + name: 'id', + type: 9, + flags: 2080, + indexId: const obx_int.IdUid(5, 6532412047047265516), + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 5901118780161912283), + name: 'name', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 3807698391663325712), name: 'pubKey', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(3, 7879974338560469443), - name: 'nip05', + id: const obx_int.IdUid(5, 5691193017308438368), + name: 'relayMinCountPerPubkey', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 8535256893585175074), + name: 'direction', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(4, 5481522983626357888), - name: 'valid', + id: const obx_int.IdUid(7, 608203675411349889), + name: 'relaysMapJson', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(8, 1950925954697465501), + name: 'fallbackToBootstrapRelays', type: 1, flags: 0, ), + ], + relations: [], + backlinks: [], + ), + obx_int.ModelEntity( + id: const obx_int.IdUid(9, 8083596691263211999), + name: 'DbTag', + lastPropertyId: const obx_int.IdUid(5, 8749480139901422655), + flags: 0, + properties: [ obx_int.ModelProperty( - id: const obx_int.IdUid(6, 5240456446636403236), - name: 'networkFetchTime', + id: const obx_int.IdUid(1, 8038806218169288086), + name: 'id', type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 7454775389295718067), + name: 'key', + type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(7, 8942013022024139638), - name: 'relays', + id: const obx_int.IdUid(3, 6669959113334473000), + name: 'value', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 4324982566098642170), + name: 'marker', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 8749480139901422655), + name: 'elements', type: 30, flags: 0, ), @@ -358,37 +583,37 @@ final _entities = [ backlinks: [], ), obx_int.ModelEntity( - id: const obx_int.IdUid(6, 263734506821907740), + id: const obx_int.IdUid(10, 1139647102897446439), name: 'DbUserRelayList', - lastPropertyId: const obx_int.IdUid(5, 745081192237571667), + lastPropertyId: const obx_int.IdUid(5, 6752102235252204032), flags: 0, properties: [ obx_int.ModelProperty( - id: const obx_int.IdUid(1, 1592738392109903014), + id: const obx_int.IdUid(1, 8442782738051343599), name: 'dbId', type: 6, flags: 1, ), obx_int.ModelProperty( - id: const obx_int.IdUid(2, 4136737139372327801), + id: const obx_int.IdUid(2, 1831797428858970543), name: 'pubKey', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(3, 3907673358109208090), + id: const obx_int.IdUid(3, 3458168862026163187), name: 'createdAt', type: 6, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(4, 6745786677378578982), + id: const obx_int.IdUid(4, 9180445425939406931), name: 'refreshedTimestamp', type: 6, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(5, 745081192237571667), + id: const obx_int.IdUid(5, 6752102235252204032), name: 'relaysJson', type: 9, flags: 0, @@ -398,118 +623,243 @@ final _entities = [ backlinks: [], ), obx_int.ModelEntity( - id: const obx_int.IdUid(7, 4509209106406578683), - name: 'DbRelaySet', - lastPropertyId: const obx_int.IdUid(8, 5182878265676463435), + id: const obx_int.IdUid(11, 4875422432875860957), + name: 'DbWallet', + lastPropertyId: const obx_int.IdUid(6, 6187257598208066222), flags: 0, properties: [ obx_int.ModelProperty( - id: const obx_int.IdUid(1, 3992132548215188818), + id: const obx_int.IdUid(1, 2463904194719547744), name: 'dbId', type: 6, flags: 1, ), obx_int.ModelProperty( - id: const obx_int.IdUid(2, 8697843502175770683), + id: const obx_int.IdUid(2, 40915183695241570), name: 'id', type: 9, - flags: 2080, - indexId: const obx_int.IdUid(1, 3132180412806400476), + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 819710555442309728), + name: 'type', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 5161138444924265698), + name: 'supportedUnits', + type: 30, + flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(3, 847921017835631159), + id: const obx_int.IdUid(5, 7892071503611618461), name: 'name', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(4, 139189138337679953), - name: 'pubKey', + id: const obx_int.IdUid(6, 6187257598208066222), + name: 'metadataJsonString', type: 9, flags: 0, ), + ], + relations: [], + backlinks: [], + ), + obx_int.ModelEntity( + id: const obx_int.IdUid(12, 2226120528162100661), + name: 'DbWalletCahsuKeyset', + lastPropertyId: const obx_int.IdUid(8, 3237734292052434749), + flags: 0, + properties: [ obx_int.ModelProperty( - id: const obx_int.IdUid(5, 6817323610958374225), - name: 'relayMinCountPerPubkey', + id: const obx_int.IdUid(1, 3277880158710242158), + name: 'dbId', type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 7356458643894973159), + name: 'id', + type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(6, 1830474112617634546), - name: 'direction', + id: const obx_int.IdUid(3, 6161811427300449057), + name: 'mintUrl', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(7, 1825872609928641993), - name: 'relaysMapJson', + id: const obx_int.IdUid(4, 5867231186242706802), + name: 'unit', type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(8, 5182878265676463435), - name: 'fallbackToBootstrapRelays', + id: const obx_int.IdUid(5, 4153305417138419934), + name: 'active', type: 1, flags: 0, ), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 5373428266765836330), + name: 'inputFeePPK', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 102274839190695701), + name: 'mintKeyPairs', + type: 30, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(8, 3237734292052434749), + name: 'fetchedAt', + type: 6, + flags: 0, + ), ], relations: [], backlinks: [], ), obx_int.ModelEntity( - id: const obx_int.IdUid(8, 2356961977798202534), - name: 'DbFilterFetchedRangeRecord', - lastPropertyId: const obx_int.IdUid(5, 849430668794703479), + id: const obx_int.IdUid(13, 4648979430576924242), + name: 'DbWalletCashuProof', + lastPropertyId: const obx_int.IdUid(6, 1283926461310942662), flags: 0, properties: [ obx_int.ModelProperty( - id: const obx_int.IdUid(1, 3939782810311199282), + id: const obx_int.IdUid(1, 1983751238131447437), name: 'dbId', type: 6, flags: 1, ), obx_int.ModelProperty( - id: const obx_int.IdUid(2, 3545576993042908087), - name: 'filterHash', + id: const obx_int.IdUid(2, 9109961458435310074), + name: 'keysetId', type: 9, - flags: 2048, - indexId: const obx_int.IdUid(2, 758461294333956924), + flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(3, 1898730736240140766), - name: 'relayUrl', + id: const obx_int.IdUid(3, 2692029993503780553), + name: 'amount', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 8723877768739229313), + name: 'secret', type: 9, - flags: 2048, - indexId: const obx_int.IdUid(3, 735436971837042211), + flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(4, 6718626507552401445), - name: 'rangeStart', - type: 6, + id: const obx_int.IdUid(5, 1096942106286695045), + name: 'unblindedSig', + type: 9, flags: 0, ), obx_int.ModelProperty( - id: const obx_int.IdUid(5, 849430668794703479), - name: 'rangeEnd', - type: 6, + id: const obx_int.IdUid(6, 1283926461310942662), + name: 'state', + type: 9, flags: 0, ), ], relations: [], backlinks: [], ), -]; - -/// Shortcut for [obx.Store.new] that passes [getObjectBoxModel] and for Flutter -/// apps by default a [directory] using `defaultStoreDirectory()` from the -/// ObjectBox Flutter library. -/// -/// Note: for desktop apps it is recommended to specify a unique [directory]. -/// -/// See [obx.Store.new] for an explanation of all parameters. -/// -/// For Flutter apps, also calls `loadObjectBoxLibraryAndroidCompat()` from -/// the ObjectBox Flutter library to fix loading the native ObjectBox library + obx_int.ModelEntity( + id: const obx_int.IdUid(14, 4149711010520675264), + name: 'DbWalletTransaction', + lastPropertyId: const obx_int.IdUid(11, 4709299339516840484), + flags: 0, + properties: [ + obx_int.ModelProperty( + id: const obx_int.IdUid(1, 1329597633054929515), + name: 'dbId', + type: 6, + flags: 1, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(2, 1399303250510831756), + name: 'id', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(3, 6260811896451838534), + name: 'walletId', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(4, 674856260732666819), + name: 'changeAmount', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(5, 8962906318528279016), + name: 'unit', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(6, 5146526142177936726), + name: 'walletType', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(7, 9076753469586209630), + name: 'state', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(8, 3451456431311591509), + name: 'completionMsg', + type: 9, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(9, 5198461071585146211), + name: 'transactionDate', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(10, 7201684274987088163), + name: 'initiatedDate', + type: 6, + flags: 0, + ), + obx_int.ModelProperty( + id: const obx_int.IdUid(11, 4709299339516840484), + name: 'metadataJsonString', + type: 9, + flags: 0, + ), + ], + relations: [], + backlinks: [], + ), +]; + +/// Shortcut for [obx.Store.new] that passes [getObjectBoxModel] and for Flutter +/// apps by default a [directory] using `defaultStoreDirectory()` from the +/// ObjectBox Flutter library. +/// +/// Note: for desktop apps it is recommended to specify a unique [directory]. +/// +/// See [obx.Store.new] for an explanation of all parameters. +/// +/// 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, @@ -538,13 +888,13 @@ Future openStore({ obx_int.ModelDefinition getObjectBoxModel() { final model = obx_int.ModelInfo( entities: _entities, - lastEntityId: const obx_int.IdUid(8, 2356961977798202534), - lastIndexId: const obx_int.IdUid(3, 735436971837042211), + lastEntityId: const obx_int.IdUid(14, 4149711010520675264), + lastIndexId: const obx_int.IdUid(5, 6532412047047265516), lastRelationId: const obx_int.IdUid(0, 0), lastSequenceId: const obx_int.IdUid(0, 0), retiredEntityUids: const [], retiredIndexUids: const [], - retiredPropertyUids: const [4248118904091022656], + retiredPropertyUids: const [], retiredRelationUids: const [], modelVersion: 5, modelVersionParserMinimum: 5, @@ -552,8 +902,158 @@ obx_int.ModelDefinition getObjectBoxModel() { ); final bindings = { - DbContactList: obx_int.EntityDefinition( + DbCashuMintInfo: obx_int.EntityDefinition( model: _entities[0], + 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[1], + 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; + }, + ), + DbContactList: obx_int.EntityDefinition( + model: _entities[2], toOneRelations: (DbContactList object) => [], toManyRelations: (DbContactList object) => {}, getId: (DbContactList object) => object.dbId, @@ -657,8 +1157,67 @@ obx_int.ModelDefinition getObjectBoxModel() { return object; }, ), + DbFilterFetchedRangeRecord: + obx_int.EntityDefinition( + model: _entities[3], + toOneRelations: (DbFilterFetchedRangeRecord object) => [], + toManyRelations: (DbFilterFetchedRangeRecord object) => {}, + getId: (DbFilterFetchedRangeRecord object) => object.dbId, + setId: (DbFilterFetchedRangeRecord object, int id) { + object.dbId = id; + }, + objectToFB: (DbFilterFetchedRangeRecord object, fb.Builder fbb) { + final filterHashOffset = fbb.writeString(object.filterHash); + final relayUrlOffset = fbb.writeString(object.relayUrl); + fbb.startTable(6); + fbb.addInt64(0, object.dbId); + fbb.addOffset(1, filterHashOffset); + fbb.addOffset(2, relayUrlOffset); + fbb.addInt64(3, object.rangeStart); + fbb.addInt64(4, object.rangeEnd); + fbb.finish(fbb.endTable()); + return object.dbId; + }, + objectFromFB: (obx.Store store, ByteData fbData) { + final buffer = fb.BufferContext(fbData); + final rootOffset = buffer.derefObject(0); + final filterHashParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 6, ''); + final relayUrlParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 8, ''); + final rangeStartParam = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 10, + 0, + ); + final rangeEndParam = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 12, + 0, + ); + final object = + DbFilterFetchedRangeRecord( + filterHash: filterHashParam, + relayUrl: relayUrlParam, + rangeStart: rangeStartParam, + rangeEnd: rangeEndParam, + ) + ..dbId = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 4, + 0, + ); + + return object; + }, + ), DbMetadata: obx_int.EntityDefinition( - model: _entities[1], + model: _entities[4], toOneRelations: (DbMetadata object) => [], toManyRelations: (DbMetadata object) => {}, getId: (DbMetadata object) => object.dbId, @@ -796,7 +1355,7 @@ obx_int.ModelDefinition getObjectBoxModel() { }, ), DbNip01Event: obx_int.EntityDefinition( - model: _entities[2], + model: _entities[5], toOneRelations: (DbNip01Event object) => [], toManyRelations: (DbNip01Event object) => {}, getId: (DbNip01Event object) => object.dbId, @@ -886,60 +1445,8 @@ obx_int.ModelDefinition getObjectBoxModel() { 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!); - final elementsOffset = fbb.writeList( - object.elements.map(fbb.writeString).toList(growable: false), - ); - fbb.startTable(6); - fbb.addInt64(0, object.id); - fbb.addOffset(1, keyOffset); - fbb.addOffset(2, valueOffset); - fbb.addOffset(3, markerOffset); - fbb.addOffset(4, elementsOffset); - 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 elementsParam = const fb.ListReader( - fb.StringReader(asciiOptimization: true), - lazy: false, - ).vTableGet(buffer, rootOffset, 12, []); - final object = DbTag( - key: keyParam, - value: valueParam, - marker: markerParam, - elements: elementsParam, - )..id = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); - - return object; - }, - ), DbNip05: obx_int.EntityDefinition( - model: _entities[4], + model: _entities[6], toOneRelations: (DbNip05 object) => [], toManyRelations: (DbNip05 object) => {}, getId: (DbNip05 object) => object.dbId, @@ -952,13 +1459,13 @@ obx_int.ModelDefinition getObjectBoxModel() { final relaysOffset = fbb.writeList( object.relays.map(fbb.writeString).toList(growable: false), ); - fbb.startTable(8); + fbb.startTable(7); 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.addInt64(4, object.networkFetchTime); + fbb.addOffset(5, relaysOffset); fbb.finish(fbb.endTable()); return object.dbId; }, @@ -980,12 +1487,12 @@ obx_int.ModelDefinition getObjectBoxModel() { final networkFetchTimeParam = const fb.Int64Reader().vTableGetNullable( buffer, rootOffset, - 14, + 12, ); final relaysParam = const fb.ListReader( fb.StringReader(asciiOptimization: true), lazy: false, - ).vTableGet(buffer, rootOffset, 16, []); + ).vTableGet(buffer, rootOffset, 14, []); final object = DbNip05( pubKey: pubKeyParam, nip05: nip05Param, @@ -997,8 +1504,129 @@ obx_int.ModelDefinition getObjectBoxModel() { return object; }, ), + DbRelaySet: obx_int.EntityDefinition( + model: _entities[7], + toOneRelations: (DbRelaySet object) => [], + toManyRelations: (DbRelaySet object) => {}, + getId: (DbRelaySet object) => object.dbId, + setId: (DbRelaySet object, int id) { + object.dbId = id; + }, + objectToFB: (DbRelaySet object, fb.Builder fbb) { + final idOffset = fbb.writeString(object.id); + final nameOffset = fbb.writeString(object.name); + final pubKeyOffset = fbb.writeString(object.pubKey); + final directionOffset = fbb.writeString(object.direction); + final relaysMapJsonOffset = fbb.writeString(object.relaysMapJson); + fbb.startTable(9); + fbb.addInt64(0, object.dbId); + fbb.addOffset(1, idOffset); + fbb.addOffset(2, nameOffset); + fbb.addOffset(3, pubKeyOffset); + fbb.addInt64(4, object.relayMinCountPerPubkey); + fbb.addOffset(5, directionOffset); + fbb.addOffset(6, relaysMapJsonOffset); + fbb.addBool(7, object.fallbackToBootstrapRelays); + 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 nameParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 8, ''); + final pubKeyParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 10, ''); + final relayMinCountPerPubkeyParam = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 12, + 0, + ); + final directionParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 14, ''); + final relaysMapJsonParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 16, ''); + final fallbackToBootstrapRelaysParam = const fb.BoolReader().vTableGet( + buffer, + rootOffset, + 18, + false, + ); + final object = DbRelaySet( + id: idParam, + name: nameParam, + pubKey: pubKeyParam, + relayMinCountPerPubkey: relayMinCountPerPubkeyParam, + direction: directionParam, + relaysMapJson: relaysMapJsonParam, + fallbackToBootstrapRelays: fallbackToBootstrapRelaysParam, + )..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }, + ), + DbTag: obx_int.EntityDefinition( + model: _entities[8], + 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!); + final elementsOffset = fbb.writeList( + object.elements.map(fbb.writeString).toList(growable: false), + ); + fbb.startTable(6); + fbb.addInt64(0, object.id); + fbb.addOffset(1, keyOffset); + fbb.addOffset(2, valueOffset); + fbb.addOffset(3, markerOffset); + fbb.addOffset(4, elementsOffset); + 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 elementsParam = const fb.ListReader( + fb.StringReader(asciiOptimization: true), + lazy: false, + ).vTableGet(buffer, rootOffset, 12, []); + final object = DbTag( + key: keyParam, + value: valueParam, + marker: markerParam, + elements: elementsParam, + )..id = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }, + ), DbUserRelayList: obx_int.EntityDefinition( - model: _entities[5], + model: _entities[9], toOneRelations: (DbUserRelayList object) => [], toManyRelations: (DbUserRelayList object) => {}, getId: (DbUserRelayList object) => object.dbId, @@ -1048,29 +1676,31 @@ obx_int.ModelDefinition getObjectBoxModel() { return object; }, ), - DbRelaySet: obx_int.EntityDefinition( - model: _entities[6], - toOneRelations: (DbRelaySet object) => [], - toManyRelations: (DbRelaySet object) => {}, - getId: (DbRelaySet object) => object.dbId, - setId: (DbRelaySet object, int id) { + DbWallet: obx_int.EntityDefinition( + model: _entities[10], + toOneRelations: (DbWallet object) => [], + toManyRelations: (DbWallet object) => {}, + getId: (DbWallet object) => object.dbId, + setId: (DbWallet object, int id) { object.dbId = id; }, - objectToFB: (DbRelaySet object, fb.Builder fbb) { + objectToFB: (DbWallet object, fb.Builder fbb) { final idOffset = fbb.writeString(object.id); + final typeOffset = fbb.writeString(object.type); + final supportedUnitsOffset = fbb.writeList( + object.supportedUnits.map(fbb.writeString).toList(growable: false), + ); final nameOffset = fbb.writeString(object.name); - final pubKeyOffset = fbb.writeString(object.pubKey); - final directionOffset = fbb.writeString(object.direction); - final relaysMapJsonOffset = fbb.writeString(object.relaysMapJson); - fbb.startTable(9); + final metadataJsonStringOffset = fbb.writeString( + object.metadataJsonString, + ); + fbb.startTable(7); fbb.addInt64(0, object.dbId); fbb.addOffset(1, idOffset); - fbb.addOffset(2, nameOffset); - fbb.addOffset(3, pubKeyOffset); - fbb.addInt64(4, object.relayMinCountPerPubkey); - fbb.addOffset(5, directionOffset); - fbb.addOffset(6, relaysMapJsonOffset); - fbb.addBool(7, object.fallbackToBootstrapRelays); + fbb.addOffset(2, typeOffset); + fbb.addOffset(3, supportedUnitsOffset); + fbb.addOffset(4, nameOffset); + fbb.addOffset(5, metadataJsonStringOffset); fbb.finish(fbb.endTable()); return object.dbId; }, @@ -1080,161 +1710,423 @@ obx_int.ModelDefinition getObjectBoxModel() { 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, 10, []); final nameParam = const fb.StringReader( asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 12, ''); + final metadataJsonStringParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGet(buffer, rootOffset, 14, ''); + final object = DbWallet( + id: idParam, + type: typeParam, + supportedUnits: supportedUnitsParam, + name: nameParam, + metadataJsonString: metadataJsonStringParam, + )..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }, + ), + DbWalletCahsuKeyset: obx_int.EntityDefinition( + model: _entities[11], + 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 pubKeyParam = const fb.StringReader( + final unitParam = const fb.StringReader( asciiOptimization: true, ).vTableGet(buffer, rootOffset, 10, ''); - final relayMinCountPerPubkeyParam = const fb.Int64Reader().vTableGet( + final activeParam = const fb.BoolReader().vTableGet( buffer, rootOffset, 12, + false, + ); + final inputFeePPKParam = const fb.Int64Reader().vTableGet( + buffer, + rootOffset, + 14, 0, ); - final directionParam = const fb.StringReader( + 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[12], + 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 relaysMapJsonParam = const fb.StringReader( + final object = DbWalletCashuProof( + keysetId: keysetIdParam, + amount: amountParam, + secret: secretParam, + unblindedSig: unblindedSigParam, + state: stateParam, + )..dbId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 4, 0); + + return object; + }, + ), + DbWalletTransaction: obx_int.EntityDefinition( + model: _entities[13], + 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 fallbackToBootstrapRelaysParam = const fb.BoolReader().vTableGet( + final completionMsgParam = const fb.StringReader( + asciiOptimization: true, + ).vTableGetNullable(buffer, rootOffset, 18); + final transactionDateParam = const fb.Int64Reader().vTableGetNullable( buffer, rootOffset, - 18, - false, + 20, ); - final object = DbRelaySet( + 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, - name: nameParam, - pubKey: pubKeyParam, - relayMinCountPerPubkey: relayMinCountPerPubkeyParam, - direction: directionParam, - relaysMapJson: relaysMapJsonParam, - fallbackToBootstrapRelays: fallbackToBootstrapRelaysParam, + 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; }, ), - DbFilterFetchedRangeRecord: - obx_int.EntityDefinition( - model: _entities[7], - toOneRelations: (DbFilterFetchedRangeRecord object) => [], - toManyRelations: (DbFilterFetchedRangeRecord object) => {}, - getId: (DbFilterFetchedRangeRecord object) => object.dbId, - setId: (DbFilterFetchedRangeRecord object, int id) { - object.dbId = id; - }, - objectToFB: (DbFilterFetchedRangeRecord object, fb.Builder fbb) { - final filterHashOffset = fbb.writeString(object.filterHash); - final relayUrlOffset = fbb.writeString(object.relayUrl); - fbb.startTable(6); - fbb.addInt64(0, object.dbId); - fbb.addOffset(1, filterHashOffset); - fbb.addOffset(2, relayUrlOffset); - fbb.addInt64(3, object.rangeStart); - fbb.addInt64(4, object.rangeEnd); - fbb.finish(fbb.endTable()); - return object.dbId; - }, - objectFromFB: (obx.Store store, ByteData fbData) { - final buffer = fb.BufferContext(fbData); - final rootOffset = buffer.derefObject(0); - final filterHashParam = const fb.StringReader( - asciiOptimization: true, - ).vTableGet(buffer, rootOffset, 6, ''); - final relayUrlParam = const fb.StringReader( - asciiOptimization: true, - ).vTableGet(buffer, rootOffset, 8, ''); - final rangeStartParam = const fb.Int64Reader().vTableGet( - buffer, - rootOffset, - 10, - 0, - ); - final rangeEndParam = const fb.Int64Reader().vTableGet( - buffer, - rootOffset, - 12, - 0, - ); - final object = - DbFilterFetchedRangeRecord( - filterHash: filterHashParam, - relayUrl: relayUrlParam, - rangeStart: rangeStartParam, - rangeEnd: rangeEndParam, - ) - ..dbId = const fb.Int64Reader().vTableGet( - buffer, - rootOffset, - 4, - 0, - ); + }; + + return obx_int.ModelDefinition(model, bindings); +} + +/// [DbCashuMintInfo] entity fields to define ObjectBox queries. +class DbCashuMintInfo_ { + /// See [DbCashuMintInfo.dbId]. + static final dbId = obx.QueryIntegerProperty( + _entities[0].properties[0], + ); + + /// See [DbCashuMintInfo.name]. + static final name = obx.QueryStringProperty( + _entities[0].properties[1], + ); + + /// See [DbCashuMintInfo.version]. + static final version = obx.QueryStringProperty( + _entities[0].properties[2], + ); + + /// See [DbCashuMintInfo.description]. + static final description = obx.QueryStringProperty( + _entities[0].properties[3], + ); + + /// See [DbCashuMintInfo.descriptionLong]. + static final descriptionLong = obx.QueryStringProperty( + _entities[0].properties[4], + ); + + /// See [DbCashuMintInfo.contactJson]. + static final contactJson = obx.QueryStringProperty( + _entities[0].properties[5], + ); + + /// See [DbCashuMintInfo.motd]. + static final motd = obx.QueryStringProperty( + _entities[0].properties[6], + ); + + /// See [DbCashuMintInfo.iconUrl]. + static final iconUrl = obx.QueryStringProperty( + _entities[0].properties[7], + ); + + /// See [DbCashuMintInfo.urls]. + static final urls = obx.QueryStringVectorProperty( + _entities[0].properties[8], + ); + + /// See [DbCashuMintInfo.time]. + static final time = obx.QueryIntegerProperty( + _entities[0].properties[9], + ); + + /// See [DbCashuMintInfo.tosUrl]. + static final tosUrl = obx.QueryStringProperty( + _entities[0].properties[10], + ); + + /// See [DbCashuMintInfo.nutsJson]. + static final nutsJson = obx.QueryStringProperty( + _entities[0].properties[11], + ); +} + +/// [DbCashuSecretCounter] entity fields to define ObjectBox queries. +class DbCashuSecretCounter_ { + /// See [DbCashuSecretCounter.dbId]. + static final dbId = obx.QueryIntegerProperty( + _entities[1].properties[0], + ); + + /// See [DbCashuSecretCounter.mintUrl]. + static final mintUrl = obx.QueryStringProperty( + _entities[1].properties[1], + ); - return object; - }, - ), - }; + /// See [DbCashuSecretCounter.keysetId]. + static final keysetId = obx.QueryStringProperty( + _entities[1].properties[2], + ); - return obx_int.ModelDefinition(model, bindings); + /// See [DbCashuSecretCounter.counter]. + static final counter = obx.QueryIntegerProperty( + _entities[1].properties[3], + ); } /// [DbContactList] entity fields to define ObjectBox queries. class DbContactList_ { /// See [DbContactList.dbId]. static final dbId = obx.QueryIntegerProperty( - _entities[0].properties[0], + _entities[2].properties[0], ); /// See [DbContactList.pubKey]. static final pubKey = obx.QueryStringProperty( - _entities[0].properties[1], + _entities[2].properties[1], ); /// See [DbContactList.contacts]. static final contacts = obx.QueryStringVectorProperty( - _entities[0].properties[2], + _entities[2].properties[2], ); /// See [DbContactList.contactRelays]. static final contactRelays = obx.QueryStringVectorProperty( - _entities[0].properties[3], + _entities[2].properties[3], ); /// See [DbContactList.petnames]. static final petnames = obx.QueryStringVectorProperty( - _entities[0].properties[4], + _entities[2].properties[4], ); /// See [DbContactList.followedTags]. static final followedTags = obx.QueryStringVectorProperty( - _entities[0].properties[5], + _entities[2].properties[5], ); /// See [DbContactList.followedCommunities]. static final followedCommunities = - obx.QueryStringVectorProperty(_entities[0].properties[6]); + obx.QueryStringVectorProperty(_entities[2].properties[6]); /// See [DbContactList.followedEvents]. static final followedEvents = obx.QueryStringVectorProperty( - _entities[0].properties[7], + _entities[2].properties[7], ); /// See [DbContactList.createdAt]. static final createdAt = obx.QueryIntegerProperty( - _entities[0].properties[8], + _entities[2].properties[8], ); /// See [DbContactList.loadedTimestamp]. static final loadedTimestamp = obx.QueryIntegerProperty( - _entities[0].properties[9], + _entities[2].properties[9], ); /// See [DbContactList.sources]. static final sources = obx.QueryStringVectorProperty( - _entities[0].properties[10], + _entities[2].properties[10], + ); +} + +/// [DbFilterFetchedRangeRecord] entity fields to define ObjectBox queries. +class DbFilterFetchedRangeRecord_ { + /// See [DbFilterFetchedRangeRecord.dbId]. + static final dbId = obx.QueryIntegerProperty( + _entities[3].properties[0], + ); + + /// See [DbFilterFetchedRangeRecord.filterHash]. + static final filterHash = obx.QueryStringProperty( + _entities[3].properties[1], + ); + + /// See [DbFilterFetchedRangeRecord.relayUrl]. + static final relayUrl = obx.QueryStringProperty( + _entities[3].properties[2], + ); + + /// See [DbFilterFetchedRangeRecord.rangeStart]. + static final rangeStart = + obx.QueryIntegerProperty( + _entities[3].properties[3], + ); + + /// See [DbFilterFetchedRangeRecord.rangeEnd]. + static final rangeEnd = obx.QueryIntegerProperty( + _entities[3].properties[4], ); } @@ -1242,76 +2134,76 @@ class DbContactList_ { class DbMetadata_ { /// See [DbMetadata.dbId]. static final dbId = obx.QueryIntegerProperty( - _entities[1].properties[0], + _entities[4].properties[0], ); /// See [DbMetadata.pubKey]. static final pubKey = obx.QueryStringProperty( - _entities[1].properties[1], + _entities[4].properties[1], ); /// See [DbMetadata.name]. static final name = obx.QueryStringProperty( - _entities[1].properties[2], + _entities[4].properties[2], ); /// See [DbMetadata.displayName]. static final displayName = obx.QueryStringProperty( - _entities[1].properties[3], + _entities[4].properties[3], ); /// See [DbMetadata.picture]. static final picture = obx.QueryStringProperty( - _entities[1].properties[4], + _entities[4].properties[4], ); /// See [DbMetadata.banner]. static final banner = obx.QueryStringProperty( - _entities[1].properties[5], + _entities[4].properties[5], ); /// See [DbMetadata.website]. static final website = obx.QueryStringProperty( - _entities[1].properties[6], + _entities[4].properties[6], ); /// See [DbMetadata.about]. static final about = obx.QueryStringProperty( - _entities[1].properties[7], + _entities[4].properties[7], ); /// See [DbMetadata.nip05]. static final nip05 = obx.QueryStringProperty( - _entities[1].properties[8], + _entities[4].properties[8], ); /// See [DbMetadata.lud16]. static final lud16 = obx.QueryStringProperty( - _entities[1].properties[9], + _entities[4].properties[9], ); /// See [DbMetadata.lud06]. static final lud06 = obx.QueryStringProperty( - _entities[1].properties[10], + _entities[4].properties[10], ); /// See [DbMetadata.updatedAt]. static final updatedAt = obx.QueryIntegerProperty( - _entities[1].properties[11], + _entities[4].properties[11], ); /// See [DbMetadata.refreshedTimestamp]. static final refreshedTimestamp = obx.QueryIntegerProperty( - _entities[1].properties[12], + _entities[4].properties[12], ); /// See [DbMetadata.splitDisplayNameWords]. static final splitDisplayNameWords = - obx.QueryStringVectorProperty(_entities[1].properties[13]); + obx.QueryStringVectorProperty(_entities[4].properties[13]); /// See [DbMetadata.splitNameWords]. static final splitNameWords = obx.QueryStringVectorProperty( - _entities[1].properties[14], + _entities[4].properties[14], ); } @@ -1319,76 +2211,52 @@ class DbMetadata_ { class DbNip01Event_ { /// See [DbNip01Event.dbId]. static final dbId = obx.QueryIntegerProperty( - _entities[2].properties[0], + _entities[5].properties[0], ); /// See [DbNip01Event.nostrId]. static final nostrId = obx.QueryStringProperty( - _entities[2].properties[1], + _entities[5].properties[1], ); /// See [DbNip01Event.pubKey]. static final pubKey = obx.QueryStringProperty( - _entities[2].properties[2], + _entities[5].properties[2], ); /// See [DbNip01Event.createdAt]. static final createdAt = obx.QueryIntegerProperty( - _entities[2].properties[3], + _entities[5].properties[3], ); /// See [DbNip01Event.kind]. static final kind = obx.QueryIntegerProperty( - _entities[2].properties[4], + _entities[5].properties[4], ); /// See [DbNip01Event.content]. static final content = obx.QueryStringProperty( - _entities[2].properties[5], + _entities[5].properties[5], ); /// See [DbNip01Event.sig]. static final sig = obx.QueryStringProperty( - _entities[2].properties[6], + _entities[5].properties[6], ); /// See [DbNip01Event.validSig]. static final validSig = obx.QueryBooleanProperty( - _entities[2].properties[7], + _entities[5].properties[7], ); /// See [DbNip01Event.sources]. static final sources = obx.QueryStringVectorProperty( - _entities[2].properties[8], + _entities[5].properties[8], ); /// See [DbNip01Event.dbTags]. static final dbTags = obx.QueryStringVectorProperty( - _entities[2].properties[9], - ); -} - -/// [DbTag] entity fields to define ObjectBox queries. -class DbTag_ { - /// See [DbTag.id]. - static final id = obx.QueryIntegerProperty(_entities[3].properties[0]); - - /// See [DbTag.key]. - static final key = obx.QueryStringProperty(_entities[3].properties[1]); - - /// See [DbTag.value]. - static final value = obx.QueryStringProperty( - _entities[3].properties[2], - ); - - /// See [DbTag.marker]. - static final marker = obx.QueryStringProperty( - _entities[3].properties[3], - ); - - /// See [DbTag.elements]. - static final elements = obx.QueryStringVectorProperty( - _entities[3].properties[4], + _entities[5].properties[9], ); } @@ -1396,32 +2264,99 @@ class DbTag_ { class DbNip05_ { /// See [DbNip05.dbId]. static final dbId = obx.QueryIntegerProperty( - _entities[4].properties[0], + _entities[6].properties[0], ); /// See [DbNip05.pubKey]. static final pubKey = obx.QueryStringProperty( - _entities[4].properties[1], + _entities[6].properties[1], ); /// See [DbNip05.nip05]. static final nip05 = obx.QueryStringProperty( - _entities[4].properties[2], + _entities[6].properties[2], ); /// See [DbNip05.valid]. static final valid = obx.QueryBooleanProperty( - _entities[4].properties[3], + _entities[6].properties[3], ); /// See [DbNip05.networkFetchTime]. static final networkFetchTime = obx.QueryIntegerProperty( - _entities[4].properties[4], + _entities[6].properties[4], ); /// See [DbNip05.relays]. static final relays = obx.QueryStringVectorProperty( - _entities[4].properties[5], + _entities[6].properties[5], + ); +} + +/// [DbRelaySet] entity fields to define ObjectBox queries. +class DbRelaySet_ { + /// See [DbRelaySet.dbId]. + static final dbId = obx.QueryIntegerProperty( + _entities[7].properties[0], + ); + + /// See [DbRelaySet.id]. + static final id = obx.QueryStringProperty( + _entities[7].properties[1], + ); + + /// See [DbRelaySet.name]. + static final name = obx.QueryStringProperty( + _entities[7].properties[2], + ); + + /// See [DbRelaySet.pubKey]. + static final pubKey = obx.QueryStringProperty( + _entities[7].properties[3], + ); + + /// See [DbRelaySet.relayMinCountPerPubkey]. + static final relayMinCountPerPubkey = obx.QueryIntegerProperty( + _entities[7].properties[4], + ); + + /// See [DbRelaySet.direction]. + static final direction = obx.QueryStringProperty( + _entities[7].properties[5], + ); + + /// See [DbRelaySet.relaysMapJson]. + static final relaysMapJson = obx.QueryStringProperty( + _entities[7].properties[6], + ); + + /// See [DbRelaySet.fallbackToBootstrapRelays]. + static final fallbackToBootstrapRelays = obx.QueryBooleanProperty( + _entities[7].properties[7], + ); +} + +/// [DbTag] entity fields to define ObjectBox queries. +class DbTag_ { + /// See [DbTag.id]. + static final id = obx.QueryIntegerProperty(_entities[8].properties[0]); + + /// See [DbTag.key]. + static final key = obx.QueryStringProperty(_entities[8].properties[1]); + + /// See [DbTag.value]. + static final value = obx.QueryStringProperty( + _entities[8].properties[2], + ); + + /// See [DbTag.marker]. + static final marker = obx.QueryStringProperty( + _entities[8].properties[3], + ); + + /// See [DbTag.elements]. + static final elements = obx.QueryStringVectorProperty( + _entities[8].properties[4], ); } @@ -1429,98 +2364,195 @@ class DbNip05_ { class DbUserRelayList_ { /// See [DbUserRelayList.dbId]. static final dbId = obx.QueryIntegerProperty( - _entities[5].properties[0], + _entities[9].properties[0], ); /// See [DbUserRelayList.pubKey]. static final pubKey = obx.QueryStringProperty( - _entities[5].properties[1], + _entities[9].properties[1], ); /// See [DbUserRelayList.createdAt]. static final createdAt = obx.QueryIntegerProperty( - _entities[5].properties[2], + _entities[9].properties[2], ); /// See [DbUserRelayList.refreshedTimestamp]. static final refreshedTimestamp = obx.QueryIntegerProperty( - _entities[5].properties[3], + _entities[9].properties[3], ); /// See [DbUserRelayList.relaysJson]. static final relaysJson = obx.QueryStringProperty( - _entities[5].properties[4], + _entities[9].properties[4], ); } -/// [DbRelaySet] entity fields to define ObjectBox queries. -class DbRelaySet_ { - /// See [DbRelaySet.dbId]. - static final dbId = obx.QueryIntegerProperty( - _entities[6].properties[0], +/// [DbWallet] entity fields to define ObjectBox queries. +class DbWallet_ { + /// See [DbWallet.dbId]. + static final dbId = obx.QueryIntegerProperty( + _entities[10].properties[0], ); - /// See [DbRelaySet.id]. - static final id = obx.QueryStringProperty( - _entities[6].properties[1], + /// See [DbWallet.id]. + static final id = obx.QueryStringProperty( + _entities[10].properties[1], ); - /// See [DbRelaySet.name]. - static final name = obx.QueryStringProperty( - _entities[6].properties[2], + /// See [DbWallet.type]. + static final type = obx.QueryStringProperty( + _entities[10].properties[2], ); - /// See [DbRelaySet.pubKey]. - static final pubKey = obx.QueryStringProperty( - _entities[6].properties[3], + /// See [DbWallet.supportedUnits]. + static final supportedUnits = obx.QueryStringVectorProperty( + _entities[10].properties[3], ); - /// See [DbRelaySet.relayMinCountPerPubkey]. - static final relayMinCountPerPubkey = obx.QueryIntegerProperty( - _entities[6].properties[4], + /// See [DbWallet.name]. + static final name = obx.QueryStringProperty( + _entities[10].properties[4], ); - /// See [DbRelaySet.direction]. - static final direction = obx.QueryStringProperty( - _entities[6].properties[5], + /// See [DbWallet.metadataJsonString]. + static final metadataJsonString = obx.QueryStringProperty( + _entities[10].properties[5], ); +} - /// See [DbRelaySet.relaysMapJson]. - static final relaysMapJson = obx.QueryStringProperty( - _entities[6].properties[6], +/// [DbWalletCahsuKeyset] entity fields to define ObjectBox queries. +class DbWalletCahsuKeyset_ { + /// See [DbWalletCahsuKeyset.dbId]. + static final dbId = obx.QueryIntegerProperty( + _entities[11].properties[0], ); - /// See [DbRelaySet.fallbackToBootstrapRelays]. - static final fallbackToBootstrapRelays = obx.QueryBooleanProperty( - _entities[6].properties[7], + /// See [DbWalletCahsuKeyset.id]. + static final id = obx.QueryStringProperty( + _entities[11].properties[1], ); -} -/// [DbFilterFetchedRangeRecord] entity fields to define ObjectBox queries. -class DbFilterFetchedRangeRecord_ { - /// See [DbFilterFetchedRangeRecord.dbId]. - static final dbId = obx.QueryIntegerProperty( - _entities[7].properties[0], + /// See [DbWalletCahsuKeyset.mintUrl]. + static final mintUrl = obx.QueryStringProperty( + _entities[11].properties[2], ); - /// See [DbFilterFetchedRangeRecord.filterHash]. - static final filterHash = obx.QueryStringProperty( - _entities[7].properties[1], + /// See [DbWalletCahsuKeyset.unit]. + static final unit = obx.QueryStringProperty( + _entities[11].properties[3], ); - /// See [DbFilterFetchedRangeRecord.relayUrl]. - static final relayUrl = obx.QueryStringProperty( - _entities[7].properties[2], + /// See [DbWalletCahsuKeyset.active]. + static final active = obx.QueryBooleanProperty( + _entities[11].properties[4], ); - /// See [DbFilterFetchedRangeRecord.rangeStart]. - static final rangeStart = - obx.QueryIntegerProperty( - _entities[7].properties[3], + /// See [DbWalletCahsuKeyset.inputFeePPK]. + static final inputFeePPK = obx.QueryIntegerProperty( + _entities[11].properties[5], + ); + + /// See [DbWalletCahsuKeyset.mintKeyPairs]. + static final mintKeyPairs = + obx.QueryStringVectorProperty( + _entities[11].properties[6], ); - /// See [DbFilterFetchedRangeRecord.rangeEnd]. - static final rangeEnd = obx.QueryIntegerProperty( - _entities[7].properties[4], + /// See [DbWalletCahsuKeyset.fetchedAt]. + static final fetchedAt = obx.QueryIntegerProperty( + _entities[11].properties[7], + ); +} + +/// [DbWalletCashuProof] entity fields to define ObjectBox queries. +class DbWalletCashuProof_ { + /// See [DbWalletCashuProof.dbId]. + static final dbId = obx.QueryIntegerProperty( + _entities[12].properties[0], + ); + + /// See [DbWalletCashuProof.keysetId]. + static final keysetId = obx.QueryStringProperty( + _entities[12].properties[1], + ); + + /// See [DbWalletCashuProof.amount]. + static final amount = obx.QueryIntegerProperty( + _entities[12].properties[2], + ); + + /// See [DbWalletCashuProof.secret]. + static final secret = obx.QueryStringProperty( + _entities[12].properties[3], + ); + + /// See [DbWalletCashuProof.unblindedSig]. + static final unblindedSig = obx.QueryStringProperty( + _entities[12].properties[4], + ); + + /// See [DbWalletCashuProof.state]. + static final state = obx.QueryStringProperty( + _entities[12].properties[5], + ); +} + +/// [DbWalletTransaction] entity fields to define ObjectBox queries. +class DbWalletTransaction_ { + /// See [DbWalletTransaction.dbId]. + static final dbId = obx.QueryIntegerProperty( + _entities[13].properties[0], + ); + + /// See [DbWalletTransaction.id]. + static final id = obx.QueryStringProperty( + _entities[13].properties[1], + ); + + /// See [DbWalletTransaction.walletId]. + static final walletId = obx.QueryStringProperty( + _entities[13].properties[2], ); + + /// See [DbWalletTransaction.changeAmount]. + static final changeAmount = obx.QueryIntegerProperty( + _entities[13].properties[3], + ); + + /// See [DbWalletTransaction.unit]. + static final unit = obx.QueryStringProperty( + _entities[13].properties[4], + ); + + /// See [DbWalletTransaction.walletType]. + static final walletType = obx.QueryStringProperty( + _entities[13].properties[5], + ); + + /// See [DbWalletTransaction.state]. + static final state = obx.QueryStringProperty( + _entities[13].properties[6], + ); + + /// See [DbWalletTransaction.completionMsg]. + static final completionMsg = obx.QueryStringProperty( + _entities[13].properties[7], + ); + + /// See [DbWalletTransaction.transactionDate]. + static final transactionDate = obx.QueryIntegerProperty( + _entities[13].properties[8], + ); + + /// See [DbWalletTransaction.initiatedDate]. + static final initiatedDate = obx.QueryIntegerProperty( + _entities[13].properties[9], + ); + + /// See [DbWalletTransaction.metadataJsonString]. + static final metadataJsonString = + obx.QueryStringProperty( + _entities[13].properties[10], + ); } diff --git a/packages/objectbox/pubspec.lock b/packages/objectbox/pubspec.lock index 6e531d095..59f1e029d 100644 --- a/packages/objectbox/pubspec.lock +++ b/packages/objectbox/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.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: dependency: transitive description: @@ -121,6 +154,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: @@ -291,6 +332,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: @@ -315,6 +364,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: @@ -569,6 +626,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: transitive description: @@ -726,6 +791,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/README.md b/packages/rust_verifier/README.md index 680c0421c..bb45bdd1c 100644 --- a/packages/rust_verifier/README.md +++ b/packages/rust_verifier/README.md @@ -75,4 +75,18 @@ cargo install flutter_rust_bridge_codegen && flutter_rust_bridge_codegen generat flutter_rust_bridge_codegen build-web ``` +if that fails, try + +```shell +flutter_rust_bridge_codegen build-web -c rust_builder/rust/ + +wasm-pack build --release --target no-modules --out-dir ../../web/pkg +``` + +https://github.com/fzyzcjy/flutter_rust_bridge/issues/2914#issuecomment-3478076794 + +```shell +flutter_rust_bridge_codegen build-web -c rust_builder/rust/ --wasm-pack-rustflags "-Ctarget-feature=+atomics -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base" +``` + RUN: `flutter run --web-header=Cross-Origin-Opener-Policy=same-origin --web-header=Cross-Origin-Embedder-Policy=require-corp` diff --git a/packages/rust_verifier/flutter_rust_bridge.yaml b/packages/rust_verifier/flutter_rust_bridge.yaml index efd4c6864..2d8b59f5b 100644 --- a/packages/rust_verifier/flutter_rust_bridge.yaml +++ b/packages/rust_verifier/flutter_rust_bridge.yaml @@ -1,3 +1,4 @@ rust_input: crate::api rust_root: rust_builder/rust/ -dart_output: lib/rust_bridge \ No newline at end of file +dart_output: lib/rust_bridge +web: true \ No newline at end of file diff --git a/packages/rust_verifier/lib/data_layer/repositories/cashu/rust_cashu_seed_secret_generator.dart b/packages/rust_verifier/lib/data_layer/repositories/cashu/rust_cashu_seed_secret_generator.dart new file mode 100644 index 000000000..f692dc4cb --- /dev/null +++ b/packages/rust_verifier/lib/data_layer/repositories/cashu/rust_cashu_seed_secret_generator.dart @@ -0,0 +1,34 @@ +import 'package:bip39_mnemonic/bip39_mnemonic.dart'; +import 'package:ndk/domain_layer/repositories/cashu_key_derivation.dart'; + +import 'package:ndk/domain_layer/usecases/cashu/cashu_seed.dart'; +import 'package:ndk_rust_verifier/rust_bridge/api/cashu_seed.dart'; +import '../rust_lib_initializer.dart'; + +class RustCashuKeyDerivation implements CashuKeyDerivation { + final RustLibInitializer _initializer = RustLibInitializer(); + + /// Creates a new instance of [RustCashuKeyDerivation] + RustCashuKeyDerivation(); + + @override + Future deriveSecret({ + required Mnemonic mnemonic, + required int counter, + required String keysetId, + }) async { + await _initializer.ensureInitialized(); + + final result = await deriveSecretRust( + seedPhrase: mnemonic.sentence, + passphrase: mnemonic.passphrase, + counter: counter, + keysetId: keysetId, + ); + + return CashuSeedDeriveSecretResult( + secretHex: result.secretHex, + blindingHex: result.blindingHex, + ); + } +} diff --git a/packages/rust_verifier/lib/data_layer/repositories/rust_lib_initializer.dart b/packages/rust_verifier/lib/data_layer/repositories/rust_lib_initializer.dart new file mode 100644 index 000000000..dafe65668 --- /dev/null +++ b/packages/rust_verifier/lib/data_layer/repositories/rust_lib_initializer.dart @@ -0,0 +1,26 @@ +import 'dart:async'; +import '../../rust_bridge/frb_generated.dart'; + +/// Singleton class to manage RustLib initialization +class RustLibInitializer { + static final RustLibInitializer _instance = RustLibInitializer._internal(); + final Completer _isInitialized = Completer(); + bool _initCalled = false; + + factory RustLibInitializer() { + return _instance; + } + + RustLibInitializer._internal(); + + /// Ensures RustLib is initialized. Safe to call multiple times. + Future ensureInitialized() async { + if (!_initCalled) { + _initCalled = true; + await RustLib.init(); + _isInitialized.complete(true); + } else { + await _isInitialized.future; + } + } +} diff --git a/packages/rust_verifier/lib/data_layer/repositories/verifiers/rust_event_verifier.dart b/packages/rust_verifier/lib/data_layer/repositories/verifiers/rust_event_verifier.dart index c15b91fb8..7a4fd8137 100644 --- a/packages/rust_verifier/lib/data_layer/repositories/verifiers/rust_event_verifier.dart +++ b/packages/rust_verifier/lib/data_layer/repositories/verifiers/rust_event_verifier.dart @@ -1,9 +1,7 @@ -import 'dart:async'; - import 'package:ndk/ndk.dart'; import '../../../rust_bridge/api/event_verifier.dart'; -import '../../../rust_bridge/frb_generated.dart'; +import '../rust_lib_initializer.dart'; /// An implementation of [EventVerifier] that uses Rust for event verification. /// @@ -11,29 +9,10 @@ import '../../../rust_bridge/frb_generated.dart'; /// verification of Nostr events using Rust's performance capabilities. /// The rust code runs in a separate isolate further increasing the the smoothness of the main thread. class RustEventVerifier implements EventVerifier { - /// A completer that tracks the initialization status of the Rust library - final Completer _isInitialized = Completer(); - static bool calledInit = false; - - /// Creates a new instance of [RustEventVerifier] and initializes the Rust library - RustEventVerifier() { - if (!calledInit) { - _init(); - calledInit = true; - } - } + final RustLibInitializer _initializer = RustLibInitializer(); - /// Initializes the Rust library. - /// - /// This method is called in the constructor and sets up the Rust environment - /// for event verification. - /// - /// Returns a [Future] that completes when initialization is done. - Future _init() async { - await RustLib.init(); - _isInitialized.complete(true); - return true; - } + /// Creates a new instance of [RustEventVerifier] + RustEventVerifier(); /// Verifies a Nostr event using the Rust implementation. /// @@ -47,7 +26,7 @@ class RustEventVerifier implements EventVerifier { @override Future verify(Nip01Event event) async { - await _isInitialized.future; + await _initializer.ensureInitialized(); if (event.sig == null) { return false; } diff --git a/packages/rust_verifier/lib/ndk_rust_verifier.dart b/packages/rust_verifier/lib/ndk_rust_verifier.dart index 91489b80b..136c7d21f 100644 --- a/packages/rust_verifier/lib/ndk_rust_verifier.dart +++ b/packages/rust_verifier/lib/ndk_rust_verifier.dart @@ -7,3 +7,6 @@ library; /// signers / verifiers export 'data_layer/repositories/verifiers/rust_event_verifier.dart'; + +/// cashu seed secret generator +export 'data_layer/repositories/cashu/rust_cashu_seed_secret_generator.dart'; diff --git a/packages/rust_verifier/lib/rust_bridge/api/cashu_seed.dart b/packages/rust_verifier/lib/rust_bridge/api/cashu_seed.dart new file mode 100644 index 000000000..17eff1d6c --- /dev/null +++ b/packages/rust_verifier/lib/rust_bridge/api/cashu_seed.dart @@ -0,0 +1,59 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import '../frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +/// Convert a keyset ID (hex string) to an integer for use in derivation path +/// Performs: BigInt.parse(keysetId, radix: 16) % (2^31 - 1) +Future keysetIdToInt({required String keysetId}) => + RustLib.instance.api.crateApiCashuSeedKeysetIdToInt(keysetId: keysetId); + +/// Generate a new BIP39 seed phrase +/// Returns a 24-word mnemonic by default (32 bytes of entropy) +Future generateSeedPhrase() => + RustLib.instance.api.crateApiCashuSeedGenerateSeedPhrase(); + +/// Derive a secret and blinding factor from a BIP39 seed phrase +/// +/// # Arguments +/// * `seed_phrase` - BIP39 mnemonic phrase (space-separated words) +/// * `passphrase` - Optional passphrase (use empty string if none) +/// * `counter` - Counter value for derivation +/// * `keyset_id` - Keyset ID as hex string +/// +/// # Returns +/// Result containing secret_hex and blinding_hex, or an error message +Future deriveSecretRust( + {required String seedPhrase, + required String passphrase, + required int counter, + required String keysetId}) => + RustLib.instance.api.crateApiCashuSeedDeriveSecretRust( + seedPhrase: seedPhrase, + passphrase: passphrase, + counter: counter, + keysetId: keysetId); + +class CashuSeedDeriveSecretResultRust { + final String secretHex; + final String blindingHex; + + const CashuSeedDeriveSecretResultRust({ + required this.secretHex, + required this.blindingHex, + }); + + @override + int get hashCode => secretHex.hashCode ^ blindingHex.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CashuSeedDeriveSecretResultRust && + runtimeType == other.runtimeType && + secretHex == other.secretHex && + blindingHex == other.blindingHex; +} diff --git a/packages/rust_verifier/lib/rust_bridge/frb_generated.dart b/packages/rust_verifier/lib/rust_bridge/frb_generated.dart index dd5c001ba..3d0ac86f2 100644 --- a/packages/rust_verifier/lib/rust_bridge/frb_generated.dart +++ b/packages/rust_verifier/lib/rust_bridge/frb_generated.dart @@ -3,6 +3,7 @@ // ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field +import 'api/cashu_seed.dart'; import 'api/event_verifier.dart'; import 'dart:async'; import 'dart:convert'; @@ -70,7 +71,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.11.1'; @override - int get rustContentHash => 786322520; + int get rustContentHash => 788124168; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -81,6 +82,14 @@ class RustLib extends BaseEntrypoint { } abstract class RustLibApi extends BaseApi { + Future crateApiCashuSeedDeriveSecretRust( + {required String seedPhrase, + required String passphrase, + required int counter, + required String keysetId}); + + Future crateApiCashuSeedGenerateSeedPhrase(); + Future crateApiEventVerifierHashEventData( {required String pubkey, required BigInt createdAt, @@ -90,6 +99,8 @@ abstract class RustLibApi extends BaseApi { Future crateApiEventVerifierInitApp(); + Future crateApiCashuSeedKeysetIdToInt({required String keysetId}); + Future crateApiEventVerifierVerifyNostrEvent( {required String eventIdHex, required String pubKeyHex, @@ -113,6 +124,62 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { required super.portManager, }); + @override + Future crateApiCashuSeedDeriveSecretRust( + {required String seedPhrase, + required String passphrase, + required int counter, + required String keysetId}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(seedPhrase, serializer); + sse_encode_String(passphrase, serializer); + sse_encode_u_32(counter, serializer); + sse_encode_String(keysetId, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 1, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_cashu_seed_derive_secret_result_rust, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiCashuSeedDeriveSecretRustConstMeta, + argValues: [seedPhrase, passphrase, counter, keysetId], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCashuSeedDeriveSecretRustConstMeta => + const TaskConstMeta( + debugName: "derive_secret_rust", + argNames: ["seedPhrase", "passphrase", "counter", "keysetId"], + ); + + @override + Future crateApiCashuSeedGenerateSeedPhrase() { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 2, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiCashuSeedGenerateSeedPhraseConstMeta, + argValues: [], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCashuSeedGenerateSeedPhraseConstMeta => + const TaskConstMeta( + debugName: "generate_seed_phrase", + argNames: [], + ); + @override Future crateApiEventVerifierHashEventData( {required String pubkey, @@ -129,7 +196,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_list_String(tags, serializer); sse_encode_String(content, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 1, port: port_); + funcId: 3, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -153,7 +220,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 2, port: port_); + funcId: 4, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -171,6 +238,31 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: [], ); + @override + Future crateApiCashuSeedKeysetIdToInt({required String keysetId}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(keysetId, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 5, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_u_32, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiCashuSeedKeysetIdToIntConstMeta, + argValues: [keysetId], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateApiCashuSeedKeysetIdToIntConstMeta => + const TaskConstMeta( + debugName: "keyset_id_to_int", + argNames: ["keysetId"], + ); + @override Future crateApiEventVerifierVerifyNostrEvent( {required String eventIdHex, @@ -191,7 +283,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(content, serializer); sse_encode_String(signatureHex, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 3, port: port_); + funcId: 6, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_bool, @@ -237,7 +329,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(eventIdHex, serializer); sse_encode_String(signatureHex, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 4, port: port_); + funcId: 7, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_bool, @@ -267,6 +359,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw as bool; } + @protected + CashuSeedDeriveSecretResultRust + dco_decode_cashu_seed_derive_secret_result_rust(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) + throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + return CashuSeedDeriveSecretResultRust( + secretHex: dco_decode_String(arr[0]), + blindingHex: dco_decode_String(arr[1]), + ); + } + @protected List dco_decode_list_String(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -291,6 +396,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw as int; } + @protected + int dco_decode_u_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + @protected BigInt dco_decode_u_64(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -322,6 +433,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return deserializer.buffer.getUint8() != 0; } + @protected + CashuSeedDeriveSecretResultRust + sse_decode_cashu_seed_derive_secret_result_rust( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_secretHex = sse_decode_String(deserializer); + var var_blindingHex = sse_decode_String(deserializer); + return CashuSeedDeriveSecretResultRust( + secretHex: var_secretHex, blindingHex: var_blindingHex); + } + @protected List sse_decode_list_String(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -359,6 +481,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return deserializer.buffer.getUint16(); } + @protected + int sse_decode_u_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint32(); + } + @protected BigInt sse_decode_u_64(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -394,6 +522,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { serializer.buffer.putUint8(self ? 1 : 0); } + @protected + void sse_encode_cashu_seed_derive_secret_result_rust( + CashuSeedDeriveSecretResultRust self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.secretHex, serializer); + sse_encode_String(self.blindingHex, serializer); + } + @protected void sse_encode_list_String(List self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -427,6 +563,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { serializer.buffer.putUint16(self); } + @protected + void sse_encode_u_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint32(self); + } + @protected void sse_encode_u_64(BigInt self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs diff --git a/packages/rust_verifier/lib/rust_bridge/frb_generated.io.dart b/packages/rust_verifier/lib/rust_bridge/frb_generated.io.dart index f41a8fd72..7c834829f 100644 --- a/packages/rust_verifier/lib/rust_bridge/frb_generated.io.dart +++ b/packages/rust_verifier/lib/rust_bridge/frb_generated.io.dart @@ -3,6 +3,7 @@ // ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field +import 'api/cashu_seed.dart'; import 'api/event_verifier.dart'; import 'dart:async'; import 'dart:convert'; @@ -24,6 +25,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected bool dco_decode_bool(dynamic raw); + @protected + CashuSeedDeriveSecretResultRust + dco_decode_cashu_seed_derive_secret_result_rust(dynamic raw); + @protected List dco_decode_list_String(dynamic raw); @@ -36,6 +41,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected int dco_decode_u_16(dynamic raw); + @protected + int dco_decode_u_32(dynamic raw); + @protected BigInt dco_decode_u_64(dynamic raw); @@ -51,6 +59,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected bool sse_decode_bool(SseDeserializer deserializer); + @protected + CashuSeedDeriveSecretResultRust + sse_decode_cashu_seed_derive_secret_result_rust( + SseDeserializer deserializer); + @protected List sse_decode_list_String(SseDeserializer deserializer); @@ -63,6 +76,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected int sse_decode_u_16(SseDeserializer deserializer); + @protected + int sse_decode_u_32(SseDeserializer deserializer); + @protected BigInt sse_decode_u_64(SseDeserializer deserializer); @@ -81,6 +97,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_bool(bool self, SseSerializer serializer); + @protected + void sse_encode_cashu_seed_derive_secret_result_rust( + CashuSeedDeriveSecretResultRust self, SseSerializer serializer); + @protected void sse_encode_list_String(List self, SseSerializer serializer); @@ -95,6 +115,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_u_16(int self, SseSerializer serializer); + @protected + void sse_encode_u_32(int self, SseSerializer serializer); + @protected void sse_encode_u_64(BigInt self, SseSerializer serializer); diff --git a/packages/rust_verifier/lib/rust_bridge/frb_generated.web.dart b/packages/rust_verifier/lib/rust_bridge/frb_generated.web.dart index dd8034db4..229ecd66d 100644 --- a/packages/rust_verifier/lib/rust_bridge/frb_generated.web.dart +++ b/packages/rust_verifier/lib/rust_bridge/frb_generated.web.dart @@ -6,6 +6,7 @@ // Static analysis wrongly picks the IO variant, thus ignore this // ignore_for_file: argument_type_not_assignable +import 'api/cashu_seed.dart'; import 'api/event_verifier.dart'; import 'dart:async'; import 'dart:convert'; @@ -26,6 +27,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected bool dco_decode_bool(dynamic raw); + @protected + CashuSeedDeriveSecretResultRust + dco_decode_cashu_seed_derive_secret_result_rust(dynamic raw); + @protected List dco_decode_list_String(dynamic raw); @@ -38,6 +43,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected int dco_decode_u_16(dynamic raw); + @protected + int dco_decode_u_32(dynamic raw); + @protected BigInt dco_decode_u_64(dynamic raw); @@ -53,6 +61,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected bool sse_decode_bool(SseDeserializer deserializer); + @protected + CashuSeedDeriveSecretResultRust + sse_decode_cashu_seed_derive_secret_result_rust( + SseDeserializer deserializer); + @protected List sse_decode_list_String(SseDeserializer deserializer); @@ -65,6 +78,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected int sse_decode_u_16(SseDeserializer deserializer); + @protected + int sse_decode_u_32(SseDeserializer deserializer); + @protected BigInt sse_decode_u_64(SseDeserializer deserializer); @@ -83,6 +99,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_bool(bool self, SseSerializer serializer); + @protected + void sse_encode_cashu_seed_derive_secret_result_rust( + CashuSeedDeriveSecretResultRust self, SseSerializer serializer); + @protected void sse_encode_list_String(List self, SseSerializer serializer); @@ -97,6 +117,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_u_16(int self, SseSerializer serializer); + @protected + void sse_encode_u_32(int self, SseSerializer serializer); + @protected void sse_encode_u_64(BigInt self, SseSerializer serializer); diff --git a/packages/rust_verifier/pubspec.lock b/packages/rust_verifier/pubspec.lock index 9c5266d0d..80df648a8 100644 --- a/packages/rust_verifier/pubspec.lock +++ b/packages/rust_verifier/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: "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,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: @@ -285,6 +326,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: transitive description: @@ -309,6 +358,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 @@ -489,6 +546,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" rust_lib_ndk: dependency: "direct main" description: @@ -597,6 +662,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.yaml b/packages/rust_verifier/pubspec.yaml index 30ada346e..210250558 100644 --- a/packages/rust_verifier/pubspec.yaml +++ b/packages/rust_verifier/pubspec.yaml @@ -21,12 +21,13 @@ dependencies: flutter_rust_bridge: ^2.11.1 rust_lib_ndk: ^0.1.7+1 ndk: ^0.7.1-dev.2 + bip39_mnemonic: ^4.0.1 -#dependency_overrides: +dependency_overrides: # rust_lib_ndk: # path: rust_builder -# ndk: -# path: ../ndk + ndk: + path: ../ndk dev_dependencies: flutter_test: diff --git a/packages/rust_verifier/rust_builder/rust/Cargo.lock b/packages/rust_verifier/rust_builder/rust/Cargo.lock index 6f7304015..ea5881a9c 100644 --- a/packages/rust_verifier/rust_builder/rust/Cargo.lock +++ b/packages/rust_verifier/rust_builder/rust/Cargo.lock @@ -60,6 +60,12 @@ version = "1.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "atomic" version = "0.5.3" @@ -99,6 +105,45 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "bip32" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db40d3dfbeab4e031d78c844642fa0caa0b0db11ce1607ac9d2986dff1405c69" +dependencies = [ + "bs58", + "hmac", + "k256", + "once_cell", + "pbkdf2", + "rand_core", + "ripemd", + "secp256k1", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "hex-conservative", +] + [[package]] name = "bitflags" version = "2.9.4" @@ -114,6 +159,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "sha2", +] + [[package]] name = "build-target" version = "0.4.0" @@ -498,6 +552,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + [[package]] name = "hmac" version = "0.12.1" @@ -639,6 +702,16 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pin-project-lite" version = "0.2.15" @@ -742,14 +815,26 @@ dependencies = [ "subtle", ] +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest", +] + [[package]] name = "rust_lib_ndk" version = "0.1.1" dependencies = [ + "bip32", + "bip39", "flutter_rust_bridge", "getrandom", "hex", "k256", + "rand_core", "serde_json", "sha2", ] @@ -792,6 +877,24 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secp256k1" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25996b82292a7a57ed3508f052cfff8640d38d32018784acd714758b43da9c8f" +dependencies = [ + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4473013577ec77b4ee3668179ef1186df3146e2cf2d927bd200974c6fe60fd99" +dependencies = [ + "cc", +] + [[package]] name = "serde" version = "1.0.214" @@ -902,6 +1005,21 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.41.0" @@ -924,6 +1042,15 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "version_check" version = "0.9.5" diff --git a/packages/rust_verifier/rust_builder/rust/Cargo.toml b/packages/rust_verifier/rust_builder/rust/Cargo.toml index 58b3ab0f4..7c533d21d 100644 --- a/packages/rust_verifier/rust_builder/rust/Cargo.toml +++ b/packages/rust_verifier/rust_builder/rust/Cargo.toml @@ -13,6 +13,9 @@ hex = "0.4.3" k256 = "0.13.4" # Pure Rust implementation sha2 = "0.10.8" serde_json = "1.0.128" +bip39 = "2.1.0" # Pure Rust BIP39 implementation +bip32 = "0.5.2" # Pure Rust BIP32 implementation +rand_core = "0.6" # For random number generation [target.'cfg(target_arch = "wasm32")'.dependencies] getrandom = { version = "0.2.16", features = ["js"] } # in later versions its wasm_js (feature name changed) diff --git a/packages/rust_verifier/rust_builder/rust/src/api/cashu_seed.rs b/packages/rust_verifier/rust_builder/rust/src/api/cashu_seed.rs new file mode 100644 index 000000000..4550c4446 --- /dev/null +++ b/packages/rust_verifier/rust_builder/rust/src/api/cashu_seed.rs @@ -0,0 +1,219 @@ +use bip32::{DerivationPath, ExtendedPrivateKey}; +use bip39::Mnemonic; +use hex; +use std::str::FromStr; + +pub struct CashuSeedDeriveSecretResultRust { + pub secret_hex: String, + pub blinding_hex: String, +} + +const DERIVATION_PURPOSE: u32 = 129372; +const DERIVATION_COIN_TYPE: u32 = 0; + +/// Convert a keyset ID (hex string) to an integer for use in derivation path +/// Performs: BigInt.parse(keysetId, radix: 16) % (2^31 - 1) +pub fn keyset_id_to_int(keyset_id: &str) -> Result { + // Parse hex string to u64 + let number = u64::from_str_radix(keyset_id, 16) + .map_err(|e| format!("Failed to parse keyset_id as hex: {}", e))?; + + // Modulus is 2^31 - 1 = 2147483647 + let modulus: u64 = 2147483647; + let keyset_id_int = (number % modulus) as u32; + + Ok(keyset_id_int) +} + +/// Generate a new BIP39 seed phrase +/// Returns a 24-word mnemonic by default (32 bytes of entropy) +pub fn generate_seed_phrase() -> Result { + use bip39::Language; + use rand_core::OsRng; + + let mut entropy = [0u8; 32]; // 32 bytes = 256 bits = 24 words + rand_core::RngCore::fill_bytes(&mut OsRng, &mut entropy); + + let mnemonic = Mnemonic::from_entropy_in(Language::English, &entropy) + .map_err(|e| format!("Failed to generate mnemonic: {}", e))?; + + Ok(mnemonic.to_string()) +} + +/// Derive a secret and blinding factor from a BIP39 seed phrase +/// +/// # Arguments +/// * `seed_phrase` - BIP39 mnemonic phrase (space-separated words) +/// * `passphrase` - Optional passphrase (use empty string if none) +/// * `counter` - Counter value for derivation +/// * `keyset_id` - Keyset ID as hex string +/// +/// # Returns +/// Result containing secret_hex and blinding_hex, or an error message +pub fn derive_secret_rust( + seed_phrase: &str, + passphrase: &str, + counter: u32, + keyset_id: &str, +) -> Result { + use bip32::ChildNumber; + use bip39::Language; + + // Parse the mnemonic + let mnemonic = Mnemonic::parse_in_normalized(Language::English, seed_phrase) + .map_err(|e| format!("Invalid seed phrase: {}", e))?; + + // Convert keyset_id to int + let keyset_id_int = keyset_id_to_int(keyset_id)?; + + // Generate seed from mnemonic (with optional passphrase) + let seed = mnemonic.to_seed(passphrase); + + // Create master key from seed + let master_key = ExtendedPrivateKey::::new(seed) + .map_err(|e| format!("Failed to create master key: {}", e))?; + + // Build derivation path components: m/129372'/0'/keyset_id_int'/counter' + // Constants are safe to unwrap; dynamic values use proper error handling + let path_components = [ + ChildNumber::new(DERIVATION_PURPOSE, true).unwrap(), + ChildNumber::new(DERIVATION_COIN_TYPE, true).unwrap(), + ChildNumber::new(keyset_id_int, true).map_err(|e| format!("Invalid keyset_id: {}", e))?, + ChildNumber::new(counter, true).map_err(|e| format!("Invalid counter: {}", e))?, + ]; + + // Derive common parent path once (avoiding duplicate derivations) + let mut parent_key = master_key; + for &child_number in &path_components { + parent_key = parent_key + .derive_child(child_number) + .map_err(|e| format!("Failed to derive parent key: {}", e))?; + } + + // Derive final keys (non-hardened /0 and /1) + let derived_secret = parent_key + .derive_child(ChildNumber::new(0, false).unwrap()) + .map_err(|e| format!("Failed to derive secret key: {}", e))?; + + let derived_blinding = parent_key + .derive_child(ChildNumber::new(1, false).unwrap()) + .map_err(|e| format!("Failed to derive blinding key: {}", e))?; + + // Get private key bytes and encode to hex + let secret_hex = hex::encode(derived_secret.private_key().to_bytes()); + let blinding_hex = hex::encode(derived_blinding.private_key().to_bytes()); + + Ok(CashuSeedDeriveSecretResultRust { + secret_hex, + blinding_hex, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_keyset_id_to_int() { + // Test a simple hex value + let result = keyset_id_to_int("1a").unwrap(); + assert_eq!(result, 26); + + // Test modulo operation with a large value + let large_hex = "FFFFFFFFFFFFFFFF"; // u64::MAX = 18446744073709551615 + let result = keyset_id_to_int(large_hex).unwrap(); + // 18446744073709551615 % 2147483647 = 3 + assert_eq!(result, 3); + } + + #[test] + fn test_generate_seed_phrase() { + use bip39::Language; + + let phrase = generate_seed_phrase().unwrap(); + // Should generate 24 words + assert_eq!(phrase.split_whitespace().count(), 24); + + // Should be valid + assert!(Mnemonic::parse_in_normalized(Language::English, &phrase).is_ok()); + } + + #[test] + fn test_derive_secret() { + // Use a known test mnemonic + let seed_phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let passphrase = ""; + let counter = 0; + let keyset_id = "1a"; + + let result = derive_secret_rust(seed_phrase, passphrase, counter, keyset_id); + assert!(result.is_ok()); + + let derived = result.unwrap(); + // Verify hex strings are valid and have correct length (64 chars = 32 bytes) + assert_eq!(derived.secret_hex.len(), 64); + assert_eq!(derived.blinding_hex.len(), 64); + assert!(hex::decode(&derived.secret_hex).is_ok()); + assert!(hex::decode(&derived.blinding_hex).is_ok()); + + // Test with different counter produces different results + let result2 = derive_secret_rust(seed_phrase, passphrase, 1, keyset_id).unwrap(); + assert_ne!(derived.secret_hex, result2.secret_hex); + assert_ne!(derived.blinding_hex, result2.blinding_hex); + } + + #[test] + fn test_derive_secret_with_passphrase() { + let seed_phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let result1 = derive_secret_rust(seed_phrase, "", 0, "1a").unwrap(); + let result2 = derive_secret_rust(seed_phrase, "test_passphrase", 0, "1a").unwrap(); + + // Different passphrase should produce different results + assert_ne!(result1.secret_hex, result2.secret_hex); + assert_ne!(result1.blinding_hex, result2.blinding_hex); + } + + #[test] + fn test_derive_secret_matches_dart_implementation() { + // Test values from the Dart implementation test + let mnemonic = + "half depart obvious quality work element tank gorilla view sugar picture humble"; + let keyset_id = "009a1f293253e41e"; + + // Expected values from Dart implementation + let expected_secrets = [ + "485875df74771877439ac06339e284c3acfcd9be7abf3bc20b516faeadfe77ae", + "8f2b39e8e594a4056eb1e6dbb4b0c38ef13b1b2c751f64f810ec04ee35b77270", + "bc628c79accd2364fd31511216a0fab62afd4a18ff77a20deded7b858c9860c8", + "59284fd1650ea9fa17db2b3acf59ecd0f2d52ec3261dd4152785813ff27a33bf", + "576c23393a8b31cc8da6688d9c9a96394ec74b40fdaf1f693a6bb84284334ea0", + ]; + + let expected_blinding_factors = [ + "ad00d431add9c673e843d4c2bf9a778a5f402b985b8da2d5550bf39cda41d679", + "967d5232515e10b81ff226ecf5a9e2e2aff92d66ebc3edf0987eb56357fd6248", + "b20f47bb6ae083659f3aa986bfa0435c55c6d93f687d51a01f26862d9b9a4899", + "fb5fca398eb0b1deb955a2988b5ac77d32956155f1c002a373535211a2dfdc29", + "5f09bfbfe27c439a597719321e061e2e40aad4a36768bb2bcc3de547c9644bf9", + ]; + + // Test keysetId conversion + let keyset_id_int = keyset_id_to_int(keyset_id).unwrap(); + assert_eq!(keyset_id_int, 864559728); + + // Test derivation for counters 0-4 + for i in 0..5 { + let result = derive_secret_rust(mnemonic, "", i, keyset_id).unwrap(); + assert_eq!( + result.secret_hex, expected_secrets[i as usize], + "Secret mismatch for counter {}", + i + ); + assert_eq!( + result.blinding_hex, expected_blinding_factors[i as usize], + "Blinding factor mismatch for counter {}", + i + ); + } + } +} diff --git a/packages/rust_verifier/rust_builder/rust/src/api/mod.rs b/packages/rust_verifier/rust_builder/rust/src/api/mod.rs index 081830ecc..1bcf8f0ca 100644 --- a/packages/rust_verifier/rust_builder/rust/src/api/mod.rs +++ b/packages/rust_verifier/rust_builder/rust/src/api/mod.rs @@ -1 +1,2 @@ -pub mod event_verifier; \ No newline at end of file +pub mod event_verifier; +pub mod cashu_seed; \ No newline at end of file diff --git a/packages/rust_verifier/rust_builder/rust/src/frb_generated.rs b/packages/rust_verifier/rust_builder/rust/src/frb_generated.rs index 1ce055919..435d3aaa3 100644 --- a/packages/rust_verifier/rust_builder/rust/src/frb_generated.rs +++ b/packages/rust_verifier/rust_builder/rust/src/frb_generated.rs @@ -37,7 +37,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 786322520; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 788124168; // Section: executor @@ -45,6 +45,79 @@ flutter_rust_bridge::frb_generated_default_handler!(); // Section: wire_funcs +fn wire__crate__api__cashu_seed__derive_secret_rust_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "derive_secret_rust", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_seed_phrase = ::sse_decode(&mut deserializer); + let api_passphrase = ::sse_decode(&mut deserializer); + let api_counter = ::sse_decode(&mut deserializer); + let api_keyset_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::cashu_seed::derive_secret_rust( + &api_seed_phrase, + &api_passphrase, + api_counter, + &api_keyset_id, + )?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__cashu_seed__generate_seed_phrase_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "generate_seed_phrase", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::cashu_seed::generate_seed_phrase()?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__event_verifier__hash_event_data_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -123,6 +196,39 @@ fn wire__crate__api__event_verifier__init_app_impl( }, ) } +fn wire__crate__api__cashu_seed__keyset_id_to_int_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "keyset_id_to_int", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_keyset_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::cashu_seed::keyset_id_to_int(&api_keyset_id)?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__event_verifier__verify_nostr_event_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -229,6 +335,18 @@ impl SseDecode for bool { } } +impl SseDecode for crate::api::cashu_seed::CashuSeedDeriveSecretResultRust { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_secretHex = ::sse_decode(deserializer); + let mut var_blindingHex = ::sse_decode(deserializer); + return crate::api::cashu_seed::CashuSeedDeriveSecretResultRust { + secret_hex: var_secretHex, + blinding_hex: var_blindingHex, + }; + } +} + impl SseDecode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -272,6 +390,13 @@ impl SseDecode for u16 { } } +impl SseDecode for u32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u32::().unwrap() + } +} + impl SseDecode for u64 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -307,20 +432,30 @@ fn pde_ffi_dispatcher_primary_impl( ) { // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { - 1 => wire__crate__api__event_verifier__hash_event_data_impl( + 1 => { + wire__crate__api__cashu_seed__derive_secret_rust_impl(port, ptr, rust_vec_len, data_len) + } + 2 => wire__crate__api__cashu_seed__generate_seed_phrase_impl( port, ptr, rust_vec_len, data_len, ), - 2 => wire__crate__api__event_verifier__init_app_impl(port, ptr, rust_vec_len, data_len), - 3 => wire__crate__api__event_verifier__verify_nostr_event_impl( + 3 => wire__crate__api__event_verifier__hash_event_data_impl( port, ptr, rust_vec_len, data_len, ), - 4 => wire__crate__api__event_verifier__verify_schnorr_signature_impl( + 4 => wire__crate__api__event_verifier__init_app_impl(port, ptr, rust_vec_len, data_len), + 5 => wire__crate__api__cashu_seed__keyset_id_to_int_impl(port, ptr, rust_vec_len, data_len), + 6 => wire__crate__api__event_verifier__verify_nostr_event_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 7 => wire__crate__api__event_verifier__verify_schnorr_signature_impl( port, ptr, rust_vec_len, @@ -344,6 +479,28 @@ fn pde_ffi_dispatcher_sync_impl( // Section: rust2dart +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::cashu_seed::CashuSeedDeriveSecretResultRust { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.secret_hex.into_into_dart().into_dart(), + self.blinding_hex.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::cashu_seed::CashuSeedDeriveSecretResultRust +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::cashu_seed::CashuSeedDeriveSecretResultRust +{ + fn into_into_dart(self) -> crate::api::cashu_seed::CashuSeedDeriveSecretResultRust { + self + } +} + impl SseEncode for String { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -358,6 +515,14 @@ impl SseEncode for bool { } } +impl SseEncode for crate::api::cashu_seed::CashuSeedDeriveSecretResultRust { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.secret_hex, serializer); + ::sse_encode(self.blinding_hex, serializer); + } +} + impl SseEncode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -395,6 +560,13 @@ impl SseEncode for u16 { } } +impl SseEncode for u32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u32::(self).unwrap(); + } +} + impl SseEncode for u64 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { diff --git a/packages/rust_verifier/web/pkg/rust_lib_ndk.js b/packages/rust_verifier/web/pkg/rust_lib_ndk.js index d78751138..4b59f3dfd 100644 --- a/packages/rust_verifier/web/pkg/rust_lib_ndk.js +++ b/packages/rust_verifier/web/pkg/rust_lib_ndk.js @@ -291,16 +291,25 @@ let wasm_bindgen; }; /** - * # Safety - * - * This should never be called manually. - * @param {any} handle - * @param {any} dart_handler_port - * @returns {number} + * @param {number} ptr */ - __exports.frb_dart_opaque_dart2rust_encode = function(handle, dart_handler_port) { - const ret = wasm.frb_dart_opaque_dart2rust_encode(handle, dart_handler_port); - return ret >>> 0; + __exports.frb_dart_opaque_drop_thread_box_persistent_handle = function(ptr) { + _assertNum(ptr); + wasm.frb_dart_opaque_drop_thread_box_persistent_handle(ptr); + }; + + /** + * @param {number} ptr + * @returns {any} + */ + __exports.frb_dart_opaque_rust2dart_decode = function(ptr) { + _assertNum(ptr); + const ret = wasm.frb_dart_opaque_rust2dart_decode(ptr); + return ret; + }; + + __exports.wasm_start_callback = function() { + wasm.wasm_start_callback(); }; function passArrayJsValueToWasm0(array, malloc) { @@ -338,37 +347,28 @@ let wasm_bindgen; }; /** - * @param {number} ptr - * @returns {any} - */ - __exports.frb_dart_opaque_rust2dart_decode = function(ptr) { - _assertNum(ptr); - const ret = wasm.frb_dart_opaque_rust2dart_decode(ptr); - return ret; - }; - - /** - * @param {number} ptr + * # Safety + * + * This should never be called manually. + * @param {any} handle + * @param {any} dart_handler_port + * @returns {number} */ - __exports.frb_dart_opaque_drop_thread_box_persistent_handle = function(ptr) { - _assertNum(ptr); - wasm.frb_dart_opaque_drop_thread_box_persistent_handle(ptr); - }; - - __exports.wasm_start_callback = function() { - wasm.wasm_start_callback(); + __exports.frb_dart_opaque_dart2rust_encode = function(handle, dart_handler_port) { + const ret = wasm.frb_dart_opaque_dart2rust_encode(handle, dart_handler_port); + return ret >>> 0; }; - function __wbg_adapter_8(arg0, arg1, arg2) { + function __wbg_adapter_6(arg0, arg1, arg2) { _assertNum(arg0); _assertNum(arg1); - wasm.closure68_externref_shim(arg0, arg1, arg2); + wasm.closure44_externref_shim(arg0, arg1, arg2); } - function __wbg_adapter_11(arg0, arg1, arg2) { + function __wbg_adapter_15(arg0, arg1, arg2) { _assertNum(arg0); _assertNum(arg1); - wasm.closure28_externref_shim(arg0, arg1, arg2); + wasm.closure81_externref_shim(arg0, arg1, arg2); } const WorkerPoolFinalization = (typeof FinalizationRegistry === 'undefined') @@ -492,6 +492,10 @@ let wasm_bindgen; const ret = arg0.call(arg1); return ret; }, arguments) }; + imports.wbg.__wbg_call_a5400b25a865cfd8 = function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.call(arg1, arg2); + return ret; + }, arguments) }; imports.wbg.__wbg_createObjectURL_e7c66c573508d0c2 = function() { return handleError(function (arg0, arg1) { const ret = URL.createObjectURL(arg1); const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); @@ -499,6 +503,10 @@ let wasm_bindgen; getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); }, arguments) }; + imports.wbg.__wbg_crypto_574e78ad8b13b65f = function() { return logError(function (arg0) { + const ret = arg0.crypto; + return ret; + }, arguments) }; imports.wbg.__wbg_data_2882c202e16286bf = function() { return logError(function (arg0) { const ret = arg0.data; return ret; @@ -521,6 +529,9 @@ let wasm_bindgen; const ret = eval(getStringFromWasm0(arg0, arg1)); return ret; }, arguments) }; + imports.wbg.__wbg_getRandomValues_b8f5dbd5f3995a9e = function() { return handleError(function (arg0, arg1) { + arg0.getRandomValues(arg1); + }, arguments) }; imports.wbg.__wbg_get_458e874b43b18b25 = function() { return handleError(function (arg0, arg1) { const ret = Reflect.get(arg0, arg1); return ret; @@ -584,6 +595,10 @@ let wasm_bindgen; getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); }, arguments) }; + imports.wbg.__wbg_msCrypto_a61aeb35a24c1329 = function() { return logError(function (arg0) { + const ret = arg0.msCrypto; + return ret; + }, arguments) }; imports.wbg.__wbg_name_03510748a8653cae = function() { return logError(function (arg0, arg1) { const ret = arg1.name; const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); @@ -627,6 +642,14 @@ let wasm_bindgen; const ret = new Blob(arg0, arg1); return ret; }, arguments) }; + imports.wbg.__wbg_newwithlength_a167dcc7aaa3ba77 = function() { return logError(function (arg0) { + const ret = new Uint8Array(arg0 >>> 0); + return ret; + }, arguments) }; + imports.wbg.__wbg_node_905d3e251edff8a2 = function() { return logError(function (arg0) { + const ret = arg0.node; + return ret; + }, arguments) }; imports.wbg.__wbg_postMessage_33814d4dc32c2dcf = function() { return handleError(function (arg0, arg1) { arg0.postMessage(arg1); }, arguments) }; @@ -639,6 +662,10 @@ let wasm_bindgen; imports.wbg.__wbg_postMessage_9c6960e5f36fbcef = function() { return handleError(function (arg0, arg1) { arg0.postMessage(arg1); }, arguments) }; + imports.wbg.__wbg_process_dc0fbacc7c1c06f7 = function() { return logError(function (arg0) { + const ret = arg0.process; + return ret; + }, arguments) }; imports.wbg.__wbg_prototypesetcall_3d4a26c1ed734349 = function() { return logError(function (arg0, arg1, arg2) { Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2); }, arguments) }; @@ -647,6 +674,13 @@ let wasm_bindgen; _assertNum(ret); return ret; }, arguments) }; + imports.wbg.__wbg_randomFillSync_ac0988aba3254290 = function() { return handleError(function (arg0, arg1) { + arg0.randomFillSync(arg1); + }, arguments) }; + imports.wbg.__wbg_require_60cc747a6bc5215a = function() { return handleError(function () { + const ret = module.require; + return ret; + }, arguments) }; imports.wbg.__wbg_set_453345bcda80b89a = function() { return handleError(function (arg0, arg1, arg2) { const ret = Reflect.set(arg0, arg1, arg2); _assertBoolean(ret); @@ -684,11 +718,19 @@ let wasm_bindgen; const ret = typeof window === 'undefined' ? null : window; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); }, arguments) }; + imports.wbg.__wbg_subarray_70fd07feefe14294 = function() { return logError(function (arg0, arg1, arg2) { + const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0); + return ret; + }, arguments) }; imports.wbg.__wbg_unshift_18d353edeebf9a72 = function() { return logError(function (arg0, arg1) { const ret = arg0.unshift(arg1); _assertNum(ret); return ret; }, arguments) }; + imports.wbg.__wbg_versions_c01dfd4722a88165 = function() { return logError(function (arg0) { + const ret = arg0.versions; + return ret; + }, arguments) }; imports.wbg.__wbg_wbindgencbdrop_eb10308566512b88 = function(arg0) { const obj = arg0.original; if (obj.cnt-- == 1) { @@ -711,6 +753,22 @@ let wasm_bindgen; _assertBoolean(ret); return ret; }; + imports.wbg.__wbg_wbindgenisfunction_8cee7dce3725ae74 = function(arg0) { + const ret = typeof(arg0) === 'function'; + _assertBoolean(ret); + return ret; + }; + imports.wbg.__wbg_wbindgenisobject_307a53c6bd97fbf8 = function(arg0) { + const val = arg0; + const ret = typeof(val) === 'object' && val !== null; + _assertBoolean(ret); + return ret; + }; + imports.wbg.__wbg_wbindgenisstring_d4fa939789f003b0 = function(arg0) { + const ret = typeof(arg0) === 'string'; + _assertBoolean(ret); + return ret; + }; imports.wbg.__wbg_wbindgenisundefined_c4b71d073b92f3c5 = function(arg0) { const ret = arg0 === undefined; _assertBoolean(ret); @@ -749,26 +807,31 @@ let wasm_bindgen; imports.wbg.__wbg_wbindgenthrow_451ec1a8469d7eb6 = function(arg0, arg1) { throw new Error(getStringFromWasm0(arg0, arg1)); }; - imports.wbg.__wbindgen_cast_10f20fef10ce98ca = function() { return logError(function (arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 66, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 68, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, 66, __wbg_adapter_8); - return ret; - }, arguments) }; imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function() { return logError(function (arg0, arg1) { // Cast intrinsic for `Ref(String) -> Externref`. const ret = getStringFromWasm0(arg0, arg1); return ret; }, arguments) }; + imports.wbg.__wbindgen_cast_c821b8d28ac8e93b = function() { return logError(function (arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 43, function: Function { arguments: [NamedExternref("Event")], shim_idx: 44, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, 43, __wbg_adapter_6); + return ret; + }, arguments) }; + imports.wbg.__wbindgen_cast_c88cb7f7a31b61bc = function() { return logError(function (arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 79, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 81, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, 79, __wbg_adapter_15); + return ret; + }, arguments) }; + imports.wbg.__wbindgen_cast_cb9088102bce6b30 = function() { return logError(function (arg0, arg1) { + // Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`. + const ret = getArrayU8FromWasm0(arg0, arg1); + return ret; + }, arguments) }; imports.wbg.__wbindgen_cast_d6cd19b81560fd6e = function() { return logError(function (arg0) { // Cast intrinsic for `F64 -> Externref`. const ret = arg0; return ret; }, arguments) }; - imports.wbg.__wbindgen_cast_f97e3e90481eb105 = function() { return logError(function (arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 40, function: Function { arguments: [NamedExternref("Event")], shim_idx: 28, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, 40, __wbg_adapter_11); - return ret; - }, arguments) }; imports.wbg.__wbindgen_init_externref_table = function() { const table = wasm.__wbindgen_export_2; const offset = table.grow(4); @@ -784,7 +847,7 @@ let wasm_bindgen; } function __wbg_init_memory(imports, memory) { - imports.wbg.memory = memory || new WebAssembly.Memory({initial:20,maximum:16384,shared:true}); + imports.wbg.memory = memory || new WebAssembly.Memory({initial:23,maximum:16384,shared:true}); } function __wbg_finalize_init(instance, module, thread_stack_size) { diff --git a/packages/rust_verifier/web/pkg/rust_lib_ndk_bg.wasm b/packages/rust_verifier/web/pkg/rust_lib_ndk_bg.wasm index f71d19052..1b7baa2ca 100644 Binary files a/packages/rust_verifier/web/pkg/rust_lib_ndk_bg.wasm and b/packages/rust_verifier/web/pkg/rust_lib_ndk_bg.wasm differ 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 af6e83770..d5b3fcd2b 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, ), @@ -136,6 +142,8 @@ class _MyHomePageState extends State const Tab(text: nwcTabName), const Tab(text: "Blossom"), const Tab(text: 'Verifiers'), + const Tab(text: 'Query Performance'), + const Tab(text: "Wallets"), //const Tab(text: 'Query Performance'), // Conditionally add Amber tab if it's part of the design // For a fixed length of 6, ensure this list matches. @@ -160,7 +168,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(() { @@ -248,6 +256,8 @@ class _MyHomePageState extends State const Tab(text: nwcTabName), const Tab(text: "Blossom"), const Tab(text: 'Verifiers'), + const Tab(text: 'Query Performance'), + const Tab(text: "Wallets"), //const Tab(text: 'Query Performance'), // Amber tab removed ]; @@ -259,6 +269,10 @@ class _MyHomePageState extends State const NwcPage(), BlossomMediaPage(ndk: ndk), VerifiersPerformancePage(ndk: ndk), + QueryPerformancePage(ndk: ndk), + WalletsPage( + ndk: ndk, + ), //QueryPerformancePage(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 1caaec5a8..590cafbd4 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: @@ -224,6 +265,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" + hex: + dependency: transitive + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" http: dependency: transitive description: @@ -240,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: @@ -406,21 +463,21 @@ packages: path: "../ndk" relative: true source: path - version: "0.7.0" + version: "0.7.1-dev.2" ndk_amber: dependency: "direct main" description: path: "../amber" relative: true source: path - version: "0.4.0" + version: "0.4.0-dev.2" ndk_rust_verifier: dependency: "direct main" description: path: "../rust_verifier" relative: true source: path - version: "0.5.0" + version: "0.5.0-dev.2" nested: dependency: transitive description: @@ -786,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 9672cc05e..20dd81d5f 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 @@ -261,3 +262,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 b8f268953..f373ee479 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'; @@ -33,6 +34,13 @@ class SembastCacheManager extends CacheManager { late final sembast.StoreRef> _filterFetchedRangeStore; + 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'); @@ -40,6 +48,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'); _filterFetchedRangeStore = sembast.stringMapStoreFactory.store('filter_fetched_ranges'); } @@ -505,4 +520,265 @@ class SembastCacheManager extends CacheManager { Future removeAllFilterFetchedRangeRecords() async { await _filterFetchedRangeStore.delete(_database); } + + // ===================== + // cashu / wallets + // ===================== + + @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, + }); + } }